diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ceb8519 --- /dev/null +++ b/.env.example @@ -0,0 +1,46 @@ +APP_ENV=development +APP_HOST=127.0.0.1 +APP_PORT=8000 +# Development-only local credentials. Override for any shared or remote environment. +DATABASE_URL=postgresql://alicebot_app:alicebot_app@localhost:5432/alicebot +DATABASE_ADMIN_URL=postgresql://alicebot_admin:alicebot_admin@localhost:5432/alicebot +REDIS_URL=redis://localhost:6379/0 +S3_ENDPOINT_URL=http://localhost:9000 +S3_ACCESS_KEY=alicebot +S3_SECRET_KEY=alicebot-secret +S3_BUCKET=alicebot-local +HEALTHCHECK_TIMEOUT_SECONDS=2 +TASK_WORKSPACE_ROOT=/tmp/alicebot/task-workspaces +# Server-side authenticated user binding for /v0 requests. +ALICEBOT_AUTH_USER_ID=00000000-0000-0000-0000-000000000001 +# Default sample-data fixture consumed by ./scripts/load_sample_data.sh. +PUBLIC_SAMPLE_DATA_PATH=fixtures/public_sample_data/continuity_v1.json +# Per-user response generation throttle (POST /v0/responses). +RESPONSE_RATE_LIMIT_WINDOW_SECONDS=60 +RESPONSE_RATE_LIMIT_MAX_REQUESTS=20 +# Hosted auth and webhook ingress throttles. +MAGIC_LINK_START_RATE_LIMIT_WINDOW_SECONDS=300 +MAGIC_LINK_START_RATE_LIMIT_MAX_REQUESTS=5 +MAGIC_LINK_VERIFY_RATE_LIMIT_WINDOW_SECONDS=300 +MAGIC_LINK_VERIFY_RATE_LIMIT_MAX_REQUESTS=10 +TELEGRAM_WEBHOOK_RATE_LIMIT_WINDOW_SECONDS=60 +TELEGRAM_WEBHOOK_RATE_LIMIT_MAX_REQUESTS=120 +# Telegram transport defaults. +TELEGRAM_LINK_TTL_SECONDS=600 +TELEGRAM_BOT_USERNAME=alicebot +TELEGRAM_WEBHOOK_SECRET= +TELEGRAM_BOT_TOKEN= +# Browser security posture. +CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 +CORS_ALLOWED_METHODS=GET,POST,PUT,PATCH,DELETE,OPTIONS +CORS_ALLOWED_HEADERS=Authorization,Content-Type,X-AliceBot-User-Id,X-Telegram-Bot-Api-Secret-Token +CORS_ALLOW_CREDENTIALS=false +CORS_PREFLIGHT_MAX_AGE_SECONDS=600 +SECURITY_HEADERS_ENABLED=true +SECURITY_HEADERS_HSTS_MAX_AGE_SECONDS=31536000 +SECURITY_HEADERS_HSTS_INCLUDE_SUBDOMAINS=true +# Proxy and ingress trust boundaries. +TRUST_PROXY_HEADERS=false +TRUSTED_PROXY_IPS= +# Entrypoint abuse-control backend. +ENTRYPOINT_RATE_LIMIT_BACKEND=redis diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..d26de4e --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,21 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + + - package-ecosystem: pip + directory: / + schedule: + interval: weekly + + - package-ecosystem: npm + directory: /packages/alice-core + schedule: + interval: weekly + + - package-ecosystem: npm + directory: /packages/alice-cli + schedule: + interval: weekly diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml new file mode 100644 index 0000000..83d2b97 --- /dev/null +++ b/.github/workflows/publish-npm.yml @@ -0,0 +1,113 @@ +name: Publish NPM Packages + +on: + push: + tags: + - "v*" + +permissions: + contents: read + +concurrency: + group: publish-npm-${{ github.ref }} + cancel-in-progress: false + +jobs: + publish-core: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + registry-url: https://registry.npmjs.org + + - name: Resolve core version + id: core_meta + working-directory: packages/alice-core + run: | + version=$(npm pkg get version --json | tr -d '"' | tr -d '\n') + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Validate core version output + run: | + if [ -z "${{ steps.core_meta.outputs.version }}" ]; then + echo "Core version output is empty." + exit 1 + fi + + - name: Check if core version is already published + id: core_exists + run: | + if npm view @aliceos/alice-core@${{ steps.core_meta.outputs.version }} version >/dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Publish @aliceos/alice-core + if: steps.core_exists.outputs.exists == 'false' + working-directory: packages/alice-core + run: npm publish --access public --provenance + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Skip core publish (already exists) + if: steps.core_exists.outputs.exists == 'true' + run: echo "@aliceos/alice-core@${{ steps.core_meta.outputs.version }} already published; skipping." + + publish-cli: + runs-on: ubuntu-latest + needs: publish-core + permissions: + contents: read + id-token: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + registry-url: https://registry.npmjs.org + + - name: Resolve cli version + id: cli_meta + working-directory: packages/alice-cli + run: | + version=$(npm pkg get version --json | tr -d '"' | tr -d '\n') + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Validate cli version output + run: | + if [ -z "${{ steps.cli_meta.outputs.version }}" ]; then + echo "CLI version output is empty." + exit 1 + fi + + - name: Check if cli version is already published + id: cli_exists + run: | + if npm view @aliceos/alice-cli@${{ steps.cli_meta.outputs.version }} version >/dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Publish @aliceos/alice-cli + if: steps.cli_exists.outputs.exists == 'false' + working-directory: packages/alice-cli + run: npm publish --access public --provenance + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Skip cli publish (already exists) + if: steps.cli_exists.outputs.exists == 'true' + run: echo "@aliceos/alice-cli@${{ steps.cli_meta.outputs.version }} already published; skipping." diff --git a/.github/workflows/security-scans.yml b/.github/workflows/security-scans.yml new file mode 100644 index 0000000..78e8cfa --- /dev/null +++ b/.github/workflows/security-scans.yml @@ -0,0 +1,57 @@ +name: Security Scans + +on: + pull_request: + push: + branches: + - main + schedule: + - cron: "23 3 * * 1" + +permissions: + contents: read + +jobs: + secrets: + name: Secrets Scan (Gitleaks) + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Run gitleaks + uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + codeql: + name: CodeQL (${{ matrix.language }}) + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + language: + - python + - javascript + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + - name: Analyze + uses: github/codeql-action/analyze@v3 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c38c4bd --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +.DS_Store +.env +.pytest_cache/ +.venv/ +*.egg-info/ +__pycache__/ +*.pyc +apps/web/.next/ +apps/web/node_modules/ +artifacts/ + +# Internal operating docs (keep local, exclude from public repo) +.ai/ +BUILD_REPORT.md +REVIEW_REPORT.md +ARCHIVE_RECOMMENDATIONS.md +RECOMMENDED_ADRS.md + +# Internal planning/process docs (keep local, exclude from public repo) +docs/archive/planning/ +docs/archive/sprints/ +docs/phase5-sprint-17-20-plan.md +docs/phase8-sprint-29-32-plan.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..73c8ef4 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,177 @@ +# Architecture + +## System Overview + +Phase 10 keeps the shipped Phase 9 modular monolith and adds a hosted product layer on top of the same continuity core. Alice Core remains authoritative for continuity objects, recall, resume, corrections, approvals, and provenance-backed retrieval. Hosted identity, Telegram, and scheduling orchestrate access to that core; they do not create a second semantics stack. + +## Technical Stack + +- API and core runtime: Python 3.12 + FastAPI under `apps/api/src/alicebot_api` +- Web app: Next.js 15 + React 19 under `apps/web` +- Background/task surface: `workers` +- Primary data store: Postgres with `pgvector` +- Local support services: Redis and MinIO via `docker-compose.yml` +- Packaging: `alice-core` in `pyproject.toml` +- Test surface: pytest, Vitest, and the Phase 9 evaluation harness + +## Runtime Boundaries + +### Core Data Plane + +Owns: + +- continuity capture and revision persistence +- typed continuity objects and memory revisions +- recall and resumption compilation +- entities, edges, and open loops +- approvals and audit traces +- CLI and MCP semantics +- importer provenance and deterministic dedupe + +### Hosted Control Plane + +Owns: + +- user accounts and auth sessions +- devices and trust levels +- workspaces and bootstrap state +- channel bindings +- user preferences and notification policy +- beta cohorts and feature flags +- telemetry and support tooling + +### Surface Layer + +- local API and CLI +- MCP server +- Telegram adapter and chat routing layer +- web onboarding/settings/admin surfaces +- brief and notification scheduler + +## Phase 10 Core Flows + +### Onboarding + +1. User authenticates with a hosted session. +2. User creates or boots a workspace. +3. Device and channel bindings are established. +4. Preferences and import choices are stored. +5. Alice generates a first brief against the existing continuity core. + +### Inbound Chat + +1. Telegram webhook receives an inbound message. +2. The message is normalized into a common channel message contract. +3. Routing resolves workspace, actor, and best-fit continuity context. +4. Core capture/recall/resume/correction logic executes. +5. A reply is dispatched back through the same channel thread. + +### Approval + +1. Core logic emits an approval request. +2. Chat surface presents approve/reject/context actions. +3. Approval resolution writes back to the same approval and audit objects used by other surfaces. + +### Daily Brief + +1. Scheduler selects workspaces due for delivery. +2. Brief compiler builds a deterministic summary from continuity state. +3. Notification policy and quiet hours are applied. +4. Delivery receipts and failures are recorded for support tooling. + +## Data Model Summary + +### Existing Baseline Objects + +- continuity capture events +- typed continuity objects +- correction events and revisions +- open loops and brief-ready summaries +- import provenance with explicit `source_kind` + +### Phase 10 Additions + +Control-plane tables: + +- `user_accounts` +- `auth_sessions` +- `devices` +- `workspaces` +- `workspace_members` +- `user_preferences` +- `beta_cohorts` +- `feature_flags` + +Channel and scheduler tables: + +- `channel_identities` +- `channel_messages` +- `channel_threads` +- `channel_delivery_receipts` +- `chat_intents` +- `continuity_briefs` +- `approval_challenges` +- `daily_brief_jobs` +- `notification_subscriptions` +- `open_loop_reviews` +- `chat_telemetry` + +## Security and Governance + +- Postgres remains the system of record. +- Hosted identity and channel access add to, but do not bypass, existing approval and provenance discipline. +- Append-only continuity and correction history stay intact. +- Device linking, channel binding, and session expiry are explicit control-plane concerns. +- Consequential actions remain approval-bounded even when initiated from chat. +- Opt-in backup/sync must preserve user isolation and encryption boundaries. + +## Deployment + +### Shipped Baseline + +Canonical local startup path remains: + +```bash +docker compose up -d +./scripts/migrate.sh +./scripts/load_sample_data.sh +APP_RELOAD=false ./scripts/api_dev.sh +``` + +### Phase 10 Production Additions + +- hosted auth/session endpoints +- public webhook ingress for Telegram +- scheduler/worker execution for briefs and notifications +- support/admin visibility for beta operations + +## Testing + +Existing quality gates remain: + +```bash +./.venv/bin/python -m pytest tests/unit tests/integration +pnpm --dir apps/web test +./scripts/run_phase9_eval.sh --report-path eval/reports/phase9_eval_latest.json +``` + +Phase 10 adds targeted verification for: + +- auth and workspace bootstrap +- device and channel linking +- idempotent webhook ingest and outbound delivery +- cross-surface parity between local, CLI, MCP, and Telegram +- daily brief scheduling, quiet hours, and failure handling +- support telemetry and rollout controls + +## Architecture Constraints + +- Phase 10 must not fork semantics between local, CLI, MCP, and Telegram. +- Telegram is another surface on the same core objects, not a separate assistant stack. +- Control-plane additions must not rewrite shipped Alice Core contracts. +- Do not expand connector breadth beyond Telegram in Phase 10 without an explicit roadmap change. +- Keep docs clear about what is shipped OSS baseline versus planned beta surface. + +## Historical Traceability + +Historical planning/control snapshots are retained in local-only internal docs and are intentionally excluded from the public repository. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..89b2bc7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,17 @@ +# Changelog + +## 2026-04-08 + +- Compacted the live control docs so `README.md`, `ROADMAP.md`, and `RULES.md` carry only current Phase 9 completion truth. +- Archived superseded Phase 9 planning and control material into local-only internal archives. +- Kept the quickstart, integration, release, runbook, and evaluation artifacts as the canonical Phase 9 launch surface. + +## 2026-04-07 + +- Prepared the first public `v0.1.0` launch documentation set for the shipped Phase 9 wedge. +- Added onboarding, integration, release, and repo policy docs without expanding product scope. + +## 2026-03-11 + +- Hardened the local runtime and verification path used by the public release candidate. +- Kept the launch surface aligned with deterministic local startup, migration, sample-data, and health-check flows. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..43e8c4b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,48 @@ +# Contributing + +Thanks for contributing to Alice. + +## Scope Discipline + +This repo enforces sprint-scoped delivery. Keep changes aligned to active sprint packet and avoid unrelated refactors. + +## Local Setup + +```bash +cp .env.example .env +python3 -m venv .venv +./.venv/bin/python -m pip install -e '.[dev]' +docker compose up -d +./scripts/migrate.sh +./scripts/load_sample_data.sh +``` + +## Required Validation + +Before opening a PR, run: + +```bash +./.venv/bin/python -m pytest tests/unit tests/integration +pnpm --dir apps/web test +``` + +For Phase 9 public-surface changes, also run: + +```bash +./scripts/run_phase9_eval.sh --report-path eval/reports/phase9_eval_latest.json +``` + +## Pull Request Expectations + +- Keep PR scope narrow and sprint-aligned. +- Update docs when behavior or command paths change. +- Include exact commands executed and pass/fail evidence. +- Do not introduce claims that outrun shipped functionality. + +## Architecture and Rules + +Read before making non-trivial changes: + +- `ARCHITECTURE.md` +- `RULES.md` +- active sprint packet (internal/local-only; not published in this repo) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ed633ad --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Alice contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PRODUCT_BRIEF.md b/PRODUCT_BRIEF.md new file mode 100644 index 0000000..9a9bb2b --- /dev/null +++ b/PRODUCT_BRIEF.md @@ -0,0 +1,89 @@ +# Product Brief + +## Product Summary + +Alice Connect is the Phase 10 product layer on top of shipped Phase 9 Alice Core. Alice Core remains the open-source local-first continuity engine; Alice Connect adds hosted identity, workspace bootstrap, Telegram-first access, chat-native continuity actions, and a daily brief loop for non-developer beta users. + +## Problem + +Phase 9 proved Alice can be installed, interoperate, remember, and resume deterministically. It does not yet make Alice usable every day for someone who will not touch a repo, CLI, or MCP setup. + +## Target Users + +- Non-developer beta users who want a personal continuity assistant in chat. +- Individual professionals who need capture, recall, resume, open-loop review, and lightweight approvals in Telegram. +- OSS adopters who start local-first and may later opt into a hosted product layer. + +## Why It Matters + +- turns continuity from a technical engine into a daily habit +- makes chat the default interface without forking core semantics +- creates a clear OSS-to-product path instead of a separate product rewrite + +## Shipped Baseline + +Phase 9 is complete and shipped. Baseline truth is: + +- Alice Core local-first runtime +- deterministic CLI continuity commands +- deterministic MCP transport with a narrow tool surface +- OpenClaw, Markdown, and ChatGPT importers +- continuity engine, approvals, and evaluation harness +- public quickstart, integration, release, and runbook docs for the OSS wedge + +## V1 Scope (Phase 10) + +### Open Source Surface + +- Alice Core +- CLI +- MCP +- importers +- OpenClaw adapter + +### Product / Beta Surface + +- Alice Connect account +- hosted workspace bootstrap +- device and channel linking +- Telegram access +- chat-native capture, recall, resume, correction, and open-loop review +- approvals in chat +- daily brief and notification loop +- opt-in encrypted backup/sync metadata path +- beta onboarding, cohort gating, and support tooling + +## Non-Goals + +- WhatsApp or broad channel expansion +- browser automation +- high-risk autonomous execution +- enterprise collaboration features +- new vertical agents +- reopening more core release-control work as Phase 10 scope + +## Success Criteria + +At the end of Phase 10, a non-developer beta user can: + +- create an account +- link Telegram +- import initial data or skip import +- capture things naturally in chat +- ask recall questions +- get resume briefs +- review open loops +- approve simple actions in chat +- receive a useful daily brief + +## Product Non-Negotiables + +- Alice Core remains the baseline truth; Phase 10 builds on it rather than replacing it. +- Telegram is a product surface on top of the same continuity semantics as local, CLI, and MCP. +- Durable answers remain provenance-backed and correction-aware. +- Consequential actions remain approval-bounded. +- Hosted product docs must clearly distinguish OSS surface from beta product surface. + +## Historical Traceability + +Superseded planning and control material is retained in local-only internal archives and is not part of the public repo. diff --git a/README.md b/README.md index 5ea9f40..f67fc59 100644 --- a/README.md +++ b/README.md @@ -1,45 +1,262 @@ -# AliceBot + -AliceBot is a private, permissioned personal AI operating system. This repository currently holds the canonical product, architecture, roadmap, and AI handoff documents that future implementation work should follow. +# Alice -## Status +**Durable memory and resumption for AI agents.** -- Planning has been distilled into durable operating docs. -- Application code has not been scaffolded yet. -- The first execution target is the foundation sprint in [.ai/active/SPRINT_PACKET.md](.ai/active/SPRINT_PACKET.md). +![Local-first](https://img.shields.io/badge/local--first-core-0A7B61) +![MCP](https://img.shields.io/badge/MCP-supported-1f6feb) +![License](https://img.shields.io/badge/license-MIT-2ea043) -## Quick Start Assumptions +AI assistants are good at replying in the moment. They are still weak at remembering what matters, resuming interrupted work, and staying aligned after corrections. -- Assumption: local development will use Docker Compose for Postgres, Redis, and S3-compatible storage. -- Assumption: backend work will use Python 3.12 and FastAPI. -- Assumption: frontend work will use Node.js 20, `pnpm`, and Next.js. -- Secrets must stay out of the repo; use `.env` files locally and a secret manager in deployed environments. +Alice is the continuity layer that fixes that. -## Repo Structure +It gives agents and workflows a local-first system for capture, recall, resumption, open-loop tracking, and correction-aware, trust-aware memory, so you do not have to rebuild context from scratch every time work resumes. -- [PRODUCT_BRIEF.md](PRODUCT_BRIEF.md): permanent product truth. -- [ARCHITECTURE.md](ARCHITECTURE.md): permanent technical truth. -- [ROADMAP.md](ROADMAP.md): milestone sequence and delivery risks. -- [RULES.md](RULES.md): durable engineering and scope rules. -- [.ai/handoff/CURRENT_STATE.md](.ai/handoff/CURRENT_STATE.md): fresh-thread recovery snapshot. -- [.ai/active/SPRINT_PACKET.md](.ai/active/SPRINT_PACKET.md): current builder sprint. -- `docs/adr/`: architecture decision records. -- `docs/runbooks/`: operational procedures. -- `docs/archive/`: source material and retired planning docs. -- `apps/api/`, `apps/web/`, `workers/`, `tests/`, `scripts/`: planned implementation areas. +## Why use Alice -## Essential Commands +Use Alice if you want your agents or workflows to: -- `docker compose up -d`: expected local infra start command once the foundation sprint lands. -- `alembic upgrade head`: expected database migration command once the API scaffold exists. -- `pytest`: expected backend and integration test entrypoint. -- `pnpm test`: expected frontend test entrypoint. -- `pnpm lint`: expected frontend lint entrypoint. +- remember decisions, commitments, and context across sessions +- resume work without rereading long threads +- track waiting-fors, blockers, and unresolved follow-ups +- improve deterministically when memory is corrected +- stay portable across CLI, MCP, and imported workflow data -## Environment Notes +## What makes Alice different -- Postgres is the planned system of record and must support `pgvector`. -- Redis is planned for queues, locks, and short-lived cache data. -- Object storage is planned for documents and task artifacts. -- Authentication, row-level security, and approval boundaries are first-class requirements from the start. -# AliceBot +### It is built for continuity, not just storage + +Alice does not treat memory as a pile of chat history or vague summaries. +It stores typed continuity objects, revisions, provenance, and open loops so context can be reused operationally. + +### It is built for resumption, not just retrieval + +Most memory tools help you find something. +Alice is designed to answer the higher-value questions: + +- What did we decide? +- What changed? +- What am I waiting on? +- What should happen next? + +### It is correction-aware + +Alice supports explicit review, correction, and supersession so future answers improve in a traceable way instead of drifting based on hidden summarization. + +### It is trust-aware + +Alice does not treat every memory as equally reliable. +Memories carry trust classification and promotion eligibility, so agents can search broadly without promoting weak, single-source AI-extracted facts into durable truth by default. + +Trust metadata flows through admission, retrieval, explain output, review behavior, and CLI/MCP responses, which makes memory quality visible instead of implicit. + +Alice also separates preservation, searchability, and promotability. +That means a continuity object can be preserved for audit, excluded from default recall, or withheld from promotion into resumption briefs depending on its lifecycle posture instead of being treated as uniformly active. + +### It is local-first and agent-agnostic + +Alice Core runs locally and exposes the same continuity semantics through the CLI and MCP, so you can use it with your own workflows instead of being locked into a closed assistant product. + +## Who Alice is for + +Alice is useful for: + +- agent builders +- technical teams +- founders and operators +- consultants and researchers +- anyone who needs reliable memory and clean resumption across days or weeks of work + +## What ships today + +The open-source surface includes: + +- Alice Core +- deterministic CLI workflows +- MCP server +- trust-aware memory classification and promotion controls +- lifecycle-aware continuity controls for preservation, recall visibility, and promotion into resumption +- importers for OpenClaw, Markdown, and ChatGPT exports +- OpenClaw adapter and demo path +- evaluation harness and integration docs + +## Quickstart + +Clone the repo and install the local runtime: + +```bash +git clone https://github.com/samrusani/AliceBot.git +cd AliceBot +cp .env.example .env +python3 -m venv .venv +./.venv/bin/python -m pip install -e '.[dev]' +``` + +Start the local services and seed sample data: + +```bash +docker compose up -d +./scripts/migrate.sh +./scripts/load_sample_data.sh +APP_RELOAD=false ./scripts/api_dev.sh +``` + +In another terminal, verify the runtime and get a first useful result: + +```bash +./.venv/bin/python -m alicebot_api status +./.venv/bin/python -m alicebot_api recall --query local-first --limit 5 +./.venv/bin/python -m alicebot_api resume --max-recent-changes 5 --max-open-loops 5 +./.venv/bin/python -m alicebot_api open-loops --limit 5 +``` + +By default, recall excludes non-searchable objects and resume excludes non-promotable `MemoryFact` items. Inspect lifecycle posture or override resume filtering when needed: + +```bash +./.venv/bin/python -m alicebot_api lifecycle list +./.venv/bin/python -m alicebot_api resume --include-non-promotable-facts --max-recent-changes 5 --max-open-loops 5 +``` + +Capture something new: + +```bash +./.venv/bin/python -m alicebot_api capture "Remember that the Q3 board pack is due on Thursday." +``` + +See the full local setup walkthrough in [docs/quickstart/local-setup-and-first-result.md](docs/quickstart/local-setup-and-first-result.md). + +## Use Alice with your agents + +Alice is designed to be a continuity layer, not a closed assistant silo. + +### MCP + +Alice exposes a narrow MCP surface for continuity workflows: + +- `alice_capture` +- `alice_recall` +- `alice_resume` +- `alice_open_loops` +- `alice_recent_decisions` +- `alice_recent_changes` +- `alice_memory_review` +- `alice_memory_correct` +- `alice_context_pack` + +This makes it straightforward to plug Alice into MCP-capable assistants and development environments without changing the underlying continuity model. + +See: + +- [docs/integrations/mcp.md](docs/integrations/mcp.md) +- [docs/integrations/hermes.md](docs/integrations/hermes.md) +- [docs/integrations/hermes-skill-pack.md](docs/integrations/hermes-skill-pack.md) + +Hermes runtime smoke test: + +```bash +./scripts/run_hermes_mcp_smoke.py +``` + +### Import and augment existing workflows + +Alice includes importer paths for existing memory and conversation data so you can upgrade an existing workflow instead of starting from zero. + +With the current integration surface, you can: + +- import OpenClaw memory into Alice +- normalize imported data into Alice continuity objects +- run recall and resumption against imported work +- add Alice MCP workflows on top of an existing setup + +OpenClaw demo: + +```bash +./scripts/use_alice_with_openclaw.sh +``` + +See: + +- [docs/integrations/importers.md](docs/integrations/importers.md) +- [docs/integrations/openclaw.md](docs/integrations/openclaw.md) + +## Example outcomes + +### Founder and operator continuity + +- keep strategic decisions from disappearing into old chats +- resume fundraising, hiring, or product threads quickly +- stay on top of commitments and follow-ups + +### Consulting and client work + +- preserve client-specific decisions and context +- restart project work without reconstructing the last week +- maintain open loops without building a manual CRM ritual + +### Agent memory upgrades + +- add durable continuity to an existing agent stack +- improve recall and resumption without rebuilding your runtime +- keep correction and provenance explicit + +## Architecture at a glance + +Alice is built around a shared continuity core with: + +- structured memory revisions +- provenance- and trust-aware recall +- lifecycle-aware preservation, search visibility, and resumption promotion +- deterministic resumption briefs +- open-loop objects +- CLI and MCP surfaces on the same semantics + +That means the system behaves consistently across local workflows, MCP-connected agents, and imported data sources. + +## Roadmap + +### Available now + +- local-first core +- CLI +- MCP +- importers +- OpenClaw adapter +- reproducible eval harness + +### In progress + +- Alice Connect +- hosted identity and workspace bootstrap +- Telegram-first conversational surface +- chat-native approvals +- daily continuity briefs + +## Docs + +- [Quickstart](docs/quickstart/local-setup-and-first-result.md) +- [Architecture](ARCHITECTURE.md) +- [MCP](docs/integrations/mcp.md) +- [Hermes Guide](docs/integrations/hermes.md) +- [Hermes Skill Pack](docs/integrations/hermes-skill-pack.md) +- [Importers](docs/integrations/importers.md) +- [OpenClaw Guide](docs/integrations/openclaw.md) +- [Examples](docs/examples/phase9-command-walkthrough.md) + +## Contributing + +Issues, adapters, importers, eval contributions, and integration examples are welcome. + +See [CONTRIBUTING.md](CONTRIBUTING.md). + +## Security + +If you discover a security issue, follow the process in [SECURITY.md](SECURITY.md). + +## License + +See [LICENSE](LICENSE). diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..d1d45cb --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,67 @@ +# Roadmap + +## Planning Basis + +- Phase 9 is shipped baseline truth, not roadmap work. +- Phase 10 is the next delivery phase: Alice Connect. + +## Phase 10 Milestones + +### P10-S1: Identity + Workspace Bootstrap + +- hosted account and session model +- workspace creation and bootstrap flow +- device linking +- user preferences and settings foundation +- beta cohort and feature-flag support + +### P10-S2: Telegram Transport + Message Normalization + +- Telegram bot and webhook ingress +- Telegram link/unlink flow +- normalized inbound message contract +- outbound dispatcher and delivery receipts +- workspace/thread routing for chat traffic + +### P10-S3: Chat-Native Continuity + Approvals + +- capture, recall, resume, correction, and open-loop review in Telegram +- deterministic routing to best-fit continuity context +- approval prompts and resolution in chat +- provenance-backed answers and correction uptake + +### P10-S4: Daily Brief + Notifications + Open-Loop Review + +- daily brief generation and delivery scheduler +- quiet hours and notification controls +- waiting-for and stale-item prompts +- one-tap open-loop review actions in chat + +### P10-S5: Beta Hardening + Launch Readiness + +- beta onboarding funnel +- admin/support tooling +- analytics and observability for chat flows +- rate limiting, abuse controls, and rollout flags +- launch assets and hosted-vs-OSS product clarity + +## Sequencing Rules + +- Do not start Telegram transport before identity and workspace bootstrap are stable. +- Do not add chat-native continuity before transport and routing are deterministic. +- Do not turn on scheduled briefs until chat continuity and notification preferences are trustworthy. +- Treat beta hardening as a launch gate, not optional polish. + +## Phase 10 Exit + +Phase 10 is done when a non-technical beta user can onboard, use Alice through Telegram, capture and recall continuity, receive a useful daily brief, approve simple actions in chat, and do so without semantic drift from Alice Core. + +## Roadmap Guardrails + +- Keep this file future-facing; completed work and sprint history belong in archive. +- Do not rewrite shipped Phase 9 capabilities as future milestones. +- Preserve the OSS baseline while layering product capabilities on top of it. + +## Archived Planning + +- Historical planning and superseded control docs are retained in local-only internal archives and are not part of the public repo. diff --git a/RULES.md b/RULES.md new file mode 100644 index 0000000..6b795da --- /dev/null +++ b/RULES.md @@ -0,0 +1,37 @@ +# Rules + +## Baseline Truth + +- Treat shipped Phase 9 capability as baseline truth, not as future roadmap scope. +- Do not rewrite shipped Phase 9 capabilities as future roadmap items. +- Do not rewrite shipped Alice Core, CLI, MCP, importer, or eval-harness behavior as aspirational work. + +## Product Scope + +- Alice remains a continuity product first, not a broad autonomous platform. +- Hosted product work must preserve a clear OSS-to-product boundary. +- Telegram is the only new user-facing channel in Phase 10 unless the roadmap changes. +- Do not add browser automation, broad connector expansion, enterprise collaboration, or new vertical agents under Phase 10. + +## Architecture + +- Phase 10 must not fork semantics between local, CLI, MCP, and Telegram. +- Telegram is another surface on the same core objects. +- Control plane owns identity, devices, channel bindings, preferences, feature flags, and telemetry. +- Data plane owns continuity objects, memory revisions, open loops, approvals, audit traces, and interop semantics. +- Compile answers from durable stored truth, not transcript replay. +- Preserve append-only continuity, correction history, and explicit provenance. + +## Operations And Delivery + +- Consequential actions remain approval-bounded on every surface. +- Inbound chat handling and outbound delivery must be idempotent and auditable. +- Daily briefs and notifications must respect timezone, preferences, and quiet hours. +- Public docs must distinguish shipped OSS surface from beta product surface. +- New public-facing flows require smoke validation, not only unit tests. + +## Control Docs + +- Keep `ROADMAP.md` future-facing and `RULES.md` limited to durable guidance. +- Archive superseded planning and control snapshots instead of keeping them in live files. +- Keep sprint packet and handoff state in internal/local-only operating docs, not public docs. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..453ac70 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,29 @@ +# Security Policy + +## Supported Scope + +Alice v0.1 is local-first. Security posture in this repo is scoped to local runtime defaults and deterministic command paths. + +## Reporting a Vulnerability + +Please report security issues privately by opening a private security advisory in GitHub for this repository. Include: + +- affected component/file +- reproduction steps +- impact assessment +- suggested mitigation (if available) + +Do not open public issues for active security vulnerabilities. + +## Security Boundaries + +- Postgres remains the system of record. +- User-owned data paths are RLS-governed. +- Public CLI/MCP/importer surfaces should not bypass trust/provenance boundaries. +- Consequential side effects remain approval-bounded. + +## Hardening Notes + +- keep `.env` local and do not commit secrets +- keep local services bound to loopback where possible +- run verification commands before release tagging diff --git a/apps/api/.gitkeep b/apps/api/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/api/.gitkeep @@ -0,0 +1 @@ + diff --git a/apps/api/alembic.ini b/apps/api/alembic.ini new file mode 100644 index 0000000..2ca852a --- /dev/null +++ b/apps/api/alembic.ini @@ -0,0 +1,37 @@ +[alembic] +script_location = apps/api/alembic +prepend_sys_path = apps/api/src +path_separator = os +sqlalchemy.url = postgresql://alicebot_admin:alicebot_admin@localhost:5432/alicebot + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = console +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s diff --git a/apps/api/alembic/env.py b/apps/api/alembic/env.py new file mode 100644 index 0000000..b8880aa --- /dev/null +++ b/apps/api/alembic/env.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from logging.config import fileConfig +import os + +from alembic import context +from sqlalchemy import engine_from_config, pool + + +config = context.config + +target_metadata = None + + +def normalize_sqlalchemy_url(database_url: str) -> str: + if database_url.startswith("postgresql://"): + return database_url.replace("postgresql://", "postgresql+psycopg://", 1) + return database_url + + +def get_url() -> str: + database_url = ( + os.getenv("DATABASE_ADMIN_URL") + or os.getenv("DATABASE_URL") + or config.get_main_option("sqlalchemy.url") + ) + return normalize_sqlalchemy_url(database_url) + + +def configure_logging() -> None: + if config.config_file_name is not None: + fileConfig(config.config_file_name) + + +def run_migrations_offline() -> None: + context.configure( + url=get_url(), + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + configuration = config.get_section(config.config_ini_section, {}) + configuration["sqlalchemy.url"] = get_url() + connectable = engine_from_config( + configuration, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations() -> None: + configure_logging() + if context.is_offline_mode(): + run_migrations_offline() + else: + run_migrations_online() + + +run_migrations() diff --git a/apps/api/alembic/versions/20260310_0001_foundation_continuity.py b/apps/api/alembic/versions/20260310_0001_foundation_continuity.py new file mode 100644 index 0000000..eeb1d3b --- /dev/null +++ b/apps/api/alembic/versions/20260310_0001_foundation_continuity.py @@ -0,0 +1,167 @@ +"""Create continuity foundation tables with RLS and append-only events.""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260310_0001" +down_revision = None +branch_labels = None +depends_on = None + +_RLS_TABLES = ("users", "threads", "sessions", "events") + +_UPGRADE_BOOTSTRAP_STATEMENTS = ( + "CREATE EXTENSION IF NOT EXISTS pgcrypto", + "CREATE EXTENSION IF NOT EXISTS vector", + "CREATE SCHEMA IF NOT EXISTS app", + """ + CREATE OR REPLACE FUNCTION app.current_user_id() + RETURNS uuid + LANGUAGE sql + STABLE + AS $$ + SELECT NULLIF(current_setting('app.current_user_id', true), '')::uuid + $$; + """, + """ + CREATE OR REPLACE FUNCTION app.reject_event_mutation() + RETURNS trigger + LANGUAGE plpgsql + AS $$ + BEGIN + RAISE EXCEPTION 'events are append-only'; + END; + $$; + """, +) + +_UPGRADE_SCHEMA_STATEMENT = """ + CREATE TABLE users ( + id uuid PRIMARY KEY, + email text NOT NULL UNIQUE, + display_name text, + created_at timestamptz NOT NULL DEFAULT now() + ); + + CREATE TABLE threads ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (id, user_id) + ); + + CREATE TABLE sessions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL, + thread_id uuid NOT NULL, + status text NOT NULL DEFAULT 'active', + started_at timestamptz NOT NULL DEFAULT now(), + ended_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (id, user_id), + FOREIGN KEY (thread_id, user_id) + REFERENCES threads(id, user_id) + ON DELETE CASCADE + ); + + CREATE TABLE events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL, + thread_id uuid NOT NULL, + session_id uuid, + sequence_no bigint NOT NULL, + kind text NOT NULL, + payload jsonb NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (id, user_id), + UNIQUE (thread_id, sequence_no), + FOREIGN KEY (thread_id, user_id) + REFERENCES threads(id, user_id) + ON DELETE CASCADE, + FOREIGN KEY (session_id, user_id) + REFERENCES sessions(id, user_id) + ON DELETE CASCADE + ); + + CREATE INDEX sessions_thread_created_idx + ON sessions (thread_id, created_at); + CREATE INDEX threads_user_created_idx + ON threads (user_id, created_at); + """ + +_UPGRADE_TRIGGER_STATEMENT = """ + CREATE TRIGGER events_append_only + BEFORE UPDATE OR DELETE ON events + FOR EACH ROW + EXECUTE FUNCTION app.reject_event_mutation(); + """ + +_UPGRADE_GRANT_STATEMENTS = ( + "GRANT USAGE ON SCHEMA public TO alicebot_app", + "GRANT USAGE ON SCHEMA app TO alicebot_app", + "GRANT SELECT, INSERT ON users TO alicebot_app", + "GRANT SELECT, INSERT ON threads TO alicebot_app", + "GRANT SELECT, INSERT ON sessions TO alicebot_app", + "GRANT SELECT, INSERT ON events TO alicebot_app", +) + +_UPGRADE_POLICY_STATEMENT = """ + CREATE POLICY users_is_owner ON users + USING (id = app.current_user_id()) + WITH CHECK (id = app.current_user_id()); + + CREATE POLICY threads_is_owner ON threads + USING (user_id = app.current_user_id()) + WITH CHECK (user_id = app.current_user_id()); + + CREATE POLICY sessions_is_owner ON sessions + USING (user_id = app.current_user_id()) + WITH CHECK (user_id = app.current_user_id()); + + CREATE POLICY events_read_own ON events + FOR SELECT + USING (user_id = app.current_user_id()); + + CREATE POLICY events_insert_own ON events + FOR INSERT + WITH CHECK (user_id = app.current_user_id()); + """ + +_DOWNGRADE_STATEMENTS = ( + "DROP TRIGGER IF EXISTS events_append_only ON events", + "DROP TABLE IF EXISTS events", + "DROP TABLE IF EXISTS sessions", + "DROP TABLE IF EXISTS threads", + "DROP TABLE IF EXISTS users", + "DROP FUNCTION IF EXISTS app.reject_event_mutation()", + "DROP FUNCTION IF EXISTS app.current_user_id()", + "DROP SCHEMA IF EXISTS app", +) + + +def _execute_statements(statements: tuple[str, ...]) -> None: + for statement in statements: + op.execute(statement) + + +def _enable_row_level_security() -> None: + for table_name in _RLS_TABLES: + op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") + op.execute(f"ALTER TABLE {table_name} FORCE ROW LEVEL SECURITY") + + +def upgrade() -> None: + _execute_statements(_UPGRADE_BOOTSTRAP_STATEMENTS) + op.execute(_UPGRADE_SCHEMA_STATEMENT) + op.execute(_UPGRADE_TRIGGER_STATEMENT) + _execute_statements(_UPGRADE_GRANT_STATEMENTS) + _enable_row_level_security() + op.execute(_UPGRADE_POLICY_STATEMENT) + + +def downgrade() -> None: + _execute_statements(_DOWNGRADE_STATEMENTS) diff --git a/apps/api/alembic/versions/20260311_0002_tighten_runtime_privileges.py b/apps/api/alembic/versions/20260311_0002_tighten_runtime_privileges.py new file mode 100644 index 0000000..5935399 --- /dev/null +++ b/apps/api/alembic/versions/20260311_0002_tighten_runtime_privileges.py @@ -0,0 +1,39 @@ +"""Tighten the runtime role to insert/select-only continuity access.""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260311_0002" +down_revision = "20260310_0001" +branch_labels = None +depends_on = None + +_UPGRADE_STATEMENTS = ( + "REVOKE UPDATE ON users FROM alicebot_app", + "REVOKE UPDATE ON threads FROM alicebot_app", + "REVOKE UPDATE ON sessions FROM alicebot_app", +) + +# Revision 20260310_0001 already leaves the runtime role with no UPDATE grants +# on these tables. Downgrading back to that revision should therefore preserve +# the same privilege floor explicitly rather than re-introducing broader access. +_DOWNGRADE_STATEMENTS = ( + "REVOKE UPDATE ON users FROM alicebot_app", + "REVOKE UPDATE ON threads FROM alicebot_app", + "REVOKE UPDATE ON sessions FROM alicebot_app", +) + + +def _execute_statements(statements: tuple[str, ...]) -> None: + for statement in statements: + op.execute(statement) + + +def upgrade() -> None: + _execute_statements(_UPGRADE_STATEMENTS) + + +def downgrade() -> None: + _execute_statements(_DOWNGRADE_STATEMENTS) diff --git a/apps/api/alembic/versions/20260311_0003_trace_backbone.py b/apps/api/alembic/versions/20260311_0003_trace_backbone.py new file mode 100644 index 0000000..6028ff4 --- /dev/null +++ b/apps/api/alembic/versions/20260311_0003_trace_backbone.py @@ -0,0 +1,117 @@ +"""Add persisted traces and trace events for context compilation.""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260311_0003" +down_revision = "20260311_0002" +branch_labels = None +depends_on = None + +_RLS_TABLES = ("traces", "trace_events") + +_UPGRADE_BOOTSTRAP_STATEMENTS = ( + """ + CREATE OR REPLACE FUNCTION app.reject_trace_event_mutation() + RETURNS trigger + LANGUAGE plpgsql + AS $$ + BEGIN + RAISE EXCEPTION 'trace events are append-only'; + END; + $$; + """, +) + +_UPGRADE_SCHEMA_STATEMENT = """ + CREATE TABLE traces ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + thread_id uuid NOT NULL, + kind text NOT NULL, + compiler_version text NOT NULL, + status text NOT NULL, + limits jsonb NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (id, user_id), + FOREIGN KEY (thread_id, user_id) + REFERENCES threads(id, user_id) + ON DELETE CASCADE + ); + + CREATE TABLE trace_events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL, + trace_id uuid NOT NULL, + sequence_no bigint NOT NULL, + kind text NOT NULL, + payload jsonb NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (trace_id, sequence_no), + FOREIGN KEY (trace_id, user_id) + REFERENCES traces(id, user_id) + ON DELETE CASCADE + ); + + CREATE INDEX traces_thread_created_idx + ON traces (thread_id, created_at); + """ + +_UPGRADE_TRIGGER_STATEMENT = """ + CREATE TRIGGER trace_events_append_only + BEFORE UPDATE OR DELETE ON trace_events + FOR EACH ROW + EXECUTE FUNCTION app.reject_trace_event_mutation(); + """ + +_UPGRADE_GRANT_STATEMENTS = ( + "GRANT SELECT, INSERT ON traces TO alicebot_app", + "GRANT SELECT, INSERT ON trace_events TO alicebot_app", +) + +_UPGRADE_POLICY_STATEMENT = """ + CREATE POLICY traces_is_owner ON traces + USING (user_id = app.current_user_id()) + WITH CHECK (user_id = app.current_user_id()); + + CREATE POLICY trace_events_read_own ON trace_events + FOR SELECT + USING (user_id = app.current_user_id()); + + CREATE POLICY trace_events_insert_own ON trace_events + FOR INSERT + WITH CHECK (user_id = app.current_user_id()); + """ + +_DOWNGRADE_STATEMENTS = ( + "DROP TRIGGER IF EXISTS trace_events_append_only ON trace_events", + "DROP TABLE IF EXISTS trace_events", + "DROP TABLE IF EXISTS traces", + "DROP FUNCTION IF EXISTS app.reject_trace_event_mutation()", +) + + +def _execute_statements(statements: tuple[str, ...]) -> None: + for statement in statements: + op.execute(statement) + + +def _enable_row_level_security() -> None: + for table_name in _RLS_TABLES: + op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") + op.execute(f"ALTER TABLE {table_name} FORCE ROW LEVEL SECURITY") + + +def upgrade() -> None: + _execute_statements(_UPGRADE_BOOTSTRAP_STATEMENTS) + op.execute(_UPGRADE_SCHEMA_STATEMENT) + op.execute(_UPGRADE_TRIGGER_STATEMENT) + _execute_statements(_UPGRADE_GRANT_STATEMENTS) + _enable_row_level_security() + op.execute(_UPGRADE_POLICY_STATEMENT) + + +def downgrade() -> None: + _execute_statements(_DOWNGRADE_STATEMENTS) diff --git a/apps/api/alembic/versions/20260311_0004_memory_admission.py b/apps/api/alembic/versions/20260311_0004_memory_admission.py new file mode 100644 index 0000000..c782d3b --- /dev/null +++ b/apps/api/alembic/versions/20260311_0004_memory_admission.py @@ -0,0 +1,123 @@ +"""Add governed memory tables and append-only memory revisions.""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260311_0004" +down_revision = "20260311_0003" +branch_labels = None +depends_on = None + +_RLS_TABLES = ("memories", "memory_revisions") + +_UPGRADE_BOOTSTRAP_STATEMENTS = ( + """ + CREATE OR REPLACE FUNCTION app.reject_memory_revision_mutation() + RETURNS trigger + LANGUAGE plpgsql + AS $$ + BEGIN + RAISE EXCEPTION 'memory revisions are append-only'; + END; + $$; + """, +) + +_UPGRADE_SCHEMA_STATEMENT = """ + CREATE TABLE memories ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + memory_key text NOT NULL, + value jsonb NOT NULL, + status text NOT NULL, + source_event_ids jsonb NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + UNIQUE (id, user_id), + UNIQUE (user_id, memory_key) + ); + + CREATE TABLE memory_revisions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL, + memory_id uuid NOT NULL, + sequence_no bigint NOT NULL, + action text NOT NULL, + memory_key text NOT NULL, + previous_value jsonb, + new_value jsonb, + source_event_ids jsonb NOT NULL, + candidate jsonb NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (id, user_id), + UNIQUE (memory_id, sequence_no), + FOREIGN KEY (memory_id, user_id) + REFERENCES memories(id, user_id) + ON DELETE CASCADE + ); + + CREATE INDEX memories_user_status_updated_idx + ON memories (user_id, status, updated_at); + CREATE INDEX memory_revisions_memory_created_idx + ON memory_revisions (memory_id, created_at); + """ + +_UPGRADE_TRIGGER_STATEMENT = """ + CREATE TRIGGER memory_revisions_append_only + BEFORE UPDATE OR DELETE ON memory_revisions + FOR EACH ROW + EXECUTE FUNCTION app.reject_memory_revision_mutation(); + """ + +_UPGRADE_GRANT_STATEMENTS = ( + "GRANT SELECT, INSERT, UPDATE ON memories TO alicebot_app", + "GRANT SELECT, INSERT ON memory_revisions TO alicebot_app", +) + +_UPGRADE_POLICY_STATEMENT = """ + CREATE POLICY memories_is_owner ON memories + USING (user_id = app.current_user_id()) + WITH CHECK (user_id = app.current_user_id()); + + CREATE POLICY memory_revisions_read_own ON memory_revisions + FOR SELECT + USING (user_id = app.current_user_id()); + + CREATE POLICY memory_revisions_insert_own ON memory_revisions + FOR INSERT + WITH CHECK (user_id = app.current_user_id()); + """ + +_DOWNGRADE_STATEMENTS = ( + "DROP TRIGGER IF EXISTS memory_revisions_append_only ON memory_revisions", + "DROP TABLE IF EXISTS memory_revisions", + "DROP TABLE IF EXISTS memories", + "DROP FUNCTION IF EXISTS app.reject_memory_revision_mutation()", +) + + +def _execute_statements(statements: tuple[str, ...]) -> None: + for statement in statements: + op.execute(statement) + + +def _enable_row_level_security() -> None: + for table_name in _RLS_TABLES: + op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") + op.execute(f"ALTER TABLE {table_name} FORCE ROW LEVEL SECURITY") + + +def upgrade() -> None: + _execute_statements(_UPGRADE_BOOTSTRAP_STATEMENTS) + op.execute(_UPGRADE_SCHEMA_STATEMENT) + op.execute(_UPGRADE_TRIGGER_STATEMENT) + _execute_statements(_UPGRADE_GRANT_STATEMENTS) + _enable_row_level_security() + op.execute(_UPGRADE_POLICY_STATEMENT) + + +def downgrade() -> None: + _execute_statements(_DOWNGRADE_STATEMENTS) diff --git a/apps/api/alembic/versions/20260312_0005_memory_review_labels.py b/apps/api/alembic/versions/20260312_0005_memory_review_labels.py new file mode 100644 index 0000000..2b7ede5 --- /dev/null +++ b/apps/api/alembic/versions/20260312_0005_memory_review_labels.py @@ -0,0 +1,99 @@ +"""Add append-only memory review labels for human evaluation.""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260312_0005" +down_revision = "20260311_0004" +branch_labels = None +depends_on = None + +_RLS_TABLES = ("memory_review_labels",) + +_UPGRADE_BOOTSTRAP_STATEMENTS = ( + """ + CREATE OR REPLACE FUNCTION app.reject_memory_review_label_mutation() + RETURNS trigger + LANGUAGE plpgsql + AS $$ + BEGIN + RAISE EXCEPTION 'memory review labels are append-only'; + END; + $$; + """, +) + +_UPGRADE_SCHEMA_STATEMENT = """ + CREATE TABLE memory_review_labels ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL, + memory_id uuid NOT NULL, + label text NOT NULL, + note text, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (id, user_id), + FOREIGN KEY (memory_id, user_id) + REFERENCES memories(id, user_id) + ON DELETE CASCADE, + CONSTRAINT memory_review_labels_label_check + CHECK (label IN ('correct', 'incorrect', 'outdated', 'insufficient_evidence')), + CONSTRAINT memory_review_labels_note_length_check + CHECK (note IS NULL OR char_length(note) <= 280) + ); + + CREATE INDEX memory_review_labels_memory_created_idx + ON memory_review_labels (memory_id, created_at, id); + """ + +_UPGRADE_TRIGGER_STATEMENT = """ + CREATE TRIGGER memory_review_labels_append_only + BEFORE UPDATE OR DELETE ON memory_review_labels + FOR EACH ROW + EXECUTE FUNCTION app.reject_memory_review_label_mutation(); + """ + +_UPGRADE_GRANT_STATEMENTS = ( + "GRANT SELECT, INSERT ON memory_review_labels TO alicebot_app", +) + +_UPGRADE_POLICY_STATEMENT = """ + CREATE POLICY memory_review_labels_read_own ON memory_review_labels + FOR SELECT + USING (user_id = app.current_user_id()); + + CREATE POLICY memory_review_labels_insert_own ON memory_review_labels + FOR INSERT + WITH CHECK (user_id = app.current_user_id()); + """ + +_DOWNGRADE_STATEMENTS = ( + "DROP TRIGGER IF EXISTS memory_review_labels_append_only ON memory_review_labels", + "DROP TABLE IF EXISTS memory_review_labels", + "DROP FUNCTION IF EXISTS app.reject_memory_review_label_mutation()", +) + + +def _execute_statements(statements: tuple[str, ...]) -> None: + for statement in statements: + op.execute(statement) + + +def _enable_row_level_security() -> None: + for table_name in _RLS_TABLES: + op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") + op.execute(f"ALTER TABLE {table_name} FORCE ROW LEVEL SECURITY") + + +def upgrade() -> None: + _execute_statements(_UPGRADE_BOOTSTRAP_STATEMENTS) + op.execute(_UPGRADE_SCHEMA_STATEMENT) + op.execute(_UPGRADE_TRIGGER_STATEMENT) + _execute_statements(_UPGRADE_GRANT_STATEMENTS) + _enable_row_level_security() + op.execute(_UPGRADE_POLICY_STATEMENT) + + +def downgrade() -> None: + _execute_statements(_DOWNGRADE_STATEMENTS) diff --git a/apps/api/alembic/versions/20260312_0006_entities_backbone.py b/apps/api/alembic/versions/20260312_0006_entities_backbone.py new file mode 100644 index 0000000..a1d3bcb --- /dev/null +++ b/apps/api/alembic/versions/20260312_0006_entities_backbone.py @@ -0,0 +1,72 @@ +"""Add explicit user-scoped entities backed by durable source memories.""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260312_0006" +down_revision = "20260312_0005" +branch_labels = None +depends_on = None + +_RLS_TABLES = ("entities",) + +_UPGRADE_SCHEMA_STATEMENT = """ + CREATE TABLE entities ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + entity_type text NOT NULL, + name text NOT NULL, + source_memory_ids jsonb NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (id, user_id), + CONSTRAINT entities_type_check + CHECK (entity_type IN ('person', 'merchant', 'product', 'project', 'routine')), + CONSTRAINT entities_name_length_check + CHECK (char_length(name) BETWEEN 1 AND 200), + CONSTRAINT entities_source_memory_ids_array_check + CHECK (jsonb_typeof(source_memory_ids) = 'array'), + CONSTRAINT entities_source_memory_ids_nonempty_check + CHECK (jsonb_array_length(source_memory_ids) > 0) + ); + + CREATE INDEX entities_user_created_idx + ON entities (user_id, created_at, id); + """ + +_UPGRADE_GRANT_STATEMENTS = ( + "GRANT SELECT, INSERT ON entities TO alicebot_app", +) + +_UPGRADE_POLICY_STATEMENT = """ + CREATE POLICY entities_is_owner ON entities + USING (user_id = app.current_user_id()) + WITH CHECK (user_id = app.current_user_id()); + """ + +_DOWNGRADE_STATEMENTS = ( + "DROP TABLE IF EXISTS entities", +) + + +def _execute_statements(statements: tuple[str, ...]) -> None: + for statement in statements: + op.execute(statement) + + +def _enable_row_level_security() -> None: + for table_name in _RLS_TABLES: + op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") + op.execute(f"ALTER TABLE {table_name} FORCE ROW LEVEL SECURITY") + + +def upgrade() -> None: + op.execute(_UPGRADE_SCHEMA_STATEMENT) + _execute_statements(_UPGRADE_GRANT_STATEMENTS) + _enable_row_level_security() + op.execute(_UPGRADE_POLICY_STATEMENT) + + +def downgrade() -> None: + _execute_statements(_DOWNGRADE_STATEMENTS) diff --git a/apps/api/alembic/versions/20260312_0007_entity_edges.py b/apps/api/alembic/versions/20260312_0007_entity_edges.py new file mode 100644 index 0000000..fa08bda --- /dev/null +++ b/apps/api/alembic/versions/20260312_0007_entity_edges.py @@ -0,0 +1,83 @@ +"""Add explicit user-scoped entity edges with simple temporal metadata.""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260312_0007" +down_revision = "20260312_0006" +branch_labels = None +depends_on = None + +_RLS_TABLES = ("entity_edges",) + +_UPGRADE_SCHEMA_STATEMENT = """ + CREATE TABLE entity_edges ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + from_entity_id uuid NOT NULL, + to_entity_id uuid NOT NULL, + relationship_type text NOT NULL, + valid_from timestamptz NULL, + valid_to timestamptz NULL, + source_memory_ids jsonb NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (id, user_id), + CONSTRAINT entity_edges_from_entity_fkey + FOREIGN KEY (from_entity_id, user_id) REFERENCES entities(id, user_id) ON DELETE CASCADE, + CONSTRAINT entity_edges_to_entity_fkey + FOREIGN KEY (to_entity_id, user_id) REFERENCES entities(id, user_id) ON DELETE CASCADE, + CONSTRAINT entity_edges_relationship_type_length_check + CHECK (char_length(relationship_type) BETWEEN 1 AND 100), + CONSTRAINT entity_edges_source_memory_ids_array_check + CHECK (jsonb_typeof(source_memory_ids) = 'array'), + CONSTRAINT entity_edges_source_memory_ids_nonempty_check + CHECK (jsonb_array_length(source_memory_ids) > 0), + CONSTRAINT entity_edges_valid_range_check + CHECK (valid_from IS NULL OR valid_to IS NULL OR valid_to >= valid_from) + ); + + CREATE INDEX entity_edges_user_created_idx + ON entity_edges (user_id, created_at, id); + CREATE INDEX entity_edges_user_from_created_idx + ON entity_edges (user_id, from_entity_id, created_at, id); + CREATE INDEX entity_edges_user_to_created_idx + ON entity_edges (user_id, to_entity_id, created_at, id); + """ + +_UPGRADE_GRANT_STATEMENTS = ( + "GRANT SELECT, INSERT ON entity_edges TO alicebot_app", +) + +_UPGRADE_POLICY_STATEMENT = """ + CREATE POLICY entity_edges_is_owner ON entity_edges + USING (user_id = app.current_user_id()) + WITH CHECK (user_id = app.current_user_id()); + """ + +_DOWNGRADE_STATEMENTS = ( + "DROP TABLE IF EXISTS entity_edges", +) + + +def _execute_statements(statements: tuple[str, ...]) -> None: + for statement in statements: + op.execute(statement) + + +def _enable_row_level_security() -> None: + for table_name in _RLS_TABLES: + op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") + op.execute(f"ALTER TABLE {table_name} FORCE ROW LEVEL SECURITY") + + +def upgrade() -> None: + op.execute(_UPGRADE_SCHEMA_STATEMENT) + _execute_statements(_UPGRADE_GRANT_STATEMENTS) + _enable_row_level_security() + op.execute(_UPGRADE_POLICY_STATEMENT) + + +def downgrade() -> None: + _execute_statements(_DOWNGRADE_STATEMENTS) diff --git a/apps/api/alembic/versions/20260312_0008_embedding_substrate.py b/apps/api/alembic/versions/20260312_0008_embedding_substrate.py new file mode 100644 index 0000000..d83551e --- /dev/null +++ b/apps/api/alembic/versions/20260312_0008_embedding_substrate.py @@ -0,0 +1,115 @@ +"""Add versioned embedding configs and user-scoped memory embeddings.""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260312_0008" +down_revision = "20260312_0007" +branch_labels = None +depends_on = None + +_RLS_TABLES = ("embedding_configs", "memory_embeddings") + +_UPGRADE_SCHEMA_STATEMENT = """ + CREATE TABLE embedding_configs ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider text NOT NULL, + model text NOT NULL, + version text NOT NULL, + dimensions integer NOT NULL, + status text NOT NULL, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (id, user_id), + UNIQUE (user_id, provider, model, version), + CONSTRAINT embedding_configs_provider_length_check + CHECK (char_length(provider) BETWEEN 1 AND 100), + CONSTRAINT embedding_configs_model_length_check + CHECK (char_length(model) BETWEEN 1 AND 200), + CONSTRAINT embedding_configs_version_length_check + CHECK (char_length(version) BETWEEN 1 AND 100), + CONSTRAINT embedding_configs_dimensions_check + CHECK (dimensions > 0), + CONSTRAINT embedding_configs_status_check + CHECK (status IN ('active', 'deprecated', 'disabled')), + CONSTRAINT embedding_configs_metadata_object_check + CHECK (jsonb_typeof(metadata) = 'object') + ); + + CREATE INDEX embedding_configs_user_created_idx + ON embedding_configs (user_id, created_at, id); + + CREATE TABLE memory_embeddings ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + memory_id uuid NOT NULL, + embedding_config_id uuid NOT NULL, + dimensions integer NOT NULL, + vector jsonb NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (id, user_id), + UNIQUE (user_id, memory_id, embedding_config_id), + CONSTRAINT memory_embeddings_memory_fkey + FOREIGN KEY (memory_id, user_id) REFERENCES memories(id, user_id) ON DELETE CASCADE, + CONSTRAINT memory_embeddings_embedding_config_fkey + FOREIGN KEY (embedding_config_id, user_id) + REFERENCES embedding_configs(id, user_id) ON DELETE CASCADE, + CONSTRAINT memory_embeddings_dimensions_check + CHECK (dimensions > 0), + CONSTRAINT memory_embeddings_vector_array_check + CHECK (jsonb_typeof(vector) = 'array'), + CONSTRAINT memory_embeddings_vector_nonempty_check + CHECK (jsonb_array_length(vector) > 0), + CONSTRAINT memory_embeddings_vector_dimensions_match_check + CHECK (jsonb_array_length(vector) = dimensions) + ); + + CREATE INDEX memory_embeddings_user_memory_created_idx + ON memory_embeddings (user_id, memory_id, created_at, id); + """ + +_UPGRADE_GRANT_STATEMENTS = ( + "GRANT SELECT, INSERT ON embedding_configs TO alicebot_app", + "GRANT SELECT, INSERT, UPDATE ON memory_embeddings TO alicebot_app", +) + +_UPGRADE_POLICY_STATEMENT = """ + CREATE POLICY embedding_configs_is_owner ON embedding_configs + USING (user_id = app.current_user_id()) + WITH CHECK (user_id = app.current_user_id()); + + CREATE POLICY memory_embeddings_is_owner ON memory_embeddings + USING (user_id = app.current_user_id()) + WITH CHECK (user_id = app.current_user_id()); + """ + +_DOWNGRADE_STATEMENTS = ( + "DROP TABLE IF EXISTS memory_embeddings", + "DROP TABLE IF EXISTS embedding_configs", +) + + +def _execute_statements(statements: tuple[str, ...]) -> None: + for statement in statements: + op.execute(statement) + + +def _enable_row_level_security() -> None: + for table_name in _RLS_TABLES: + op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") + op.execute(f"ALTER TABLE {table_name} FORCE ROW LEVEL SECURITY") + + +def upgrade() -> None: + op.execute(_UPGRADE_SCHEMA_STATEMENT) + _execute_statements(_UPGRADE_GRANT_STATEMENTS) + _enable_row_level_security() + op.execute(_UPGRADE_POLICY_STATEMENT) + + +def downgrade() -> None: + _execute_statements(_DOWNGRADE_STATEMENTS) diff --git a/apps/api/alembic/versions/20260312_0009_policy_and_consent_core.py b/apps/api/alembic/versions/20260312_0009_policy_and_consent_core.py new file mode 100644 index 0000000..25fcf20 --- /dev/null +++ b/apps/api/alembic/versions/20260312_0009_policy_and_consent_core.py @@ -0,0 +1,111 @@ +"""Add user-scoped consents and deterministic policy storage.""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260312_0009" +down_revision = "20260312_0008" +branch_labels = None +depends_on = None + +_RLS_TABLES = ("consents", "policies") + +_UPGRADE_SCHEMA_STATEMENT = """ + CREATE TABLE consents ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + consent_key text NOT NULL, + status text NOT NULL, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (id, user_id), + UNIQUE (user_id, consent_key), + CONSTRAINT consents_key_length_check + CHECK (char_length(consent_key) BETWEEN 1 AND 200), + CONSTRAINT consents_status_check + CHECK (status IN ('granted', 'revoked')), + CONSTRAINT consents_metadata_object_check + CHECK (jsonb_typeof(metadata) = 'object') + ); + + CREATE INDEX consents_user_key_created_idx + ON consents (user_id, consent_key, created_at, id); + + CREATE TABLE policies ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name text NOT NULL, + action text NOT NULL, + scope text NOT NULL, + effect text NOT NULL, + priority integer NOT NULL, + active boolean NOT NULL DEFAULT TRUE, + conditions jsonb NOT NULL DEFAULT '{}'::jsonb, + required_consents jsonb NOT NULL DEFAULT '[]'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (id, user_id), + CONSTRAINT policies_name_length_check + CHECK (char_length(name) BETWEEN 1 AND 200), + CONSTRAINT policies_action_length_check + CHECK (char_length(action) BETWEEN 1 AND 100), + CONSTRAINT policies_scope_length_check + CHECK (char_length(scope) BETWEEN 1 AND 200), + CONSTRAINT policies_effect_check + CHECK (effect IN ('allow', 'deny', 'require_approval')), + CONSTRAINT policies_priority_check + CHECK (priority >= 0), + CONSTRAINT policies_conditions_object_check + CHECK (jsonb_typeof(conditions) = 'object'), + CONSTRAINT policies_required_consents_array_check + CHECK (jsonb_typeof(required_consents) = 'array') + ); + + CREATE INDEX policies_user_active_priority_created_idx + ON policies (user_id, active, priority, created_at, id); + """ + +_UPGRADE_GRANT_STATEMENTS = ( + "GRANT SELECT, INSERT, UPDATE ON consents TO alicebot_app", + "GRANT SELECT, INSERT ON policies TO alicebot_app", +) + +_UPGRADE_POLICY_STATEMENT = """ + CREATE POLICY consents_is_owner ON consents + USING (user_id = app.current_user_id()) + WITH CHECK (user_id = app.current_user_id()); + + CREATE POLICY policies_is_owner ON policies + USING (user_id = app.current_user_id()) + WITH CHECK (user_id = app.current_user_id()); + """ + +_DOWNGRADE_STATEMENTS = ( + "DROP TABLE IF EXISTS policies", + "DROP TABLE IF EXISTS consents", +) + + +def _execute_statements(statements: tuple[str, ...]) -> None: + for statement in statements: + op.execute(statement) + + +def _enable_row_level_security() -> None: + for table_name in _RLS_TABLES: + op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") + op.execute(f"ALTER TABLE {table_name} FORCE ROW LEVEL SECURITY") + + +def upgrade() -> None: + op.execute(_UPGRADE_SCHEMA_STATEMENT) + _execute_statements(_UPGRADE_GRANT_STATEMENTS) + _enable_row_level_security() + op.execute(_UPGRADE_POLICY_STATEMENT) + + +def downgrade() -> None: + _execute_statements(_DOWNGRADE_STATEMENTS) diff --git a/apps/api/alembic/versions/20260312_0010_tools_registry_and_allowlist.py b/apps/api/alembic/versions/20260312_0010_tools_registry_and_allowlist.py new file mode 100644 index 0000000..6d58470 --- /dev/null +++ b/apps/api/alembic/versions/20260312_0010_tools_registry_and_allowlist.py @@ -0,0 +1,96 @@ +"""Add stable tool registry storage for deterministic allowlist evaluation.""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260312_0010" +down_revision = "20260312_0009" +branch_labels = None +depends_on = None + +_RLS_TABLES = ("tools",) + +_UPGRADE_SCHEMA_STATEMENT = """ + CREATE TABLE tools ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + tool_key text NOT NULL, + name text NOT NULL, + description text NOT NULL, + version text NOT NULL, + metadata_version text NOT NULL, + active boolean NOT NULL DEFAULT TRUE, + tags jsonb NOT NULL DEFAULT '[]'::jsonb, + action_hints jsonb NOT NULL DEFAULT '[]'::jsonb, + scope_hints jsonb NOT NULL DEFAULT '[]'::jsonb, + domain_hints jsonb NOT NULL DEFAULT '[]'::jsonb, + risk_hints jsonb NOT NULL DEFAULT '[]'::jsonb, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (id, user_id), + UNIQUE (user_id, tool_key, version), + CONSTRAINT tools_key_length_check + CHECK (char_length(tool_key) BETWEEN 1 AND 200), + CONSTRAINT tools_name_length_check + CHECK (char_length(name) BETWEEN 1 AND 200), + CONSTRAINT tools_description_length_check + CHECK (char_length(description) BETWEEN 1 AND 500), + CONSTRAINT tools_version_length_check + CHECK (char_length(version) BETWEEN 1 AND 100), + CONSTRAINT tools_metadata_version_check + CHECK (metadata_version = 'tool_metadata_v0'), + CONSTRAINT tools_tags_array_check + CHECK (jsonb_typeof(tags) = 'array'), + CONSTRAINT tools_action_hints_array_check + CHECK (jsonb_typeof(action_hints) = 'array'), + CONSTRAINT tools_scope_hints_array_check + CHECK (jsonb_typeof(scope_hints) = 'array'), + CONSTRAINT tools_domain_hints_array_check + CHECK (jsonb_typeof(domain_hints) = 'array'), + CONSTRAINT tools_risk_hints_array_check + CHECK (jsonb_typeof(risk_hints) = 'array'), + CONSTRAINT tools_metadata_object_check + CHECK (jsonb_typeof(metadata) = 'object') + ); + + CREATE INDEX tools_user_active_key_version_created_idx + ON tools (user_id, active, tool_key, version, created_at, id); + """ + +_UPGRADE_GRANT_STATEMENTS = ( + "GRANT SELECT, INSERT ON tools TO alicebot_app", +) + +_UPGRADE_POLICY_STATEMENT = """ + CREATE POLICY tools_is_owner ON tools + USING (user_id = app.current_user_id()) + WITH CHECK (user_id = app.current_user_id()); + """ + +_DOWNGRADE_STATEMENTS = ( + "DROP TABLE IF EXISTS tools", +) + + +def _execute_statements(statements: tuple[str, ...]) -> None: + for statement in statements: + op.execute(statement) + + +def _enable_row_level_security() -> None: + for table_name in _RLS_TABLES: + op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") + op.execute(f"ALTER TABLE {table_name} FORCE ROW LEVEL SECURITY") + + +def upgrade() -> None: + op.execute(_UPGRADE_SCHEMA_STATEMENT) + _execute_statements(_UPGRADE_GRANT_STATEMENTS) + _enable_row_level_security() + op.execute(_UPGRADE_POLICY_STATEMENT) + + +def downgrade() -> None: + _execute_statements(_DOWNGRADE_STATEMENTS) diff --git a/apps/api/alembic/versions/20260312_0011_approval_request_records.py b/apps/api/alembic/versions/20260312_0011_approval_request_records.py new file mode 100644 index 0000000..49aff5c --- /dev/null +++ b/apps/api/alembic/versions/20260312_0011_approval_request_records.py @@ -0,0 +1,88 @@ +"""Add durable approval request records.""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260312_0011" +down_revision = "20260312_0010" +branch_labels = None +depends_on = None + +_RLS_TABLES = ("approvals",) + +_UPGRADE_SCHEMA_STATEMENT = """ + CREATE TABLE approvals ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + thread_id uuid NOT NULL, + tool_id uuid NOT NULL, + status text NOT NULL DEFAULT 'pending', + request jsonb NOT NULL, + tool jsonb NOT NULL, + routing jsonb NOT NULL, + routing_trace_id uuid NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (id, user_id), + CONSTRAINT approvals_thread_user_fk + FOREIGN KEY (thread_id, user_id) + REFERENCES threads(id, user_id) + ON DELETE CASCADE, + CONSTRAINT approvals_tool_user_fk + FOREIGN KEY (tool_id, user_id) + REFERENCES tools(id, user_id) + ON DELETE RESTRICT, + CONSTRAINT approvals_routing_trace_user_fk + FOREIGN KEY (routing_trace_id, user_id) + REFERENCES traces(id, user_id) + ON DELETE RESTRICT, + CONSTRAINT approvals_status_check + CHECK (status = 'pending'), + CONSTRAINT approvals_request_object_check + CHECK (jsonb_typeof(request) = 'object'), + CONSTRAINT approvals_tool_object_check + CHECK (jsonb_typeof(tool) = 'object'), + CONSTRAINT approvals_routing_object_check + CHECK (jsonb_typeof(routing) = 'object') + ); + + CREATE INDEX approvals_user_created_idx + ON approvals (user_id, created_at, id); + """ + +_UPGRADE_GRANT_STATEMENTS = ( + "GRANT SELECT, INSERT ON approvals TO alicebot_app", +) + +_UPGRADE_POLICY_STATEMENT = """ + CREATE POLICY approvals_is_owner ON approvals + USING (user_id = app.current_user_id()) + WITH CHECK (user_id = app.current_user_id()); + """ + +_DOWNGRADE_STATEMENTS = ( + "DROP TABLE IF EXISTS approvals", +) + + +def _execute_statements(statements: tuple[str, ...]) -> None: + for statement in statements: + op.execute(statement) + + +def _enable_row_level_security() -> None: + for table_name in _RLS_TABLES: + op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") + op.execute(f"ALTER TABLE {table_name} FORCE ROW LEVEL SECURITY") + + +def upgrade() -> None: + op.execute(_UPGRADE_SCHEMA_STATEMENT) + _execute_statements(_UPGRADE_GRANT_STATEMENTS) + _enable_row_level_security() + op.execute(_UPGRADE_POLICY_STATEMENT) + + +def downgrade() -> None: + _execute_statements(_DOWNGRADE_STATEMENTS) diff --git a/apps/api/alembic/versions/20260312_0012_approval_resolution.py b/apps/api/alembic/versions/20260312_0012_approval_resolution.py new file mode 100644 index 0000000..7ef2907 --- /dev/null +++ b/apps/api/alembic/versions/20260312_0012_approval_resolution.py @@ -0,0 +1,63 @@ +"""Add approval resolution state and runtime update access.""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260312_0012" +down_revision = "20260312_0011" +branch_labels = None +depends_on = None + +_UPGRADE_SCHEMA_STATEMENT = """ + ALTER TABLE approvals + DROP CONSTRAINT approvals_status_check, + ADD COLUMN resolved_at timestamptz, + ADD COLUMN resolved_by_user_id uuid REFERENCES users(id) ON DELETE RESTRICT, + ADD CONSTRAINT approvals_status_check + CHECK (status IN ('pending', 'approved', 'rejected')), + ADD CONSTRAINT approvals_resolution_consistency_check + CHECK ( + (status = 'pending' AND resolved_at IS NULL AND resolved_by_user_id IS NULL) + OR ( + status IN ('approved', 'rejected') + AND resolved_at IS NOT NULL + AND resolved_by_user_id IS NOT NULL + ) + ), + ADD CONSTRAINT approvals_resolved_by_owner_check + CHECK (resolved_by_user_id IS NULL OR resolved_by_user_id = user_id); + """ + +_UPGRADE_GRANT_STATEMENTS = ( + "GRANT UPDATE ON approvals TO alicebot_app", +) + +_DOWNGRADE_STATEMENTS = ( + "REVOKE UPDATE ON approvals FROM alicebot_app", + """ + ALTER TABLE approvals + DROP CONSTRAINT approvals_resolved_by_owner_check, + DROP CONSTRAINT approvals_resolution_consistency_check, + DROP CONSTRAINT approvals_status_check, + DROP COLUMN resolved_by_user_id, + DROP COLUMN resolved_at, + ADD CONSTRAINT approvals_status_check + CHECK (status = 'pending'); + """, +) + + +def _execute_statements(statements: tuple[str, ...]) -> None: + for statement in statements: + op.execute(statement) + + +def upgrade() -> None: + op.execute(_UPGRADE_SCHEMA_STATEMENT) + _execute_statements(_UPGRADE_GRANT_STATEMENTS) + + +def downgrade() -> None: + _execute_statements(_DOWNGRADE_STATEMENTS) diff --git a/apps/api/alembic/versions/20260313_0013_tool_executions.py b/apps/api/alembic/versions/20260313_0013_tool_executions.py new file mode 100644 index 0000000..9bcfdfe --- /dev/null +++ b/apps/api/alembic/versions/20260313_0013_tool_executions.py @@ -0,0 +1,118 @@ +"""Add durable tool execution review records.""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260313_0013" +down_revision = "20260312_0012" +branch_labels = None +depends_on = None + +_RLS_TABLES = ("tool_executions",) + +_UPGRADE_SCHEMA_STATEMENT = """ + CREATE TABLE tool_executions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + approval_id uuid NOT NULL, + thread_id uuid NOT NULL, + tool_id uuid NOT NULL, + trace_id uuid NOT NULL, + request_event_id uuid, + result_event_id uuid, + status text NOT NULL, + handler_key text, + request jsonb NOT NULL, + tool jsonb NOT NULL, + result jsonb NOT NULL, + executed_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (id, user_id), + CONSTRAINT tool_executions_approval_user_fk + FOREIGN KEY (approval_id, user_id) + REFERENCES approvals(id, user_id) + ON DELETE RESTRICT, + CONSTRAINT tool_executions_thread_user_fk + FOREIGN KEY (thread_id, user_id) + REFERENCES threads(id, user_id) + ON DELETE CASCADE, + CONSTRAINT tool_executions_tool_user_fk + FOREIGN KEY (tool_id, user_id) + REFERENCES tools(id, user_id) + ON DELETE RESTRICT, + CONSTRAINT tool_executions_trace_user_fk + FOREIGN KEY (trace_id, user_id) + REFERENCES traces(id, user_id) + ON DELETE RESTRICT, + CONSTRAINT tool_executions_request_event_user_fk + FOREIGN KEY (request_event_id, user_id) + REFERENCES events(id, user_id) + ON DELETE RESTRICT, + CONSTRAINT tool_executions_result_event_user_fk + FOREIGN KEY (result_event_id, user_id) + REFERENCES events(id, user_id) + ON DELETE RESTRICT, + CONSTRAINT tool_executions_status_check + CHECK (status IN ('completed', 'blocked')), + CONSTRAINT tool_executions_request_object_check + CHECK (jsonb_typeof(request) = 'object'), + CONSTRAINT tool_executions_tool_object_check + CHECK (jsonb_typeof(tool) = 'object'), + CONSTRAINT tool_executions_result_object_check + CHECK (jsonb_typeof(result) = 'object'), + CONSTRAINT tool_executions_status_event_consistency_check + CHECK ( + ( + status = 'completed' + AND handler_key IS NOT NULL + AND request_event_id IS NOT NULL + AND result_event_id IS NOT NULL + ) + OR ( + status = 'blocked' + AND request_event_id IS NULL + AND result_event_id IS NULL + ) + ) + ); + + CREATE INDEX tool_executions_user_executed_idx + ON tool_executions (user_id, executed_at, id); + """ + +_UPGRADE_GRANT_STATEMENTS = ( + "GRANT SELECT, INSERT ON tool_executions TO alicebot_app", +) + +_UPGRADE_POLICY_STATEMENT = """ + CREATE POLICY tool_executions_is_owner ON tool_executions + USING (user_id = app.current_user_id()) + WITH CHECK (user_id = app.current_user_id()); + """ + +_DOWNGRADE_STATEMENTS = ( + "DROP TABLE IF EXISTS tool_executions", +) + + +def _execute_statements(statements: tuple[str, ...]) -> None: + for statement in statements: + op.execute(statement) + + +def _enable_row_level_security() -> None: + for table_name in _RLS_TABLES: + op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") + op.execute(f"ALTER TABLE {table_name} FORCE ROW LEVEL SECURITY") + + +def upgrade() -> None: + op.execute(_UPGRADE_SCHEMA_STATEMENT) + _execute_statements(_UPGRADE_GRANT_STATEMENTS) + _enable_row_level_security() + op.execute(_UPGRADE_POLICY_STATEMENT) + + +def downgrade() -> None: + _execute_statements(_DOWNGRADE_STATEMENTS) diff --git a/apps/api/alembic/versions/20260313_0014_execution_budgets.py b/apps/api/alembic/versions/20260313_0014_execution_budgets.py new file mode 100644 index 0000000..f6c3519 --- /dev/null +++ b/apps/api/alembic/versions/20260313_0014_execution_budgets.py @@ -0,0 +1,71 @@ +"""Add deterministic execution budget records.""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260313_0014" +down_revision = "20260313_0013" +branch_labels = None +depends_on = None + +_RLS_TABLES = ("execution_budgets",) + +_UPGRADE_SCHEMA_STATEMENT = """ + CREATE TABLE execution_budgets ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + tool_key text, + domain_hint text, + max_completed_executions integer NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (id, user_id), + CONSTRAINT execution_budgets_selector_check + CHECK (tool_key IS NOT NULL OR domain_hint IS NOT NULL), + CONSTRAINT execution_budgets_max_completed_executions_check + CHECK (max_completed_executions > 0) + ); + + CREATE INDEX execution_budgets_user_created_idx + ON execution_budgets (user_id, created_at, id); + + CREATE INDEX execution_budgets_user_match_idx + ON execution_budgets (user_id, tool_key, domain_hint, created_at, id); + """ + +_UPGRADE_GRANT_STATEMENTS = ( + "GRANT SELECT, INSERT ON execution_budgets TO alicebot_app", +) + +_UPGRADE_POLICY_STATEMENT = """ + CREATE POLICY execution_budgets_is_owner ON execution_budgets + USING (user_id = app.current_user_id()) + WITH CHECK (user_id = app.current_user_id()); + """ + +_DOWNGRADE_STATEMENTS = ( + "DROP TABLE IF EXISTS execution_budgets", +) + + +def _execute_statements(statements: tuple[str, ...]) -> None: + for statement in statements: + op.execute(statement) + + +def _enable_row_level_security() -> None: + for table_name in _RLS_TABLES: + op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") + op.execute(f"ALTER TABLE {table_name} FORCE ROW LEVEL SECURITY") + + +def upgrade() -> None: + op.execute(_UPGRADE_SCHEMA_STATEMENT) + _execute_statements(_UPGRADE_GRANT_STATEMENTS) + _enable_row_level_security() + op.execute(_UPGRADE_POLICY_STATEMENT) + + +def downgrade() -> None: + _execute_statements(_DOWNGRADE_STATEMENTS) diff --git a/apps/api/alembic/versions/20260313_0015_execution_budget_lifecycle.py b/apps/api/alembic/versions/20260313_0015_execution_budget_lifecycle.py new file mode 100644 index 0000000..ffacd8c --- /dev/null +++ b/apps/api/alembic/versions/20260313_0015_execution_budget_lifecycle.py @@ -0,0 +1,80 @@ +"""Add execution budget lifecycle controls.""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260313_0015" +down_revision = "20260313_0014" +branch_labels = None +depends_on = None + +_UPGRADE_STATEMENTS = ( + """ + ALTER TABLE execution_budgets + ADD COLUMN status text NOT NULL DEFAULT 'active', + ADD COLUMN deactivated_at timestamptz, + ADD COLUMN superseded_by_budget_id uuid REFERENCES execution_budgets(id) ON DELETE SET NULL DEFERRABLE INITIALLY DEFERRED, + ADD COLUMN supersedes_budget_id uuid REFERENCES execution_budgets(id) ON DELETE SET NULL DEFERRABLE INITIALLY DEFERRED; + """, + """ + ALTER TABLE execution_budgets + ADD CONSTRAINT execution_budgets_status_check + CHECK (status IN ('active', 'inactive', 'superseded')), + ADD CONSTRAINT execution_budgets_lifecycle_state_check + CHECK ( + (status = 'active' AND deactivated_at IS NULL AND superseded_by_budget_id IS NULL) + OR (status = 'inactive' AND deactivated_at IS NOT NULL AND superseded_by_budget_id IS NULL) + OR (status = 'superseded' AND deactivated_at IS NOT NULL AND superseded_by_budget_id IS NOT NULL) + ), + ADD CONSTRAINT execution_budgets_supersedes_budget_unique + UNIQUE (supersedes_budget_id); + """, + """ + CREATE INDEX execution_budgets_user_status_created_idx + ON execution_budgets (user_id, status, created_at, id); + """, + """ + CREATE UNIQUE INDEX execution_budgets_one_active_scope_idx + ON execution_budgets ( + user_id, + COALESCE(tool_key, ''), + COALESCE(domain_hint, '') + ) + WHERE status = 'active'; + """, + "GRANT SELECT, INSERT, UPDATE ON execution_budgets TO alicebot_app", +) + +_DOWNGRADE_STATEMENTS = ( + "REVOKE UPDATE ON execution_budgets FROM alicebot_app", + "DROP INDEX IF EXISTS execution_budgets_one_active_scope_idx", + "DROP INDEX IF EXISTS execution_budgets_user_status_created_idx", + """ + ALTER TABLE execution_budgets + DROP CONSTRAINT IF EXISTS execution_budgets_supersedes_budget_unique, + DROP CONSTRAINT IF EXISTS execution_budgets_lifecycle_state_check, + DROP CONSTRAINT IF EXISTS execution_budgets_status_check; + """, + """ + ALTER TABLE execution_budgets + DROP COLUMN IF EXISTS supersedes_budget_id, + DROP COLUMN IF EXISTS superseded_by_budget_id, + DROP COLUMN IF EXISTS deactivated_at, + DROP COLUMN IF EXISTS status; + """, +) + + +def _execute_statements(statements: tuple[str, ...]) -> None: + for statement in statements: + op.execute(statement) + + +def upgrade() -> None: + _execute_statements(_UPGRADE_STATEMENTS) + + +def downgrade() -> None: + _execute_statements(_DOWNGRADE_STATEMENTS) diff --git a/apps/api/alembic/versions/20260313_0016_execution_budget_rolling_window.py b/apps/api/alembic/versions/20260313_0016_execution_budget_rolling_window.py new file mode 100644 index 0000000..31a842c --- /dev/null +++ b/apps/api/alembic/versions/20260313_0016_execution_budget_rolling_window.py @@ -0,0 +1,47 @@ +"""Add optional rolling-window execution budget support.""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260313_0016" +down_revision = "20260313_0015" +branch_labels = None +depends_on = None + +_UPGRADE_STATEMENTS = ( + """ + ALTER TABLE execution_budgets + ADD COLUMN rolling_window_seconds integer; + """, + """ + ALTER TABLE execution_budgets + ADD CONSTRAINT execution_budgets_rolling_window_seconds_check + CHECK (rolling_window_seconds IS NULL OR rolling_window_seconds > 0); + """, +) + +_DOWNGRADE_STATEMENTS = ( + """ + ALTER TABLE execution_budgets + DROP CONSTRAINT IF EXISTS execution_budgets_rolling_window_seconds_check; + """, + """ + ALTER TABLE execution_budgets + DROP COLUMN IF EXISTS rolling_window_seconds; + """, +) + + +def _execute_statements(statements: tuple[str, ...]) -> None: + for statement in statements: + op.execute(statement) + + +def upgrade() -> None: + _execute_statements(_UPGRADE_STATEMENTS) + + +def downgrade() -> None: + _execute_statements(_DOWNGRADE_STATEMENTS) diff --git a/apps/api/alembic/versions/20260313_0017_tasks_lifecycle_records.py b/apps/api/alembic/versions/20260313_0017_tasks_lifecycle_records.py new file mode 100644 index 0000000..f00f07c --- /dev/null +++ b/apps/api/alembic/versions/20260313_0017_tasks_lifecycle_records.py @@ -0,0 +1,112 @@ +"""Add durable task records with deterministic lifecycle status.""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260313_0017" +down_revision = "20260313_0016" +branch_labels = None +depends_on = None + +_RLS_TABLES = ("tasks",) + +_UPGRADE_SCHEMA_STATEMENT = """ + CREATE TABLE tasks ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + thread_id uuid NOT NULL, + tool_id uuid NOT NULL, + status text NOT NULL, + request jsonb NOT NULL, + tool jsonb NOT NULL, + latest_approval_id uuid, + latest_execution_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (id, user_id), + CONSTRAINT tasks_thread_user_fk + FOREIGN KEY (thread_id, user_id) + REFERENCES threads(id, user_id) + ON DELETE CASCADE, + CONSTRAINT tasks_tool_user_fk + FOREIGN KEY (tool_id, user_id) + REFERENCES tools(id, user_id) + ON DELETE RESTRICT, + CONSTRAINT tasks_latest_approval_user_fk + FOREIGN KEY (latest_approval_id, user_id) + REFERENCES approvals(id, user_id) + ON DELETE RESTRICT, + CONSTRAINT tasks_latest_execution_user_fk + FOREIGN KEY (latest_execution_id, user_id) + REFERENCES tool_executions(id, user_id) + ON DELETE RESTRICT, + CONSTRAINT tasks_status_check + CHECK (status IN ('pending_approval', 'approved', 'executed', 'denied', 'blocked')), + CONSTRAINT tasks_request_object_check + CHECK (jsonb_typeof(request) = 'object'), + CONSTRAINT tasks_tool_object_check + CHECK (jsonb_typeof(tool) = 'object'), + CONSTRAINT tasks_pending_approval_link_check + CHECK (status <> 'pending_approval' OR latest_approval_id IS NOT NULL), + CONSTRAINT tasks_execution_link_check + CHECK ( + ( + status IN ('executed', 'blocked') + AND latest_execution_id IS NOT NULL + ) + OR ( + status NOT IN ('executed', 'blocked') + AND latest_execution_id IS NULL + ) + ) + ); + + CREATE INDEX tasks_user_created_idx + ON tasks (user_id, created_at, id); + + CREATE UNIQUE INDEX tasks_latest_approval_unique_idx + ON tasks (user_id, latest_approval_id) + WHERE latest_approval_id IS NOT NULL; + + CREATE UNIQUE INDEX tasks_latest_execution_unique_idx + ON tasks (user_id, latest_execution_id) + WHERE latest_execution_id IS NOT NULL; + """ + +_UPGRADE_GRANT_STATEMENTS = ( + "GRANT SELECT, INSERT, UPDATE ON tasks TO alicebot_app", +) + +_UPGRADE_POLICY_STATEMENT = """ + CREATE POLICY tasks_is_owner ON tasks + USING (user_id = app.current_user_id()) + WITH CHECK (user_id = app.current_user_id()); + """ + +_DOWNGRADE_STATEMENTS = ( + "DROP TABLE IF EXISTS tasks", +) + + +def _execute_statements(statements: tuple[str, ...]) -> None: + for statement in statements: + op.execute(statement) + + +def _enable_row_level_security() -> None: + for table_name in _RLS_TABLES: + op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") + op.execute(f"ALTER TABLE {table_name} FORCE ROW LEVEL SECURITY") + + +def upgrade() -> None: + op.execute(_UPGRADE_SCHEMA_STATEMENT) + _execute_statements(_UPGRADE_GRANT_STATEMENTS) + _enable_row_level_security() + op.execute(_UPGRADE_POLICY_STATEMENT) + + +def downgrade() -> None: + _execute_statements(_DOWNGRADE_STATEMENTS) diff --git a/apps/api/alembic/versions/20260313_0018_task_steps.py b/apps/api/alembic/versions/20260313_0018_task_steps.py new file mode 100644 index 0000000..9467472 --- /dev/null +++ b/apps/api/alembic/versions/20260313_0018_task_steps.py @@ -0,0 +1,93 @@ +"""Add durable task-step review records.""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260313_0018" +down_revision = "20260313_0017" +branch_labels = None +depends_on = None + +_RLS_TABLES = ("task_steps",) + +_UPGRADE_SCHEMA_STATEMENT = """ + CREATE TABLE task_steps ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + task_id uuid NOT NULL, + sequence_no integer NOT NULL, + kind text NOT NULL, + status text NOT NULL, + request jsonb NOT NULL, + outcome jsonb NOT NULL, + trace_id uuid NOT NULL, + trace_kind text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (id, user_id), + CONSTRAINT task_steps_task_user_fk + FOREIGN KEY (task_id, user_id) + REFERENCES tasks(id, user_id) + ON DELETE CASCADE, + CONSTRAINT task_steps_trace_user_fk + FOREIGN KEY (trace_id, user_id) + REFERENCES traces(id, user_id) + ON DELETE RESTRICT, + CONSTRAINT task_steps_sequence_no_check + CHECK (sequence_no > 0), + CONSTRAINT task_steps_kind_check + CHECK (kind IN ('governed_request')), + CONSTRAINT task_steps_status_check + CHECK (status IN ('created', 'approved', 'executed', 'blocked', 'denied')), + CONSTRAINT task_steps_request_object_check + CHECK (jsonb_typeof(request) = 'object'), + CONSTRAINT task_steps_outcome_object_check + CHECK (jsonb_typeof(outcome) = 'object'), + CONSTRAINT task_steps_trace_kind_nonempty_check + CHECK (length(trace_kind) > 0) + ); + + CREATE UNIQUE INDEX task_steps_task_sequence_idx + ON task_steps (user_id, task_id, sequence_no); + + CREATE INDEX task_steps_user_created_idx + ON task_steps (user_id, created_at, id); + """ + +_UPGRADE_GRANT_STATEMENTS = ( + "GRANT SELECT, INSERT, UPDATE ON task_steps TO alicebot_app", +) + +_UPGRADE_POLICY_STATEMENT = """ + CREATE POLICY task_steps_is_owner ON task_steps + USING (user_id = app.current_user_id()) + WITH CHECK (user_id = app.current_user_id()); + """ + +_DOWNGRADE_STATEMENTS = ( + "DROP TABLE IF EXISTS task_steps", +) + + +def _execute_statements(statements: tuple[str, ...]) -> None: + for statement in statements: + op.execute(statement) + + +def _enable_row_level_security() -> None: + for table_name in _RLS_TABLES: + op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") + op.execute(f"ALTER TABLE {table_name} FORCE ROW LEVEL SECURITY") + + +def upgrade() -> None: + op.execute(_UPGRADE_SCHEMA_STATEMENT) + _execute_statements(_UPGRADE_GRANT_STATEMENTS) + _enable_row_level_security() + op.execute(_UPGRADE_POLICY_STATEMENT) + + +def downgrade() -> None: + _execute_statements(_DOWNGRADE_STATEMENTS) diff --git a/apps/api/alembic/versions/20260313_0019_task_step_lineage.py b/apps/api/alembic/versions/20260313_0019_task_step_lineage.py new file mode 100644 index 0000000..b0d98a5 --- /dev/null +++ b/apps/api/alembic/versions/20260313_0019_task_step_lineage.py @@ -0,0 +1,58 @@ +"""Add explicit lineage fields for manual task-step continuation.""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260313_0019" +down_revision = "20260313_0018" +branch_labels = None +depends_on = None + +_UPGRADE_SCHEMA_STATEMENT = """ + ALTER TABLE task_steps + ADD COLUMN parent_step_id uuid, + ADD COLUMN source_approval_id uuid, + ADD COLUMN source_execution_id uuid, + ADD CONSTRAINT task_steps_parent_step_user_fk + FOREIGN KEY (parent_step_id, user_id) + REFERENCES task_steps(id, user_id) + ON DELETE RESTRICT, + ADD CONSTRAINT task_steps_source_approval_user_fk + FOREIGN KEY (source_approval_id, user_id) + REFERENCES approvals(id, user_id) + ON DELETE RESTRICT, + ADD CONSTRAINT task_steps_source_execution_user_fk + FOREIGN KEY (source_execution_id, user_id) + REFERENCES tool_executions(id, user_id) + ON DELETE RESTRICT, + ADD CONSTRAINT task_steps_parent_step_not_self_check + CHECK (parent_step_id IS NULL OR parent_step_id <> id); + """ + +_DOWNGRADE_STATEMENTS = ( + """ + ALTER TABLE task_steps + DROP CONSTRAINT task_steps_parent_step_not_self_check, + DROP CONSTRAINT task_steps_source_execution_user_fk, + DROP CONSTRAINT task_steps_source_approval_user_fk, + DROP CONSTRAINT task_steps_parent_step_user_fk, + DROP COLUMN source_execution_id, + DROP COLUMN source_approval_id, + DROP COLUMN parent_step_id; + """, +) + + +def _execute_statements(statements: tuple[str, ...]) -> None: + for statement in statements: + op.execute(statement) + + +def upgrade() -> None: + op.execute(_UPGRADE_SCHEMA_STATEMENT) + + +def downgrade() -> None: + _execute_statements(_DOWNGRADE_STATEMENTS) diff --git a/apps/api/alembic/versions/20260313_0020_approval_task_step_linkage.py b/apps/api/alembic/versions/20260313_0020_approval_task_step_linkage.py new file mode 100644 index 0000000..fe8a270 --- /dev/null +++ b/apps/api/alembic/versions/20260313_0020_approval_task_step_linkage.py @@ -0,0 +1,41 @@ +"""Link approvals directly to their durable task step.""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260313_0020" +down_revision = "20260313_0019" +branch_labels = None +depends_on = None + +_UPGRADE_SCHEMA_STATEMENT = """ + ALTER TABLE approvals + ADD COLUMN task_step_id uuid, + ADD CONSTRAINT approvals_task_step_user_fk + FOREIGN KEY (task_step_id, user_id) + REFERENCES task_steps(id, user_id) + ON DELETE RESTRICT; + """ + +_DOWNGRADE_STATEMENTS = ( + """ + ALTER TABLE approvals + DROP CONSTRAINT approvals_task_step_user_fk, + DROP COLUMN task_step_id; + """, +) + + +def _execute_statements(statements: tuple[str, ...]) -> None: + for statement in statements: + op.execute(statement) + + +def upgrade() -> None: + op.execute(_UPGRADE_SCHEMA_STATEMENT) + + +def downgrade() -> None: + _execute_statements(_DOWNGRADE_STATEMENTS) diff --git a/apps/api/alembic/versions/20260313_0021_tool_execution_task_step_linkage.py b/apps/api/alembic/versions/20260313_0021_tool_execution_task_step_linkage.py new file mode 100644 index 0000000..6a26d41 --- /dev/null +++ b/apps/api/alembic/versions/20260313_0021_tool_execution_task_step_linkage.py @@ -0,0 +1,81 @@ +"""Link tool executions directly to their durable task step.""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260313_0021" +down_revision = "20260313_0020" +branch_labels = None +depends_on = None + +_UPGRADE_STATEMENTS = ( + """ + ALTER TABLE tool_executions + ADD COLUMN task_step_id uuid; + """, + """ + UPDATE tool_executions AS executions + SET task_step_id = COALESCE( + approvals.task_step_id, + ( + SELECT task_steps.id + FROM task_steps + WHERE task_steps.user_id = executions.user_id + AND task_steps.outcome ->> 'approval_id' = approvals.id::text + ORDER BY task_steps.created_at ASC, task_steps.id ASC + LIMIT 1 + ) + ) + FROM approvals + WHERE approvals.id = executions.approval_id + AND approvals.user_id = executions.user_id; + """, + """ + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM tool_executions + WHERE task_step_id IS NULL + ) THEN + RAISE EXCEPTION + 'tool_executions.task_step_id backfill failed for existing rows'; + END IF; + END; + $$; + """, + """ + ALTER TABLE tool_executions + ADD CONSTRAINT tool_executions_task_step_user_fk + FOREIGN KEY (task_step_id, user_id) + REFERENCES task_steps(id, user_id) + ON DELETE RESTRICT; + """, + """ + ALTER TABLE tool_executions + ALTER COLUMN task_step_id SET NOT NULL; + """, +) + +_DOWNGRADE_STATEMENTS = ( + """ + ALTER TABLE tool_executions + DROP CONSTRAINT tool_executions_task_step_user_fk, + DROP COLUMN task_step_id; + """, +) + + +def _execute_statements(statements: tuple[str, ...]) -> None: + for statement in statements: + op.execute(statement) + + +def upgrade() -> None: + _execute_statements(_UPGRADE_STATEMENTS) + + +def downgrade() -> None: + _execute_statements(_DOWNGRADE_STATEMENTS) diff --git a/apps/api/alembic/versions/20260313_0022_task_workspaces.py b/apps/api/alembic/versions/20260313_0022_task_workspaces.py new file mode 100644 index 0000000..626224f --- /dev/null +++ b/apps/api/alembic/versions/20260313_0022_task_workspaces.py @@ -0,0 +1,77 @@ +"""Add user-scoped task workspace records.""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260313_0022" +down_revision = "20260313_0021" +branch_labels = None +depends_on = None + +_RLS_TABLES = ("task_workspaces",) + +_UPGRADE_SCHEMA_STATEMENT = """ + CREATE TABLE task_workspaces ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + task_id uuid NOT NULL, + status text NOT NULL, + local_path text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (id, user_id), + CONSTRAINT task_workspaces_task_user_fk + FOREIGN KEY (task_id, user_id) + REFERENCES tasks(id, user_id) + ON DELETE CASCADE, + CONSTRAINT task_workspaces_status_check + CHECK (status IN ('active')), + CONSTRAINT task_workspaces_local_path_nonempty_check + CHECK (length(local_path) > 0) + ); + + CREATE INDEX task_workspaces_user_created_idx + ON task_workspaces (user_id, created_at, id); + + CREATE UNIQUE INDEX task_workspaces_active_task_idx + ON task_workspaces (user_id, task_id) + WHERE status = 'active'; + """ + +_UPGRADE_GRANT_STATEMENTS = ( + "GRANT SELECT, INSERT ON task_workspaces TO alicebot_app", +) + +_UPGRADE_POLICY_STATEMENT = """ + CREATE POLICY task_workspaces_is_owner ON task_workspaces + USING (user_id = app.current_user_id()) + WITH CHECK (user_id = app.current_user_id()); + """ + +_DOWNGRADE_STATEMENTS = ( + "DROP TABLE IF EXISTS task_workspaces", +) + + +def _execute_statements(statements: tuple[str, ...]) -> None: + for statement in statements: + op.execute(statement) + + +def _enable_row_level_security() -> None: + for table_name in _RLS_TABLES: + op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") + op.execute(f"ALTER TABLE {table_name} FORCE ROW LEVEL SECURITY") + + +def upgrade() -> None: + op.execute(_UPGRADE_SCHEMA_STATEMENT) + _execute_statements(_UPGRADE_GRANT_STATEMENTS) + _enable_row_level_security() + op.execute(_UPGRADE_POLICY_STATEMENT) + + +def downgrade() -> None: + _execute_statements(_DOWNGRADE_STATEMENTS) diff --git a/apps/api/alembic/versions/20260313_0023_task_artifacts.py b/apps/api/alembic/versions/20260313_0023_task_artifacts.py new file mode 100644 index 0000000..a3d32c1 --- /dev/null +++ b/apps/api/alembic/versions/20260313_0023_task_artifacts.py @@ -0,0 +1,87 @@ +"""Add user-scoped task artifact records.""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260313_0023" +down_revision = "20260313_0022" +branch_labels = None +depends_on = None + +_RLS_TABLES = ("task_artifacts",) + +_UPGRADE_SCHEMA_STATEMENT = """ + CREATE TABLE task_artifacts ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + task_id uuid NOT NULL, + task_workspace_id uuid NOT NULL, + status text NOT NULL, + ingestion_status text NOT NULL, + relative_path text NOT NULL, + media_type_hint text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (id, user_id), + CONSTRAINT task_artifacts_task_user_fk + FOREIGN KEY (task_id, user_id) + REFERENCES tasks(id, user_id) + ON DELETE CASCADE, + CONSTRAINT task_artifacts_workspace_user_fk + FOREIGN KEY (task_workspace_id, user_id) + REFERENCES task_workspaces(id, user_id) + ON DELETE CASCADE, + CONSTRAINT task_artifacts_status_check + CHECK (status IN ('registered')), + CONSTRAINT task_artifacts_ingestion_status_check + CHECK (ingestion_status IN ('pending')), + CONSTRAINT task_artifacts_relative_path_nonempty_check + CHECK (length(relative_path) > 0), + CONSTRAINT task_artifacts_media_type_hint_nonempty_check + CHECK (media_type_hint IS NULL OR length(media_type_hint) > 0) + ); + + CREATE INDEX task_artifacts_user_created_idx + ON task_artifacts (user_id, created_at, id); + + CREATE UNIQUE INDEX task_artifacts_workspace_relative_path_idx + ON task_artifacts (user_id, task_workspace_id, relative_path); + """ + +_UPGRADE_GRANT_STATEMENTS = ( + "GRANT SELECT, INSERT ON task_artifacts TO alicebot_app", +) + +_UPGRADE_POLICY_STATEMENT = """ + CREATE POLICY task_artifacts_is_owner ON task_artifacts + USING (user_id = app.current_user_id()) + WITH CHECK (user_id = app.current_user_id()); + """ + +_DOWNGRADE_STATEMENTS = ( + "DROP TABLE IF EXISTS task_artifacts", +) + + +def _execute_statements(statements: tuple[str, ...]) -> None: + for statement in statements: + op.execute(statement) + + +def _enable_row_level_security() -> None: + for table_name in _RLS_TABLES: + op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") + op.execute(f"ALTER TABLE {table_name} FORCE ROW LEVEL SECURITY") + + +def upgrade() -> None: + op.execute(_UPGRADE_SCHEMA_STATEMENT) + _execute_statements(_UPGRADE_GRANT_STATEMENTS) + _enable_row_level_security() + op.execute(_UPGRADE_POLICY_STATEMENT) + + +def downgrade() -> None: + _execute_statements(_DOWNGRADE_STATEMENTS) diff --git a/apps/api/alembic/versions/20260314_0024_task_artifact_chunks.py b/apps/api/alembic/versions/20260314_0024_task_artifact_chunks.py new file mode 100644 index 0000000..ee51410 --- /dev/null +++ b/apps/api/alembic/versions/20260314_0024_task_artifact_chunks.py @@ -0,0 +1,97 @@ +"""Add user-scoped task artifact chunk records.""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260314_0024" +down_revision = "20260313_0023" +branch_labels = None +depends_on = None + +_RLS_TABLES = ("task_artifact_chunks",) + +_UPGRADE_SCHEMA_STATEMENT = """ + CREATE TABLE task_artifact_chunks ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + task_artifact_id uuid NOT NULL, + sequence_no integer NOT NULL, + char_start integer NOT NULL, + char_end_exclusive integer NOT NULL, + text text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (id, user_id), + CONSTRAINT task_artifact_chunks_artifact_user_fk + FOREIGN KEY (task_artifact_id, user_id) + REFERENCES task_artifacts(id, user_id) + ON DELETE CASCADE, + CONSTRAINT task_artifact_chunks_sequence_no_check + CHECK (sequence_no >= 1), + CONSTRAINT task_artifact_chunks_char_start_check + CHECK (char_start >= 0), + CONSTRAINT task_artifact_chunks_char_end_exclusive_check + CHECK (char_end_exclusive > char_start), + CONSTRAINT task_artifact_chunks_text_nonempty_check + CHECK (length(text) > 0) + ); + + CREATE UNIQUE INDEX task_artifact_chunks_artifact_sequence_idx + ON task_artifact_chunks (user_id, task_artifact_id, sequence_no); + """ + +_UPGRADE_GRANT_STATEMENTS = ( + "GRANT UPDATE ON task_artifacts TO alicebot_app", + "GRANT SELECT, INSERT ON task_artifact_chunks TO alicebot_app", +) + +_UPGRADE_POLICY_STATEMENT = """ + CREATE POLICY task_artifact_chunks_is_owner ON task_artifact_chunks + USING (user_id = app.current_user_id()) + WITH CHECK (user_id = app.current_user_id()); + """ + +_UPGRADE_TASK_ARTIFACTS_STATEMENTS = ( + "ALTER TABLE task_artifacts DROP CONSTRAINT task_artifacts_ingestion_status_check", + """ + ALTER TABLE task_artifacts + ADD CONSTRAINT task_artifacts_ingestion_status_check + CHECK (ingestion_status IN ('pending', 'ingested')) + """, +) + +_DOWNGRADE_STATEMENTS = ( + "REVOKE UPDATE ON task_artifacts FROM alicebot_app", + "DROP TABLE IF EXISTS task_artifact_chunks", + "ALTER TABLE task_artifacts DROP CONSTRAINT task_artifacts_ingestion_status_check", + """ + ALTER TABLE task_artifacts + ADD CONSTRAINT task_artifacts_ingestion_status_check + CHECK (ingestion_status IN ('pending')) + """, +) + + +def _execute_statements(statements: tuple[str, ...]) -> None: + for statement in statements: + op.execute(statement) + + +def _enable_row_level_security() -> None: + for table_name in _RLS_TABLES: + op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") + op.execute(f"ALTER TABLE {table_name} FORCE ROW LEVEL SECURITY") + + +def upgrade() -> None: + _execute_statements(_UPGRADE_TASK_ARTIFACTS_STATEMENTS) + op.execute(_UPGRADE_SCHEMA_STATEMENT) + _execute_statements(_UPGRADE_GRANT_STATEMENTS) + _enable_row_level_security() + op.execute(_UPGRADE_POLICY_STATEMENT) + + +def downgrade() -> None: + _execute_statements(_DOWNGRADE_STATEMENTS) diff --git a/apps/api/alembic/versions/20260314_0025_task_artifact_chunk_embeddings.py b/apps/api/alembic/versions/20260314_0025_task_artifact_chunk_embeddings.py new file mode 100644 index 0000000..23cba0e --- /dev/null +++ b/apps/api/alembic/versions/20260314_0025_task_artifact_chunk_embeddings.py @@ -0,0 +1,81 @@ +"""Add user-scoped task artifact chunk embedding records.""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260314_0025" +down_revision = "20260314_0024" +branch_labels = None +depends_on = None + +_RLS_TABLES = ("task_artifact_chunk_embeddings",) + +_UPGRADE_SCHEMA_STATEMENT = """ + CREATE TABLE task_artifact_chunk_embeddings ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + task_artifact_chunk_id uuid NOT NULL, + embedding_config_id uuid NOT NULL, + dimensions integer NOT NULL, + vector jsonb NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (id, user_id), + UNIQUE (user_id, task_artifact_chunk_id, embedding_config_id), + CONSTRAINT task_artifact_chunk_embeddings_chunk_fkey + FOREIGN KEY (task_artifact_chunk_id, user_id) + REFERENCES task_artifact_chunks(id, user_id) ON DELETE CASCADE, + CONSTRAINT task_artifact_chunk_embeddings_embedding_config_fkey + FOREIGN KEY (embedding_config_id, user_id) + REFERENCES embedding_configs(id, user_id) ON DELETE CASCADE, + CONSTRAINT task_artifact_chunk_embeddings_dimensions_check + CHECK (dimensions > 0), + CONSTRAINT task_artifact_chunk_embeddings_vector_array_check + CHECK (jsonb_typeof(vector) = 'array'), + CONSTRAINT task_artifact_chunk_embeddings_vector_nonempty_check + CHECK (jsonb_array_length(vector) > 0), + CONSTRAINT task_artifact_chunk_embeddings_vector_dimensions_match_check + CHECK (jsonb_array_length(vector) = dimensions) + ); + + CREATE INDEX task_artifact_chunk_embeddings_user_chunk_created_idx + ON task_artifact_chunk_embeddings (user_id, task_artifact_chunk_id, created_at, id); + """ + +_UPGRADE_GRANT_STATEMENTS = ( + "GRANT SELECT, INSERT, UPDATE ON task_artifact_chunk_embeddings TO alicebot_app", +) + +_UPGRADE_POLICY_STATEMENT = """ + CREATE POLICY task_artifact_chunk_embeddings_is_owner ON task_artifact_chunk_embeddings + USING (user_id = app.current_user_id()) + WITH CHECK (user_id = app.current_user_id()); + """ + +_DOWNGRADE_STATEMENTS = ( + "DROP TABLE IF EXISTS task_artifact_chunk_embeddings", +) + + +def _execute_statements(statements: tuple[str, ...]) -> None: + for statement in statements: + op.execute(statement) + + +def _enable_row_level_security() -> None: + for table_name in _RLS_TABLES: + op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") + op.execute(f"ALTER TABLE {table_name} FORCE ROW LEVEL SECURITY") + + +def upgrade() -> None: + op.execute(_UPGRADE_SCHEMA_STATEMENT) + _execute_statements(_UPGRADE_GRANT_STATEMENTS) + _enable_row_level_security() + op.execute(_UPGRADE_POLICY_STATEMENT) + + +def downgrade() -> None: + _execute_statements(_DOWNGRADE_STATEMENTS) diff --git a/apps/api/alembic/versions/20260316_0026_gmail_accounts.py b/apps/api/alembic/versions/20260316_0026_gmail_accounts.py new file mode 100644 index 0000000..fce7b4c --- /dev/null +++ b/apps/api/alembic/versions/20260316_0026_gmail_accounts.py @@ -0,0 +1,82 @@ +"""Add user-scoped Gmail account records.""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260316_0026" +down_revision = "20260314_0025" +branch_labels = None +depends_on = None + +GMAIL_READONLY_SCOPE = "https://www.googleapis.com/auth/gmail.readonly" + +_RLS_TABLES = ("gmail_accounts",) + +_UPGRADE_SCHEMA_STATEMENT = f""" + CREATE TABLE gmail_accounts ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider_account_id text NOT NULL, + email_address text NOT NULL, + display_name text, + scope text NOT NULL, + access_token text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (id, user_id), + CONSTRAINT gmail_accounts_provider_account_id_nonempty_check + CHECK (length(provider_account_id) > 0), + CONSTRAINT gmail_accounts_email_address_nonempty_check + CHECK (length(email_address) > 0), + CONSTRAINT gmail_accounts_display_name_nonempty_check + CHECK (display_name IS NULL OR length(display_name) > 0), + CONSTRAINT gmail_accounts_scope_readonly_check + CHECK (scope = '{GMAIL_READONLY_SCOPE}'), + CONSTRAINT gmail_accounts_access_token_nonempty_check + CHECK (length(access_token) > 0) + ); + + CREATE INDEX gmail_accounts_user_created_idx + ON gmail_accounts (user_id, created_at, id); + + CREATE UNIQUE INDEX gmail_accounts_provider_account_idx + ON gmail_accounts (user_id, provider_account_id); + """ + +_UPGRADE_GRANT_STATEMENTS = ( + "GRANT SELECT, INSERT ON gmail_accounts TO alicebot_app", +) + +_UPGRADE_POLICY_STATEMENT = """ + CREATE POLICY gmail_accounts_is_owner ON gmail_accounts + USING (user_id = app.current_user_id()) + WITH CHECK (user_id = app.current_user_id()); + """ + +_DOWNGRADE_STATEMENTS = ( + "DROP TABLE IF EXISTS gmail_accounts", +) + + +def _execute_statements(statements: tuple[str, ...]) -> None: + for statement in statements: + op.execute(statement) + + +def _enable_row_level_security() -> None: + for table_name in _RLS_TABLES: + op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") + op.execute(f"ALTER TABLE {table_name} FORCE ROW LEVEL SECURITY") + + +def upgrade() -> None: + op.execute(_UPGRADE_SCHEMA_STATEMENT) + _execute_statements(_UPGRADE_GRANT_STATEMENTS) + _enable_row_level_security() + op.execute(_UPGRADE_POLICY_STATEMENT) + + +def downgrade() -> None: + _execute_statements(_DOWNGRADE_STATEMENTS) diff --git a/apps/api/alembic/versions/20260316_0027_gmail_account_credentials.py b/apps/api/alembic/versions/20260316_0027_gmail_account_credentials.py new file mode 100644 index 0000000..8f5dd87 --- /dev/null +++ b/apps/api/alembic/versions/20260316_0027_gmail_account_credentials.py @@ -0,0 +1,128 @@ +"""Move Gmail access tokens into a protected credential table.""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260316_0027" +down_revision = "20260316_0026" +branch_labels = None +depends_on = None + +GMAIL_AUTH_KIND_OAUTH_ACCESS_TOKEN = "oauth_access_token" +GMAIL_PROTECTED_CREDENTIAL_KIND = "gmail_oauth_access_token_v1" + +_RLS_TABLES = ("gmail_account_credentials",) + +_UPGRADE_SCHEMA_STATEMENT = f""" + CREATE TABLE gmail_account_credentials ( + gmail_account_id uuid PRIMARY KEY REFERENCES gmail_accounts(id) ON DELETE CASCADE, + user_id uuid NOT NULL, + auth_kind text NOT NULL, + credential_blob jsonb NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + FOREIGN KEY (gmail_account_id, user_id) + REFERENCES gmail_accounts (id, user_id) + ON DELETE CASCADE, + CONSTRAINT gmail_account_credentials_auth_kind_check + CHECK (auth_kind = '{GMAIL_AUTH_KIND_OAUTH_ACCESS_TOKEN}'), + CONSTRAINT gmail_account_credentials_blob_shape_check + CHECK ( + jsonb_typeof(credential_blob) = 'object' + AND credential_blob ? 'credential_kind' + AND credential_blob ? 'access_token' + AND credential_blob ->> 'credential_kind' = '{GMAIL_PROTECTED_CREDENTIAL_KIND}' + AND jsonb_typeof(credential_blob -> 'access_token') = 'string' + AND length(credential_blob ->> 'access_token') > 0 + ) + ); + + CREATE INDEX gmail_account_credentials_user_created_idx + ON gmail_account_credentials (user_id, created_at, gmail_account_id); + """ + +_UPGRADE_BACKFILL_STATEMENT = f""" + INSERT INTO gmail_account_credentials ( + gmail_account_id, + user_id, + auth_kind, + credential_blob, + created_at, + updated_at + ) + SELECT + id, + user_id, + '{GMAIL_AUTH_KIND_OAUTH_ACCESS_TOKEN}', + jsonb_build_object( + 'credential_kind', '{GMAIL_PROTECTED_CREDENTIAL_KIND}', + 'access_token', access_token + ), + created_at, + updated_at + FROM gmail_accounts; + """ + +_UPGRADE_DROP_PLAINTEXT_STATEMENTS = ( + "ALTER TABLE gmail_accounts DROP CONSTRAINT gmail_accounts_access_token_nonempty_check", + "ALTER TABLE gmail_accounts DROP COLUMN access_token", +) + +_UPGRADE_GRANT_STATEMENTS = ( + "GRANT SELECT, INSERT ON gmail_account_credentials TO alicebot_app", +) + +_UPGRADE_POLICY_STATEMENT = """ + CREATE POLICY gmail_account_credentials_is_owner ON gmail_account_credentials + USING (user_id = app.current_user_id()) + WITH CHECK (user_id = app.current_user_id()); + """ + +_DOWNGRADE_ADD_PLAINTEXT_STATEMENTS = ( + "ALTER TABLE gmail_accounts ADD COLUMN access_token text", +) + +_DOWNGRADE_BACKFILL_STATEMENT = """ + UPDATE gmail_accounts AS accounts + SET access_token = credentials.credential_blob ->> 'access_token' + FROM gmail_account_credentials AS credentials + WHERE credentials.gmail_account_id = accounts.id + """ + +_DOWNGRADE_RESTORE_CONSTRAINT_STATEMENTS = ( + "ALTER TABLE gmail_accounts ALTER COLUMN access_token SET NOT NULL", + """ + ALTER TABLE gmail_accounts + ADD CONSTRAINT gmail_accounts_access_token_nonempty_check + CHECK (length(access_token) > 0) + """, + "DROP TABLE IF EXISTS gmail_account_credentials", +) + + +def _execute_statements(statements: tuple[str, ...]) -> None: + for statement in statements: + op.execute(statement) + + +def _enable_row_level_security() -> None: + for table_name in _RLS_TABLES: + op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") + op.execute(f"ALTER TABLE {table_name} FORCE ROW LEVEL SECURITY") + + +def upgrade() -> None: + op.execute(_UPGRADE_SCHEMA_STATEMENT) + op.execute(_UPGRADE_BACKFILL_STATEMENT) + _execute_statements(_UPGRADE_DROP_PLAINTEXT_STATEMENTS) + _execute_statements(_UPGRADE_GRANT_STATEMENTS) + _enable_row_level_security() + op.execute(_UPGRADE_POLICY_STATEMENT) + + +def downgrade() -> None: + _execute_statements(_DOWNGRADE_ADD_PLAINTEXT_STATEMENTS) + op.execute(_DOWNGRADE_BACKFILL_STATEMENT) + _execute_statements(_DOWNGRADE_RESTORE_CONSTRAINT_STATEMENTS) diff --git a/apps/api/alembic/versions/20260316_0028_gmail_refresh_token_lifecycle.py b/apps/api/alembic/versions/20260316_0028_gmail_refresh_token_lifecycle.py new file mode 100644 index 0000000..7d30c37 --- /dev/null +++ b/apps/api/alembic/versions/20260316_0028_gmail_refresh_token_lifecycle.py @@ -0,0 +1,89 @@ +"""Allow Gmail protected credentials to store refresh-token lifecycle data.""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260316_0028" +down_revision = "20260316_0027" +branch_labels = None +depends_on = None + +GMAIL_PROTECTED_CREDENTIAL_KIND = "gmail_oauth_access_token_v1" +GMAIL_REFRESHABLE_PROTECTED_CREDENTIAL_KIND = "gmail_oauth_refresh_token_v2" + +_UPGRADE_STATEMENTS = ( + "ALTER TABLE gmail_account_credentials DROP CONSTRAINT gmail_account_credentials_blob_shape_check", + f""" + ALTER TABLE gmail_account_credentials + ADD CONSTRAINT gmail_account_credentials_blob_shape_check + CHECK ( + jsonb_typeof(credential_blob) = 'object' + AND credential_blob ? 'credential_kind' + AND credential_blob ? 'access_token' + AND jsonb_typeof(credential_blob -> 'access_token') = 'string' + AND length(credential_blob ->> 'access_token') > 0 + AND ( + ( + credential_blob ->> 'credential_kind' = '{GMAIL_PROTECTED_CREDENTIAL_KIND}' + ) + OR + ( + credential_blob ->> 'credential_kind' = '{GMAIL_REFRESHABLE_PROTECTED_CREDENTIAL_KIND}' + AND credential_blob ? 'refresh_token' + AND credential_blob ? 'client_id' + AND credential_blob ? 'client_secret' + AND credential_blob ? 'access_token_expires_at' + AND jsonb_typeof(credential_blob -> 'refresh_token') = 'string' + AND jsonb_typeof(credential_blob -> 'client_id') = 'string' + AND jsonb_typeof(credential_blob -> 'client_secret') = 'string' + AND jsonb_typeof(credential_blob -> 'access_token_expires_at') = 'string' + AND length(credential_blob ->> 'refresh_token') > 0 + AND length(credential_blob ->> 'client_id') > 0 + AND length(credential_blob ->> 'client_secret') > 0 + AND length(credential_blob ->> 'access_token_expires_at') > 0 + ) + ) + ) + """, + "GRANT UPDATE ON gmail_account_credentials TO alicebot_app", +) + +_DOWNGRADE_STATEMENTS = ( + """ + UPDATE gmail_account_credentials + SET credential_blob = jsonb_build_object( + 'credential_kind', 'gmail_oauth_access_token_v1', + 'access_token', credential_blob ->> 'access_token' + ) + WHERE credential_blob ->> 'credential_kind' = 'gmail_oauth_refresh_token_v2' + """, + "REVOKE UPDATE ON gmail_account_credentials FROM alicebot_app", + "ALTER TABLE gmail_account_credentials DROP CONSTRAINT gmail_account_credentials_blob_shape_check", + f""" + ALTER TABLE gmail_account_credentials + ADD CONSTRAINT gmail_account_credentials_blob_shape_check + CHECK ( + jsonb_typeof(credential_blob) = 'object' + AND credential_blob ? 'credential_kind' + AND credential_blob ? 'access_token' + AND credential_blob ->> 'credential_kind' = '{GMAIL_PROTECTED_CREDENTIAL_KIND}' + AND jsonb_typeof(credential_blob -> 'access_token') = 'string' + AND length(credential_blob ->> 'access_token') > 0 + ) + """, +) + + +def _execute_statements(statements: tuple[str, ...]) -> None: + for statement in statements: + op.execute(statement) + + +def upgrade() -> None: + _execute_statements(_UPGRADE_STATEMENTS) + + +def downgrade() -> None: + _execute_statements(_DOWNGRADE_STATEMENTS) diff --git a/apps/api/alembic/versions/20260316_0029_gmail_external_secret_manager.py b/apps/api/alembic/versions/20260316_0029_gmail_external_secret_manager.py new file mode 100644 index 0000000..ff436f0 --- /dev/null +++ b/apps/api/alembic/versions/20260316_0029_gmail_external_secret_manager.py @@ -0,0 +1,126 @@ +"""Add external secret-manager references for Gmail protected credentials.""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260316_0029" +down_revision = "20260316_0028" +branch_labels = None +depends_on = None + +GMAIL_PROTECTED_CREDENTIAL_KIND = "gmail_oauth_access_token_v1" +GMAIL_REFRESHABLE_PROTECTED_CREDENTIAL_KIND = "gmail_oauth_refresh_token_v2" +GMAIL_SECRET_MANAGER_KIND_FILE_V1 = "file_v1" +GMAIL_SECRET_MANAGER_KIND_LEGACY_DB_V0 = "legacy_db_v0" + +_CREDENTIAL_BLOB_SHAPE_CHECK = f""" + ( + jsonb_typeof(credential_blob) = 'object' + AND credential_blob ? 'credential_kind' + AND credential_blob ? 'access_token' + AND jsonb_typeof(credential_blob -> 'access_token') = 'string' + AND length(credential_blob ->> 'access_token') > 0 + AND ( + ( + credential_blob ->> 'credential_kind' = '{GMAIL_PROTECTED_CREDENTIAL_KIND}' + ) + OR + ( + credential_blob ->> 'credential_kind' = '{GMAIL_REFRESHABLE_PROTECTED_CREDENTIAL_KIND}' + AND credential_blob ? 'refresh_token' + AND credential_blob ? 'client_id' + AND credential_blob ? 'client_secret' + AND credential_blob ? 'access_token_expires_at' + AND jsonb_typeof(credential_blob -> 'refresh_token') = 'string' + AND jsonb_typeof(credential_blob -> 'client_id') = 'string' + AND jsonb_typeof(credential_blob -> 'client_secret') = 'string' + AND jsonb_typeof(credential_blob -> 'access_token_expires_at') = 'string' + AND length(credential_blob ->> 'refresh_token') > 0 + AND length(credential_blob ->> 'client_id') > 0 + AND length(credential_blob ->> 'client_secret') > 0 + AND length(credential_blob ->> 'access_token_expires_at') > 0 + ) + ) + ) +""" + +_UPGRADE_STATEMENTS = ( + "ALTER TABLE gmail_account_credentials DROP CONSTRAINT gmail_account_credentials_blob_shape_check", + "ALTER TABLE gmail_account_credentials ADD COLUMN credential_kind text", + "ALTER TABLE gmail_account_credentials ADD COLUMN secret_manager_kind text", + "ALTER TABLE gmail_account_credentials ADD COLUMN secret_ref text", + "ALTER TABLE gmail_account_credentials ALTER COLUMN credential_blob DROP NOT NULL", + """ + UPDATE gmail_account_credentials + SET credential_kind = credential_blob ->> 'credential_kind', + secret_manager_kind = 'legacy_db_v0' + """, + "ALTER TABLE gmail_account_credentials ALTER COLUMN credential_kind SET NOT NULL", + "ALTER TABLE gmail_account_credentials ALTER COLUMN secret_manager_kind SET NOT NULL", + f""" + ALTER TABLE gmail_account_credentials + ADD CONSTRAINT gmail_account_credentials_storage_shape_check + CHECK ( + credential_kind IN ( + '{GMAIL_PROTECTED_CREDENTIAL_KIND}', + '{GMAIL_REFRESHABLE_PROTECTED_CREDENTIAL_KIND}' + ) + AND ( + ( + secret_manager_kind = '{GMAIL_SECRET_MANAGER_KIND_LEGACY_DB_V0}' + AND secret_ref IS NULL + AND {_CREDENTIAL_BLOB_SHAPE_CHECK} + ) + OR + ( + secret_manager_kind = '{GMAIL_SECRET_MANAGER_KIND_FILE_V1}' + AND secret_ref IS NOT NULL + AND length(secret_ref) > 0 + AND credential_blob IS NULL + ) + ) + ) + """, +) + +_DOWNGRADE_STATEMENTS = ( + """ + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM gmail_account_credentials + WHERE secret_manager_kind = 'file_v1' + ) THEN + RAISE EXCEPTION + 'cannot downgrade gmail_account_credentials while external Gmail secrets are present'; + END IF; + END + $$; + """, + "ALTER TABLE gmail_account_credentials DROP CONSTRAINT gmail_account_credentials_storage_shape_check", + "ALTER TABLE gmail_account_credentials ALTER COLUMN credential_blob SET NOT NULL", + "ALTER TABLE gmail_account_credentials DROP COLUMN secret_ref", + "ALTER TABLE gmail_account_credentials DROP COLUMN secret_manager_kind", + "ALTER TABLE gmail_account_credentials DROP COLUMN credential_kind", + f""" + ALTER TABLE gmail_account_credentials + ADD CONSTRAINT gmail_account_credentials_blob_shape_check + CHECK {_CREDENTIAL_BLOB_SHAPE_CHECK} + """, +) + + +def _execute_statements(statements: tuple[str, ...]) -> None: + for statement in statements: + op.execute(statement) + + +def upgrade() -> None: + _execute_statements(_UPGRADE_STATEMENTS) + + +def downgrade() -> None: + _execute_statements(_DOWNGRADE_STATEMENTS) diff --git a/apps/api/alembic/versions/20260319_0030_calendar_accounts_and_credentials.py b/apps/api/alembic/versions/20260319_0030_calendar_accounts_and_credentials.py new file mode 100644 index 0000000..416732a --- /dev/null +++ b/apps/api/alembic/versions/20260319_0030_calendar_accounts_and_credentials.py @@ -0,0 +1,119 @@ +"""Add user-scoped Calendar account records with protected credentials.""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260319_0030" +down_revision = "20260316_0029" +branch_labels = None +depends_on = None + +CALENDAR_AUTH_KIND_OAUTH_ACCESS_TOKEN = "oauth_access_token" +CALENDAR_READONLY_SCOPE = "https://www.googleapis.com/auth/calendar.readonly" +CALENDAR_PROTECTED_CREDENTIAL_KIND = "calendar_oauth_access_token_v1" +CALENDAR_SECRET_MANAGER_KIND_FILE_V1 = "file_v1" + +_RLS_TABLES = ("calendar_accounts", "calendar_account_credentials") + +_UPGRADE_SCHEMA_STATEMENT = f""" + CREATE TABLE calendar_accounts ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider_account_id text NOT NULL, + email_address text NOT NULL, + display_name text, + scope text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (id, user_id), + CONSTRAINT calendar_accounts_provider_account_id_nonempty_check + CHECK (length(provider_account_id) > 0), + CONSTRAINT calendar_accounts_email_address_nonempty_check + CHECK (length(email_address) > 0), + CONSTRAINT calendar_accounts_display_name_nonempty_check + CHECK (display_name IS NULL OR length(display_name) > 0), + CONSTRAINT calendar_accounts_scope_readonly_check + CHECK (scope = '{CALENDAR_READONLY_SCOPE}') + ); + + CREATE INDEX calendar_accounts_user_created_idx + ON calendar_accounts (user_id, created_at, id); + + CREATE UNIQUE INDEX calendar_accounts_provider_account_idx + ON calendar_accounts (user_id, provider_account_id); + + CREATE TABLE calendar_account_credentials ( + calendar_account_id uuid PRIMARY KEY REFERENCES calendar_accounts(id) ON DELETE CASCADE, + user_id uuid NOT NULL, + auth_kind text NOT NULL, + credential_kind text NOT NULL, + secret_manager_kind text NOT NULL, + secret_ref text, + credential_blob jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + FOREIGN KEY (calendar_account_id, user_id) + REFERENCES calendar_accounts (id, user_id) + ON DELETE CASCADE, + CONSTRAINT calendar_account_credentials_auth_kind_check + CHECK (auth_kind = '{CALENDAR_AUTH_KIND_OAUTH_ACCESS_TOKEN}'), + CONSTRAINT calendar_account_credentials_storage_shape_check + CHECK ( + credential_kind = '{CALENDAR_PROTECTED_CREDENTIAL_KIND}' + AND secret_manager_kind = '{CALENDAR_SECRET_MANAGER_KIND_FILE_V1}' + AND secret_ref IS NOT NULL + AND length(secret_ref) > 0 + AND credential_blob IS NULL + ) + ); + + CREATE INDEX calendar_account_credentials_user_created_idx + ON calendar_account_credentials (user_id, created_at, calendar_account_id); + """ + +_UPGRADE_GRANT_STATEMENTS = ( + "GRANT SELECT, INSERT ON calendar_accounts TO alicebot_app", + "GRANT SELECT, INSERT ON calendar_account_credentials TO alicebot_app", +) + +_UPGRADE_POLICY_STATEMENTS = ( + """ + CREATE POLICY calendar_accounts_is_owner ON calendar_accounts + USING (user_id = app.current_user_id()) + WITH CHECK (user_id = app.current_user_id()); + """, + """ + CREATE POLICY calendar_account_credentials_is_owner ON calendar_account_credentials + USING (user_id = app.current_user_id()) + WITH CHECK (user_id = app.current_user_id()); + """, +) + +_DOWNGRADE_STATEMENTS = ( + "DROP TABLE IF EXISTS calendar_account_credentials", + "DROP TABLE IF EXISTS calendar_accounts", +) + + +def _execute_statements(statements: tuple[str, ...]) -> None: + for statement in statements: + op.execute(statement) + + +def _enable_row_level_security() -> None: + for table_name in _RLS_TABLES: + op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") + op.execute(f"ALTER TABLE {table_name} FORCE ROW LEVEL SECURITY") + + +def upgrade() -> None: + op.execute(_UPGRADE_SCHEMA_STATEMENT) + _execute_statements(_UPGRADE_GRANT_STATEMENTS) + _enable_row_level_security() + _execute_statements(_UPGRADE_POLICY_STATEMENTS) + + +def downgrade() -> None: + _execute_statements(_DOWNGRADE_STATEMENTS) diff --git a/apps/api/alembic/versions/20260323_0030_typed_memory_backbone.py b/apps/api/alembic/versions/20260323_0030_typed_memory_backbone.py new file mode 100644 index 0000000..26bb65b --- /dev/null +++ b/apps/api/alembic/versions/20260323_0030_typed_memory_backbone.py @@ -0,0 +1,102 @@ +"""Add typed memory metadata columns to the memory backbone.""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260323_0030" +down_revision = "20260319_0030" +branch_labels = None +depends_on = None + +MEMORY_TYPES = ( + "preference", + "identity_fact", + "relationship_fact", + "project_fact", + "decision", + "commitment", + "routine", + "constraint", + "working_style", +) + +MEMORY_CONFIRMATION_STATUSES = ( + "unconfirmed", + "confirmed", + "contested", +) + +_MEMORY_TYPES_SQL = ", ".join(f"'{value}'" for value in MEMORY_TYPES) +_MEMORY_CONFIRMATION_STATUSES_SQL = ", ".join( + f"'{value}'" for value in MEMORY_CONFIRMATION_STATUSES +) + +_UPGRADE_STATEMENTS = ( + """ + ALTER TABLE memories + ADD COLUMN memory_type text NOT NULL DEFAULT 'preference', + ADD COLUMN confidence double precision NULL, + ADD COLUMN salience double precision NULL, + ADD COLUMN confirmation_status text NOT NULL DEFAULT 'unconfirmed', + ADD COLUMN valid_from timestamptz NULL, + ADD COLUMN valid_to timestamptz NULL, + ADD COLUMN last_confirmed_at timestamptz NULL + """, + f""" + ALTER TABLE memories + ADD CONSTRAINT memories_memory_type_check + CHECK (memory_type IN ({_MEMORY_TYPES_SQL})) + """, + f""" + ALTER TABLE memories + ADD CONSTRAINT memories_confirmation_status_check + CHECK (confirmation_status IN ({_MEMORY_CONFIRMATION_STATUSES_SQL})) + """, + """ + ALTER TABLE memories + ADD CONSTRAINT memories_confidence_range_check + CHECK (confidence IS NULL OR (confidence >= 0.0 AND confidence <= 1.0)) + """, + """ + ALTER TABLE memories + ADD CONSTRAINT memories_salience_range_check + CHECK (salience IS NULL OR (salience >= 0.0 AND salience <= 1.0)) + """, + """ + ALTER TABLE memories + ADD CONSTRAINT memories_valid_range_check + CHECK (valid_from IS NULL OR valid_to IS NULL OR valid_to >= valid_from) + """, + """ + CREATE INDEX memories_user_type_updated_idx + ON memories (user_id, memory_type, updated_at) + """, +) + +_DOWNGRADE_STATEMENTS = ( + "DROP INDEX IF EXISTS memories_user_type_updated_idx", + "ALTER TABLE memories DROP CONSTRAINT IF EXISTS memories_valid_range_check", + "ALTER TABLE memories DROP CONSTRAINT IF EXISTS memories_salience_range_check", + "ALTER TABLE memories DROP CONSTRAINT IF EXISTS memories_confidence_range_check", + "ALTER TABLE memories DROP CONSTRAINT IF EXISTS memories_confirmation_status_check", + "ALTER TABLE memories DROP CONSTRAINT IF EXISTS memories_memory_type_check", + "ALTER TABLE memories DROP COLUMN IF EXISTS last_confirmed_at", + "ALTER TABLE memories DROP COLUMN IF EXISTS valid_to", + "ALTER TABLE memories DROP COLUMN IF EXISTS valid_from", + "ALTER TABLE memories DROP COLUMN IF EXISTS confirmation_status", + "ALTER TABLE memories DROP COLUMN IF EXISTS salience", + "ALTER TABLE memories DROP COLUMN IF EXISTS confidence", + "ALTER TABLE memories DROP COLUMN IF EXISTS memory_type", +) + + +def upgrade() -> None: + for statement in _UPGRADE_STATEMENTS: + op.execute(statement) + + +def downgrade() -> None: + for statement in _DOWNGRADE_STATEMENTS: + op.execute(statement) diff --git a/apps/api/alembic/versions/20260323_0031_open_loop_backbone.py b/apps/api/alembic/versions/20260323_0031_open_loop_backbone.py new file mode 100644 index 0000000..d0ab4b4 --- /dev/null +++ b/apps/api/alembic/versions/20260323_0031_open_loop_backbone.py @@ -0,0 +1,94 @@ +"""Add open-loop lifecycle table for unresolved commitments.""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260323_0031" +down_revision = "20260323_0030" +branch_labels = None +depends_on = None + +OPEN_LOOP_STATUSES = ( + "open", + "resolved", + "dismissed", +) + +_OPEN_LOOP_STATUSES_SQL = ", ".join(f"'{value}'" for value in OPEN_LOOP_STATUSES) + +_RLS_TABLES = ("open_loops",) + +_UPGRADE_SCHEMA_STATEMENT = f""" + CREATE TABLE open_loops ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + memory_id uuid NULL, + title text NOT NULL, + status text NOT NULL, + opened_at timestamptz NOT NULL DEFAULT now(), + due_at timestamptz NULL, + resolved_at timestamptz NULL, + resolution_note text NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (id, user_id), + CONSTRAINT open_loops_memory_fkey + FOREIGN KEY (memory_id, user_id) + REFERENCES memories(id, user_id) + ON DELETE SET NULL, + CONSTRAINT open_loops_status_check + CHECK (status IN ({_OPEN_LOOP_STATUSES_SQL})), + CONSTRAINT open_loops_title_length_check + CHECK (char_length(title) <= 280), + CONSTRAINT open_loops_resolution_note_length_check + CHECK (resolution_note IS NULL OR char_length(resolution_note) <= 2000), + CONSTRAINT open_loops_resolved_state_check + CHECK ( + (status = 'open' AND resolved_at IS NULL AND resolution_note IS NULL) + OR (status IN ('resolved', 'dismissed') AND resolved_at IS NOT NULL) + ) + ); + + CREATE INDEX open_loops_user_status_opened_idx + ON open_loops (user_id, status, opened_at DESC, created_at DESC, id DESC); + CREATE INDEX open_loops_user_memory_idx + ON open_loops (user_id, memory_id, created_at DESC, id DESC); + """ + +_UPGRADE_GRANT_STATEMENTS = ( + "GRANT SELECT, INSERT, UPDATE ON open_loops TO alicebot_app", +) + +_UPGRADE_POLICY_STATEMENT = """ + CREATE POLICY open_loops_is_owner ON open_loops + USING (user_id = app.current_user_id()) + WITH CHECK (user_id = app.current_user_id()); + """ + +_DOWNGRADE_STATEMENTS = ( + "DROP TABLE IF EXISTS open_loops", +) + + +def _execute_statements(statements: tuple[str, ...]) -> None: + for statement in statements: + op.execute(statement) + + +def _enable_row_level_security() -> None: + for table_name in _RLS_TABLES: + op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") + op.execute(f"ALTER TABLE {table_name} FORCE ROW LEVEL SECURITY") + + +def upgrade() -> None: + op.execute(_UPGRADE_SCHEMA_STATEMENT) + _execute_statements(_UPGRADE_GRANT_STATEMENTS) + _enable_row_level_security() + op.execute(_UPGRADE_POLICY_STATEMENT) + + +def downgrade() -> None: + _execute_statements(_DOWNGRADE_STATEMENTS) diff --git a/apps/api/alembic/versions/20260324_0032_thread_agent_profiles.py b/apps/api/alembic/versions/20260324_0032_thread_agent_profiles.py new file mode 100644 index 0000000..fb31424 --- /dev/null +++ b/apps/api/alembic/versions/20260324_0032_thread_agent_profiles.py @@ -0,0 +1,52 @@ +"""Bind deterministic Phase 3 agent profile identity to threads.""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260324_0032" +down_revision = "20260323_0031" +branch_labels = None +depends_on = None + +AGENT_PROFILE_IDS = ( + "assistant_default", + "coach_default", +) + +DEFAULT_AGENT_PROFILE_ID = "assistant_default" + +_AGENT_PROFILE_IDS_SQL = ", ".join(f"'{value}'" for value in AGENT_PROFILE_IDS) + +_UPGRADE_STATEMENTS = ( + f""" + ALTER TABLE threads + ADD COLUMN agent_profile_id text NOT NULL DEFAULT '{DEFAULT_AGENT_PROFILE_ID}' + """, + f""" + ALTER TABLE threads + ADD CONSTRAINT threads_agent_profile_id_check + CHECK (agent_profile_id IN ({_AGENT_PROFILE_IDS_SQL})) + """, + """ + CREATE INDEX threads_user_agent_profile_created_idx + ON threads (user_id, agent_profile_id, created_at DESC, id DESC) + """, +) + +_DOWNGRADE_STATEMENTS = ( + "DROP INDEX IF EXISTS threads_user_agent_profile_created_idx", + "ALTER TABLE threads DROP CONSTRAINT IF EXISTS threads_agent_profile_id_check", + "ALTER TABLE threads DROP COLUMN IF EXISTS agent_profile_id", +) + + +def upgrade() -> None: + for statement in _UPGRADE_STATEMENTS: + op.execute(statement) + + +def downgrade() -> None: + for statement in _DOWNGRADE_STATEMENTS: + op.execute(statement) diff --git a/apps/api/alembic/versions/20260324_0033_agent_profile_registry.py b/apps/api/alembic/versions/20260324_0033_agent_profile_registry.py new file mode 100644 index 0000000..69baf1d --- /dev/null +++ b/apps/api/alembic/versions/20260324_0033_agent_profile_registry.py @@ -0,0 +1,80 @@ +"""Create durable agent profile registry and bind thread profile FK.""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260324_0033" +down_revision = "20260324_0032" +branch_labels = None +depends_on = None + +AGENT_PROFILE_SEED_ROWS = ( + ( + "assistant_default", + "Assistant Default", + "General-purpose assistant profile for baseline conversations.", + ), + ( + "coach_default", + "Coach Default", + "Coaching-oriented profile focused on guidance and accountability.", + ), +) + +AGENT_PROFILE_IDS = tuple(profile_id for profile_id, *_ in AGENT_PROFILE_SEED_ROWS) +_AGENT_PROFILE_IDS_SQL = ", ".join(f"'{value}'" for value in AGENT_PROFILE_IDS) +_AGENT_PROFILE_SEED_VALUES_SQL = ", ".join( + f"('{profile_id}', '{name}', '{description}')" + for profile_id, name, description in AGENT_PROFILE_SEED_ROWS +) + +_UPGRADE_STATEMENTS = ( + """ + CREATE TABLE agent_profiles ( + id text PRIMARY KEY, + name text NOT NULL, + description text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT agent_profiles_id_nonempty_check + CHECK (char_length(id) > 0), + CONSTRAINT agent_profiles_name_nonempty_check + CHECK (char_length(name) > 0), + CONSTRAINT agent_profiles_description_nonempty_check + CHECK (char_length(description) > 0) + ) + """, + f""" + INSERT INTO agent_profiles (id, name, description) + VALUES {_AGENT_PROFILE_SEED_VALUES_SQL} + """, + "GRANT SELECT ON agent_profiles TO alicebot_app", + "ALTER TABLE threads DROP CONSTRAINT IF EXISTS threads_agent_profile_id_check", + """ + ALTER TABLE threads + ADD CONSTRAINT threads_agent_profile_id_fkey + FOREIGN KEY (agent_profile_id) + REFERENCES agent_profiles(id) + """, +) + +_DOWNGRADE_STATEMENTS = ( + "ALTER TABLE threads DROP CONSTRAINT IF EXISTS threads_agent_profile_id_fkey", + f""" + ALTER TABLE threads + ADD CONSTRAINT threads_agent_profile_id_check + CHECK (agent_profile_id IN ({_AGENT_PROFILE_IDS_SQL})) + """, + "DROP TABLE IF EXISTS agent_profiles", +) + + +def upgrade() -> None: + for statement in _UPGRADE_STATEMENTS: + op.execute(statement) + + +def downgrade() -> None: + for statement in _DOWNGRADE_STATEMENTS: + op.execute(statement) diff --git a/apps/api/alembic/versions/20260324_0034_memory_agent_profile_scope.py b/apps/api/alembic/versions/20260324_0034_memory_agent_profile_scope.py new file mode 100644 index 0000000..85c37a1 --- /dev/null +++ b/apps/api/alembic/versions/20260324_0034_memory_agent_profile_scope.py @@ -0,0 +1,87 @@ +"""Scope memory records to durable agent profiles.""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260324_0034" +down_revision = "20260324_0033" +branch_labels = None +depends_on = None + +DEFAULT_AGENT_PROFILE_ID = "assistant_default" + +_UPGRADE_STATEMENTS = ( + f""" + ALTER TABLE memories + ADD COLUMN agent_profile_id text NOT NULL DEFAULT '{DEFAULT_AGENT_PROFILE_ID}' + """, + """ + ALTER TABLE memories + ADD CONSTRAINT memories_agent_profile_id_fkey + FOREIGN KEY (agent_profile_id) + REFERENCES agent_profiles(id) + """, + "ALTER TABLE memories DROP CONSTRAINT IF EXISTS memories_user_id_memory_key_key", + """ + ALTER TABLE memories + ADD CONSTRAINT memories_user_profile_memory_key_key + UNIQUE (user_id, agent_profile_id, memory_key) + """, + """ + CREATE INDEX memories_user_profile_updated_created_id_idx + ON memories (user_id, agent_profile_id, updated_at, created_at, id) + """, +) + +_DOWNGRADE_STATEMENTS = ( + "DROP INDEX IF EXISTS memories_user_profile_updated_created_id_idx", + "ALTER TABLE memories DROP CONSTRAINT IF EXISTS memories_user_profile_memory_key_key", + """ + WITH ranked_memories AS ( + SELECT + id, + user_id, + memory_key, + agent_profile_id, + ROW_NUMBER() OVER ( + PARTITION BY user_id, memory_key + ORDER BY + CASE + WHEN agent_profile_id = 'assistant_default' THEN 0 + ELSE 1 + END ASC, + created_at ASC, + id ASC + ) AS duplicate_rank + FROM memories + ) + UPDATE memories + SET memory_key = ranked_memories.memory_key + || '#profile:' + || ranked_memories.agent_profile_id + || '#' + || ranked_memories.id::text + FROM ranked_memories + WHERE memories.id = ranked_memories.id + AND ranked_memories.duplicate_rank > 1 + """, + """ + ALTER TABLE memories + ADD CONSTRAINT memories_user_id_memory_key_key + UNIQUE (user_id, memory_key) + """, + "ALTER TABLE memories DROP CONSTRAINT IF EXISTS memories_agent_profile_id_fkey", + "ALTER TABLE memories DROP COLUMN IF EXISTS agent_profile_id", +) + + +def upgrade() -> None: + for statement in _UPGRADE_STATEMENTS: + op.execute(statement) + + +def downgrade() -> None: + for statement in _DOWNGRADE_STATEMENTS: + op.execute(statement) diff --git a/apps/api/alembic/versions/20260325_0035_policy_agent_profile_scope.py b/apps/api/alembic/versions/20260325_0035_policy_agent_profile_scope.py new file mode 100644 index 0000000..6345765 --- /dev/null +++ b/apps/api/alembic/versions/20260325_0035_policy_agent_profile_scope.py @@ -0,0 +1,49 @@ +"""Scope policy evaluation inputs to global + thread profile policies.""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260325_0035" +down_revision = "20260324_0034" +branch_labels = None +depends_on = None + +_UPGRADE_STATEMENTS = ( + """ + ALTER TABLE policies + ADD COLUMN agent_profile_id text NULL + """, + """ + ALTER TABLE policies + ADD CONSTRAINT policies_agent_profile_id_fkey + FOREIGN KEY (agent_profile_id) + REFERENCES agent_profiles(id) + """, + "DROP INDEX IF EXISTS policies_user_active_priority_created_idx", + """ + CREATE INDEX policies_user_active_profile_priority_created_idx + ON policies (user_id, active, agent_profile_id, priority, created_at, id) + """, +) + +_DOWNGRADE_STATEMENTS = ( + "DROP INDEX IF EXISTS policies_user_active_profile_priority_created_idx", + "ALTER TABLE policies DROP CONSTRAINT IF EXISTS policies_agent_profile_id_fkey", + "ALTER TABLE policies DROP COLUMN IF EXISTS agent_profile_id", + """ + CREATE INDEX policies_user_active_priority_created_idx + ON policies (user_id, active, priority, created_at, id) + """, +) + + +def upgrade() -> None: + for statement in _UPGRADE_STATEMENTS: + op.execute(statement) + + +def downgrade() -> None: + for statement in _DOWNGRADE_STATEMENTS: + op.execute(statement) diff --git a/apps/api/alembic/versions/20260325_0036_agent_profile_model_runtime.py b/apps/api/alembic/versions/20260325_0036_agent_profile_model_runtime.py new file mode 100644 index 0000000..72acef9 --- /dev/null +++ b/apps/api/alembic/versions/20260325_0036_agent_profile_model_runtime.py @@ -0,0 +1,72 @@ +"""Add profile-scoped runtime model provider/name configuration.""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260325_0036" +down_revision = "20260325_0035" +branch_labels = None +depends_on = None + +AGENT_PROFILE_RUNTIME_SEED_ROWS = ( + ("assistant_default", "openai_responses", "gpt-5-mini"), + ("coach_default", "openai_responses", "gpt-5"), +) + +_AGENT_PROFILE_RUNTIME_SEED_VALUES_SQL = ", ".join( + f"('{profile_id}', '{model_provider}', '{model_name}')" + for profile_id, model_provider, model_name in AGENT_PROFILE_RUNTIME_SEED_ROWS +) + +_UPGRADE_STATEMENTS = ( + """ + ALTER TABLE agent_profiles + ADD COLUMN model_provider text NULL + """, + """ + ALTER TABLE agent_profiles + ADD COLUMN model_name text NULL + """, + """ + ALTER TABLE agent_profiles + ADD CONSTRAINT agent_profiles_model_provider_check + CHECK (model_provider IS NULL OR model_provider = 'openai_responses') + """, + """ + ALTER TABLE agent_profiles + ADD CONSTRAINT agent_profiles_model_runtime_pairing_check + CHECK ( + (model_provider IS NULL AND model_name IS NULL) + OR + (model_provider IS NOT NULL AND char_length(model_name) > 0) + ) + """, + f""" + UPDATE agent_profiles + SET model_provider = seeded.model_provider, + model_name = seeded.model_name + FROM ( + VALUES {_AGENT_PROFILE_RUNTIME_SEED_VALUES_SQL} + ) AS seeded (id, model_provider, model_name) + WHERE agent_profiles.id = seeded.id + """, +) + +_DOWNGRADE_STATEMENTS = ( + "ALTER TABLE agent_profiles DROP CONSTRAINT IF EXISTS agent_profiles_model_runtime_pairing_check", + "ALTER TABLE agent_profiles DROP CONSTRAINT IF EXISTS agent_profiles_model_provider_check", + "ALTER TABLE agent_profiles DROP COLUMN IF EXISTS model_name", + "ALTER TABLE agent_profiles DROP COLUMN IF EXISTS model_provider", +) + + +def upgrade() -> None: + for statement in _UPGRADE_STATEMENTS: + op.execute(statement) + + +def downgrade() -> None: + for statement in _DOWNGRADE_STATEMENTS: + op.execute(statement) diff --git a/apps/api/alembic/versions/20260325_0037_execution_budget_agent_profile_scope.py b/apps/api/alembic/versions/20260325_0037_execution_budget_agent_profile_scope.py new file mode 100644 index 0000000..af1f051 --- /dev/null +++ b/apps/api/alembic/versions/20260325_0037_execution_budget_agent_profile_scope.py @@ -0,0 +1,79 @@ +"""Add optional execution budget profile scope with deterministic active-scope uniqueness.""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260325_0037" +down_revision = "20260325_0036" +branch_labels = None +depends_on = None + +_UPGRADE_STATEMENTS = ( + """ + ALTER TABLE execution_budgets + ADD COLUMN agent_profile_id text NULL; + """, + """ + ALTER TABLE execution_budgets + ADD CONSTRAINT execution_budgets_agent_profile_id_fkey + FOREIGN KEY (agent_profile_id) + REFERENCES agent_profiles(id); + """, + "DROP INDEX IF EXISTS execution_budgets_user_match_idx", + "DROP INDEX IF EXISTS execution_budgets_one_active_scope_idx", + """ + CREATE INDEX execution_budgets_user_profile_match_idx + ON execution_budgets (user_id, agent_profile_id, tool_key, domain_hint, created_at, id); + """, + """ + CREATE UNIQUE INDEX execution_budgets_one_active_scope_idx + ON execution_budgets ( + user_id, + COALESCE(agent_profile_id, ''), + COALESCE(tool_key, ''), + COALESCE(domain_hint, '') + ) + WHERE status = 'active'; + """, +) + +_DOWNGRADE_STATEMENTS = ( + "DROP INDEX IF EXISTS execution_budgets_one_active_scope_idx", + "DROP INDEX IF EXISTS execution_budgets_user_profile_match_idx", + """ + ALTER TABLE execution_budgets + DROP CONSTRAINT IF EXISTS execution_budgets_agent_profile_id_fkey; + """, + """ + ALTER TABLE execution_budgets + DROP COLUMN IF EXISTS agent_profile_id; + """, + """ + CREATE INDEX execution_budgets_user_match_idx + ON execution_budgets (user_id, tool_key, domain_hint, created_at, id); + """, + """ + CREATE UNIQUE INDEX execution_budgets_one_active_scope_idx + ON execution_budgets ( + user_id, + COALESCE(tool_key, ''), + COALESCE(domain_hint, '') + ) + WHERE status = 'active'; + """, +) + + +def _execute_statements(statements: tuple[str, ...]) -> None: + for statement in statements: + op.execute(statement) + + +def upgrade() -> None: + _execute_statements(_UPGRADE_STATEMENTS) + + +def downgrade() -> None: + _execute_statements(_DOWNGRADE_STATEMENTS) diff --git a/apps/api/alembic/versions/20260327_0038_task_runs.py b/apps/api/alembic/versions/20260327_0038_task_runs.py new file mode 100644 index 0000000..0e59081 --- /dev/null +++ b/apps/api/alembic/versions/20260327_0038_task_runs.py @@ -0,0 +1,99 @@ +"""Add durable task-run lifecycle records for deterministic worker ticking.""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260327_0038" +down_revision = "20260325_0037" +branch_labels = None +depends_on = None + +_RLS_TABLES = ("task_runs",) + +_UPGRADE_SCHEMA_STATEMENT = """ + CREATE TABLE task_runs ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + task_id uuid NOT NULL, + status text NOT NULL, + checkpoint jsonb NOT NULL DEFAULT '{}'::jsonb, + tick_count integer NOT NULL DEFAULT 0, + step_count integer NOT NULL DEFAULT 0, + max_ticks integer NOT NULL, + stop_reason text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (id, user_id), + CONSTRAINT task_runs_task_user_fk + FOREIGN KEY (task_id, user_id) + REFERENCES tasks(id, user_id) + ON DELETE CASCADE, + CONSTRAINT task_runs_status_check + CHECK (status IN ('queued', 'running', 'waiting', 'paused', 'completed', 'cancelled')), + CONSTRAINT task_runs_checkpoint_object_check + CHECK (jsonb_typeof(checkpoint) = 'object'), + CONSTRAINT task_runs_tick_count_check + CHECK (tick_count >= 0), + CONSTRAINT task_runs_step_count_check + CHECK (step_count >= 0), + CONSTRAINT task_runs_max_ticks_check + CHECK (max_ticks > 0), + CONSTRAINT task_runs_stop_reason_check + CHECK ( + stop_reason IS NULL + OR stop_reason IN ('wait_state', 'budget_exhausted', 'paused', 'completed', 'cancelled') + ), + CONSTRAINT task_runs_status_stop_reason_check + CHECK ( + (status IN ('queued', 'running') AND stop_reason IS NULL) + OR (status = 'waiting' AND stop_reason = 'wait_state') + OR (status = 'paused' AND stop_reason IN ('budget_exhausted', 'paused')) + OR (status = 'completed' AND stop_reason = 'completed') + OR (status = 'cancelled' AND stop_reason = 'cancelled') + ) + ); + + CREATE INDEX task_runs_user_task_created_idx + ON task_runs (user_id, task_id, created_at, id); + + CREATE INDEX task_runs_user_status_updated_idx + ON task_runs (user_id, status, updated_at, id); + """ + +_UPGRADE_GRANT_STATEMENTS = ( + "GRANT SELECT, INSERT, UPDATE ON task_runs TO alicebot_app", +) + +_UPGRADE_POLICY_STATEMENT = """ + CREATE POLICY task_runs_is_owner ON task_runs + USING (user_id = app.current_user_id()) + WITH CHECK (user_id = app.current_user_id()); + """ + +_DOWNGRADE_STATEMENTS = ( + "DROP TABLE IF EXISTS task_runs", +) + + +def _execute_statements(statements: tuple[str, ...]) -> None: + for statement in statements: + op.execute(statement) + + +def _enable_row_level_security() -> None: + for table_name in _RLS_TABLES: + op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") + op.execute(f"ALTER TABLE {table_name} FORCE ROW LEVEL SECURITY") + + +def upgrade() -> None: + op.execute(_UPGRADE_SCHEMA_STATEMENT) + _execute_statements(_UPGRADE_GRANT_STATEMENTS) + _enable_row_level_security() + op.execute(_UPGRADE_POLICY_STATEMENT) + + +def downgrade() -> None: + _execute_statements(_DOWNGRADE_STATEMENTS) diff --git a/apps/api/alembic/versions/20260327_0039_task_run_execution_linkage.py b/apps/api/alembic/versions/20260327_0039_task_run_execution_linkage.py new file mode 100644 index 0000000..b5a8b8d --- /dev/null +++ b/apps/api/alembic/versions/20260327_0039_task_run_execution_linkage.py @@ -0,0 +1,148 @@ +"""Link task runs, approvals, and tool executions with idempotent execution keys.""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260327_0039" +down_revision = "20260327_0038" +branch_labels = None +depends_on = None + +_UPGRADE_STATEMENTS = ( + """ + ALTER TABLE task_runs + DROP CONSTRAINT task_runs_status_check, + DROP CONSTRAINT task_runs_stop_reason_check, + DROP CONSTRAINT task_runs_status_stop_reason_check; + """, + """ + ALTER TABLE task_runs + ADD CONSTRAINT task_runs_status_check + CHECK (status IN ('queued', 'running', 'waiting', 'waiting_approval', 'paused', 'completed', 'cancelled')), + ADD CONSTRAINT task_runs_stop_reason_check + CHECK ( + stop_reason IS NULL + OR stop_reason IN ('wait_state', 'waiting_approval', 'budget_exhausted', 'paused', 'completed', 'cancelled') + ), + ADD CONSTRAINT task_runs_status_stop_reason_check + CHECK ( + (status IN ('queued', 'running') AND stop_reason IS NULL) + OR (status = 'waiting' AND stop_reason = 'wait_state') + OR (status = 'waiting_approval' AND stop_reason = 'waiting_approval') + OR (status = 'paused' AND stop_reason IN ('budget_exhausted', 'paused')) + OR (status = 'completed' AND stop_reason = 'completed') + OR (status = 'cancelled' AND stop_reason = 'cancelled') + ); + """, + """ + ALTER TABLE approvals + ADD COLUMN task_run_id uuid; + """, + """ + ALTER TABLE approvals + ADD CONSTRAINT approvals_task_run_user_fk + FOREIGN KEY (task_run_id, user_id) + REFERENCES task_runs(id, user_id) + ON DELETE SET NULL; + """, + """ + CREATE INDEX approvals_user_task_run_created_idx + ON approvals (user_id, task_run_id, created_at, id) + WHERE task_run_id IS NOT NULL; + """, + """ + ALTER TABLE tool_executions + ADD COLUMN task_run_id uuid, + ADD COLUMN idempotency_key text; + """, + """ + UPDATE tool_executions AS executions + SET task_run_id = approvals.task_run_id + FROM approvals + WHERE approvals.id = executions.approval_id + AND approvals.user_id = executions.user_id + AND approvals.task_run_id IS NOT NULL; + """, + """ + ALTER TABLE tool_executions + ADD CONSTRAINT tool_executions_task_run_user_fk + FOREIGN KEY (task_run_id, user_id) + REFERENCES task_runs(id, user_id) + ON DELETE SET NULL, + ADD CONSTRAINT tool_executions_idempotency_key_check + CHECK (idempotency_key IS NULL OR length(btrim(idempotency_key)) > 0); + """, + """ + CREATE INDEX tool_executions_user_task_run_executed_idx + ON tool_executions (user_id, task_run_id, executed_at, id) + WHERE task_run_id IS NOT NULL; + """, + """ + CREATE UNIQUE INDEX tool_executions_task_run_idempotency_idx + ON tool_executions (user_id, task_run_id, approval_id, idempotency_key) + WHERE task_run_id IS NOT NULL AND idempotency_key IS NOT NULL; + """, +) + +_DOWNGRADE_STATEMENTS = ( + """ + DROP INDEX IF EXISTS tool_executions_task_run_idempotency_idx; + """, + """ + DROP INDEX IF EXISTS tool_executions_user_task_run_executed_idx; + """, + """ + ALTER TABLE tool_executions + DROP CONSTRAINT IF EXISTS tool_executions_task_run_user_fk, + DROP CONSTRAINT IF EXISTS tool_executions_idempotency_key_check, + DROP COLUMN IF EXISTS task_run_id, + DROP COLUMN IF EXISTS idempotency_key; + """, + """ + DROP INDEX IF EXISTS approvals_user_task_run_created_idx; + """, + """ + ALTER TABLE approvals + DROP CONSTRAINT IF EXISTS approvals_task_run_user_fk, + DROP COLUMN IF EXISTS task_run_id; + """, + """ + ALTER TABLE task_runs + DROP CONSTRAINT task_runs_status_check, + DROP CONSTRAINT task_runs_stop_reason_check, + DROP CONSTRAINT task_runs_status_stop_reason_check; + """, + """ + ALTER TABLE task_runs + ADD CONSTRAINT task_runs_status_check + CHECK (status IN ('queued', 'running', 'waiting', 'paused', 'completed', 'cancelled')), + ADD CONSTRAINT task_runs_stop_reason_check + CHECK ( + stop_reason IS NULL + OR stop_reason IN ('wait_state', 'budget_exhausted', 'paused', 'completed', 'cancelled') + ), + ADD CONSTRAINT task_runs_status_stop_reason_check + CHECK ( + (status IN ('queued', 'running') AND stop_reason IS NULL) + OR (status = 'waiting' AND stop_reason = 'wait_state') + OR (status = 'paused' AND stop_reason IN ('budget_exhausted', 'paused')) + OR (status = 'completed' AND stop_reason = 'completed') + OR (status = 'cancelled' AND stop_reason = 'cancelled') + ); + """, +) + + +def _execute_statements(statements: tuple[str, ...]) -> None: + for statement in statements: + op.execute(statement) + + +def upgrade() -> None: + _execute_statements(_UPGRADE_STATEMENTS) + + +def downgrade() -> None: + _execute_statements(_DOWNGRADE_STATEMENTS) diff --git a/apps/api/alembic/versions/20260327_0040_task_run_retry_failure_controls.py b/apps/api/alembic/versions/20260327_0040_task_run_retry_failure_controls.py new file mode 100644 index 0000000..393c79b --- /dev/null +++ b/apps/api/alembic/versions/20260327_0040_task_run_retry_failure_controls.py @@ -0,0 +1,198 @@ +"""Add retry posture, failure class, and Sprint 13 task-run status controls.""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260327_0040" +down_revision = "20260327_0039" +branch_labels = None +depends_on = None + +_UPGRADE_STATEMENTS = ( + """ + ALTER TABLE task_runs + ADD COLUMN retry_count integer NOT NULL DEFAULT 0, + ADD COLUMN retry_cap integer NOT NULL DEFAULT 1, + ADD COLUMN retry_posture text NOT NULL DEFAULT 'none', + ADD COLUMN failure_class text, + ADD COLUMN last_transitioned_at timestamptz NOT NULL DEFAULT clock_timestamp(); + """, + """ + ALTER TABLE task_runs + DROP CONSTRAINT task_runs_status_check, + DROP CONSTRAINT task_runs_stop_reason_check, + DROP CONSTRAINT task_runs_status_stop_reason_check; + """, + """ + UPDATE task_runs + SET status = CASE + WHEN status = 'waiting' THEN 'waiting_user' + WHEN status = 'completed' THEN 'done' + WHEN status = 'paused' AND stop_reason = 'budget_exhausted' THEN 'failed' + ELSE status + END, + stop_reason = CASE + WHEN stop_reason = 'wait_state' THEN 'waiting_user' + WHEN stop_reason = 'completed' THEN 'done' + ELSE stop_reason + END, + failure_class = CASE + WHEN status = 'paused' AND stop_reason = 'budget_exhausted' THEN 'budget' + ELSE failure_class + END, + retry_posture = CASE + WHEN status = 'paused' AND stop_reason = 'budget_exhausted' THEN 'terminal' + WHEN status IN ('queued', 'running') THEN 'none' + WHEN status = 'waiting_approval' THEN 'awaiting_approval' + WHEN status = 'waiting' THEN 'awaiting_user' + WHEN status = 'paused' THEN 'paused' + WHEN status = 'completed' THEN 'terminal' + WHEN status = 'cancelled' THEN 'terminal' + WHEN status = 'failed' THEN 'terminal' + ELSE retry_posture + END, + retry_cap = GREATEST(max_ticks, 1), + last_transitioned_at = updated_at; + """, + """ + ALTER TABLE task_runs + ADD CONSTRAINT task_runs_status_check + CHECK ( + status IN ( + 'queued', + 'running', + 'waiting_approval', + 'waiting_user', + 'paused', + 'failed', + 'done', + 'cancelled' + ) + ), + ADD CONSTRAINT task_runs_stop_reason_check + CHECK ( + stop_reason IS NULL + OR stop_reason IN ( + 'waiting_approval', + 'waiting_user', + 'paused', + 'budget_exhausted', + 'approval_rejected', + 'policy_blocked', + 'retry_exhausted', + 'fatal_error', + 'done', + 'cancelled' + ) + ), + ADD CONSTRAINT task_runs_failure_class_check + CHECK ( + failure_class IS NULL + OR failure_class IN ('transient', 'policy', 'approval', 'budget', 'fatal') + ), + ADD CONSTRAINT task_runs_retry_posture_check + CHECK ( + retry_posture IN ( + 'none', + 'retryable', + 'exhausted', + 'terminal', + 'paused', + 'awaiting_approval', + 'awaiting_user' + ) + ), + ADD CONSTRAINT task_runs_retry_bounds_check + CHECK (retry_count >= 0 AND retry_count <= retry_cap), + ADD CONSTRAINT task_runs_status_stop_reason_check + CHECK ( + (status IN ('queued', 'running') AND stop_reason IS NULL AND failure_class IS NULL) + OR (status = 'waiting_approval' AND stop_reason = 'waiting_approval' AND failure_class IS NULL) + OR (status = 'waiting_user' AND stop_reason = 'waiting_user' AND failure_class IS NULL) + OR (status = 'paused' AND stop_reason = 'paused' AND failure_class IS NULL) + OR ( + status = 'failed' + AND stop_reason IN ('budget_exhausted', 'approval_rejected', 'policy_blocked', 'retry_exhausted', 'fatal_error') + AND failure_class IS NOT NULL + ) + OR (status = 'done' AND stop_reason = 'done' AND failure_class IS NULL) + OR (status = 'cancelled' AND stop_reason = 'cancelled' AND failure_class IS NULL) + ); + """, + """ + CREATE INDEX task_runs_user_status_transition_idx + ON task_runs (user_id, status, last_transitioned_at, id); + """, +) + +_DOWNGRADE_STATEMENTS = ( + """ + DROP INDEX IF EXISTS task_runs_user_status_transition_idx; + """, + """ + ALTER TABLE task_runs + DROP CONSTRAINT task_runs_status_check, + DROP CONSTRAINT task_runs_stop_reason_check, + DROP CONSTRAINT task_runs_status_stop_reason_check, + DROP CONSTRAINT IF EXISTS task_runs_failure_class_check, + DROP CONSTRAINT IF EXISTS task_runs_retry_posture_check, + DROP CONSTRAINT IF EXISTS task_runs_retry_bounds_check; + """, + """ + UPDATE task_runs + SET status = CASE + WHEN status = 'waiting_user' THEN 'waiting' + WHEN status = 'done' THEN 'completed' + WHEN status = 'failed' THEN 'paused' + ELSE status + END, + stop_reason = CASE + WHEN stop_reason = 'waiting_user' THEN 'wait_state' + WHEN stop_reason = 'done' THEN 'completed' + WHEN stop_reason IN ('approval_rejected', 'policy_blocked', 'retry_exhausted', 'fatal_error') THEN 'paused' + ELSE stop_reason + END; + """, + """ + ALTER TABLE task_runs + ADD CONSTRAINT task_runs_status_check + CHECK (status IN ('queued', 'running', 'waiting', 'waiting_approval', 'paused', 'completed', 'cancelled')), + ADD CONSTRAINT task_runs_stop_reason_check + CHECK ( + stop_reason IS NULL + OR stop_reason IN ('wait_state', 'waiting_approval', 'budget_exhausted', 'paused', 'completed', 'cancelled') + ), + ADD CONSTRAINT task_runs_status_stop_reason_check + CHECK ( + (status IN ('queued', 'running') AND stop_reason IS NULL) + OR (status = 'waiting' AND stop_reason = 'wait_state') + OR (status = 'waiting_approval' AND stop_reason = 'waiting_approval') + OR (status = 'paused' AND stop_reason IN ('budget_exhausted', 'paused')) + OR (status = 'completed' AND stop_reason = 'completed') + OR (status = 'cancelled' AND stop_reason = 'cancelled') + ); + """, + """ + ALTER TABLE task_runs + DROP COLUMN IF EXISTS retry_count, + DROP COLUMN IF EXISTS retry_cap, + DROP COLUMN IF EXISTS retry_posture, + DROP COLUMN IF EXISTS failure_class, + DROP COLUMN IF EXISTS last_transitioned_at; + """, +) + + +def _execute_statements(statements: tuple[str, ...]) -> None: + for statement in statements: + op.execute(statement) + + +def upgrade() -> None: + _execute_statements(_UPGRADE_STATEMENTS) + + +def downgrade() -> None: + _execute_statements(_DOWNGRADE_STATEMENTS) diff --git a/apps/api/alembic/versions/20260329_0041_phase5_continuity_backbone.py b/apps/api/alembic/versions/20260329_0041_phase5_continuity_backbone.py new file mode 100644 index 0000000..03c56a2 --- /dev/null +++ b/apps/api/alembic/versions/20260329_0041_phase5_continuity_backbone.py @@ -0,0 +1,178 @@ +"""Add Phase 5 continuity capture backbone and typed continuity objects.""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260329_0041" +down_revision = "20260327_0040" +branch_labels = None +depends_on = None + +CONTINUITY_OBJECT_TYPES = ( + "Note", + "MemoryFact", + "Decision", + "Commitment", + "WaitingFor", + "Blocker", + "NextAction", +) + +CAPTURE_EXPLICIT_SIGNALS = ( + "remember_this", + "task", + "decision", + "commitment", + "waiting_for", + "blocker", + "next_action", + "note", +) + +CAPTURE_ADMISSION_POSTURES = ( + "DERIVED", + "TRIAGE", +) + +_CONTINUITY_OBJECT_TYPES_SQL = ", ".join(f"'{value}'" for value in CONTINUITY_OBJECT_TYPES) +_CAPTURE_EXPLICIT_SIGNALS_SQL = ", ".join(f"'{value}'" for value in CAPTURE_EXPLICIT_SIGNALS) +_CAPTURE_ADMISSION_POSTURES_SQL = ", ".join(f"'{value}'" for value in CAPTURE_ADMISSION_POSTURES) + +_RLS_TABLES = ( + "continuity_capture_events", + "continuity_objects", +) + +_UPGRADE_BOOTSTRAP_STATEMENTS = ( + """ + CREATE OR REPLACE FUNCTION app.reject_continuity_capture_mutation() + RETURNS trigger + LANGUAGE plpgsql + AS $$ + BEGIN + RAISE EXCEPTION 'continuity capture events are append-only'; + END; + $$; + """, +) + +_UPGRADE_SCHEMA_STATEMENT = f""" + CREATE TABLE continuity_capture_events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + raw_content text NOT NULL, + explicit_signal text NULL, + admission_posture text NOT NULL, + admission_reason text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (id, user_id), + CONSTRAINT continuity_capture_events_raw_content_length_check + CHECK (char_length(raw_content) >= 1 AND char_length(raw_content) <= 4000), + CONSTRAINT continuity_capture_events_explicit_signal_check + CHECK (explicit_signal IS NULL OR explicit_signal IN ({_CAPTURE_EXPLICIT_SIGNALS_SQL})), + CONSTRAINT continuity_capture_events_admission_posture_check + CHECK (admission_posture IN ({_CAPTURE_ADMISSION_POSTURES_SQL})), + CONSTRAINT continuity_capture_events_admission_reason_length_check + CHECK (char_length(admission_reason) >= 1 AND char_length(admission_reason) <= 200) + ); + + CREATE TABLE continuity_objects ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + capture_event_id uuid NOT NULL, + object_type text NOT NULL, + status text NOT NULL, + title text NOT NULL, + body jsonb NOT NULL, + provenance jsonb NOT NULL, + confidence double precision NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (id, user_id), + UNIQUE (user_id, capture_event_id), + CONSTRAINT continuity_objects_capture_event_fkey + FOREIGN KEY (capture_event_id, user_id) + REFERENCES continuity_capture_events(id, user_id) + ON DELETE CASCADE, + CONSTRAINT continuity_objects_object_type_check + CHECK (object_type IN ({_CONTINUITY_OBJECT_TYPES_SQL})), + CONSTRAINT continuity_objects_status_check + CHECK (status IN ('active', 'completed', 'cancelled', 'superseded', 'stale')), + CONSTRAINT continuity_objects_title_length_check + CHECK (char_length(title) >= 1 AND char_length(title) <= 280), + CONSTRAINT continuity_objects_confidence_range_check + CHECK (confidence >= 0.0 AND confidence <= 1.0) + ); + + CREATE INDEX continuity_capture_events_user_created_idx + ON continuity_capture_events (user_id, created_at DESC, id DESC); + CREATE INDEX continuity_capture_events_user_posture_created_idx + ON continuity_capture_events (user_id, admission_posture, created_at DESC, id DESC); + CREATE INDEX continuity_objects_user_capture_idx + ON continuity_objects (user_id, capture_event_id, created_at DESC, id DESC); + CREATE INDEX continuity_objects_user_type_created_idx + ON continuity_objects (user_id, object_type, created_at DESC, id DESC); + """ + +_UPGRADE_TRIGGER_STATEMENT = """ + CREATE TRIGGER continuity_capture_events_append_only + BEFORE UPDATE OR DELETE ON continuity_capture_events + FOR EACH ROW + EXECUTE FUNCTION app.reject_continuity_capture_mutation(); + """ + +_UPGRADE_GRANT_STATEMENTS = ( + "GRANT SELECT, INSERT ON continuity_capture_events TO alicebot_app", + "GRANT SELECT, INSERT ON continuity_objects TO alicebot_app", +) + +_UPGRADE_POLICY_STATEMENT = """ + CREATE POLICY continuity_capture_events_read_own ON continuity_capture_events + FOR SELECT + USING (user_id = app.current_user_id()); + + CREATE POLICY continuity_capture_events_insert_own ON continuity_capture_events + FOR INSERT + WITH CHECK (user_id = app.current_user_id()); + + CREATE POLICY continuity_objects_read_own ON continuity_objects + FOR SELECT + USING (user_id = app.current_user_id()); + + CREATE POLICY continuity_objects_insert_own ON continuity_objects + FOR INSERT + WITH CHECK (user_id = app.current_user_id()); + """ + +_DOWNGRADE_STATEMENTS = ( + "DROP TRIGGER IF EXISTS continuity_capture_events_append_only ON continuity_capture_events", + "DROP TABLE IF EXISTS continuity_objects", + "DROP TABLE IF EXISTS continuity_capture_events", + "DROP FUNCTION IF EXISTS app.reject_continuity_capture_mutation()", +) + + +def _execute_statements(statements: tuple[str, ...]) -> None: + for statement in statements: + op.execute(statement) + + +def _enable_row_level_security() -> None: + for table_name in _RLS_TABLES: + op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") + op.execute(f"ALTER TABLE {table_name} FORCE ROW LEVEL SECURITY") + + +def upgrade() -> None: + _execute_statements(_UPGRADE_BOOTSTRAP_STATEMENTS) + op.execute(_UPGRADE_SCHEMA_STATEMENT) + op.execute(_UPGRADE_TRIGGER_STATEMENT) + _execute_statements(_UPGRADE_GRANT_STATEMENTS) + _enable_row_level_security() + op.execute(_UPGRADE_POLICY_STATEMENT) + + +def downgrade() -> None: + _execute_statements(_DOWNGRADE_STATEMENTS) diff --git a/apps/api/alembic/versions/20260330_0042_phase5_continuity_corrections.py b/apps/api/alembic/versions/20260330_0042_phase5_continuity_corrections.py new file mode 100644 index 0000000..65f6be6 --- /dev/null +++ b/apps/api/alembic/versions/20260330_0042_phase5_continuity_corrections.py @@ -0,0 +1,209 @@ +"""Add Phase 5 continuity correction ledger and lifecycle/freshness fields.""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260330_0042" +down_revision = "20260329_0041" +branch_labels = None +depends_on = None + +CONTINUITY_CORRECTION_ACTIONS = ( + "confirm", + "edit", + "delete", + "supersede", + "mark_stale", +) + +CONTINUITY_OBJECT_STATUSES = ( + "active", + "completed", + "cancelled", + "superseded", + "stale", + "deleted", +) + +_CORRECTION_ACTIONS_SQL = ", ".join(f"'{value}'" for value in CONTINUITY_CORRECTION_ACTIONS) +_OBJECT_STATUSES_SQL = ", ".join(f"'{value}'" for value in CONTINUITY_OBJECT_STATUSES) + +_RLS_TABLES = ( + "continuity_correction_events", +) + +_UPGRADE_BOOTSTRAP_STATEMENTS = ( + """ + CREATE OR REPLACE FUNCTION app.reject_continuity_correction_mutation() + RETURNS trigger + LANGUAGE plpgsql + AS $$ + BEGIN + RAISE EXCEPTION 'continuity correction events are append-only'; + END; + $$; + """, +) + +_UPGRADE_SCHEMA_STATEMENTS = ( + "ALTER TABLE continuity_objects ADD COLUMN last_confirmed_at timestamptz NULL", + "ALTER TABLE continuity_objects ADD COLUMN supersedes_object_id uuid NULL", + "ALTER TABLE continuity_objects ADD COLUMN superseded_by_object_id uuid NULL", + "ALTER TABLE continuity_objects DROP CONSTRAINT continuity_objects_status_check", + ( + "ALTER TABLE continuity_objects " + "ADD CONSTRAINT continuity_objects_status_check " + f"CHECK (status IN ({_OBJECT_STATUSES_SQL}))" + ), + ( + "ALTER TABLE continuity_objects " + "ADD CONSTRAINT continuity_objects_supersedes_fkey " + "FOREIGN KEY (supersedes_object_id, user_id) " + "REFERENCES continuity_objects(id, user_id) " + "ON DELETE SET NULL" + ), + ( + "ALTER TABLE continuity_objects " + "ADD CONSTRAINT continuity_objects_superseded_by_fkey " + "FOREIGN KEY (superseded_by_object_id, user_id) " + "REFERENCES continuity_objects(id, user_id) " + "ON DELETE SET NULL" + ), + ( + "ALTER TABLE continuity_objects " + "ADD CONSTRAINT continuity_objects_supersedes_not_self_check " + "CHECK (supersedes_object_id IS NULL OR supersedes_object_id <> id)" + ), + ( + "ALTER TABLE continuity_objects " + "ADD CONSTRAINT continuity_objects_superseded_by_not_self_check " + "CHECK (superseded_by_object_id IS NULL OR superseded_by_object_id <> id)" + ), + ( + "CREATE INDEX continuity_objects_user_status_updated_idx " + "ON continuity_objects (user_id, status, updated_at DESC, id DESC)" + ), + ( + "CREATE INDEX continuity_objects_user_supersedes_idx " + "ON continuity_objects (user_id, supersedes_object_id)" + ), + ( + "CREATE INDEX continuity_objects_user_superseded_by_idx " + "ON continuity_objects (user_id, superseded_by_object_id)" + ), + """ + CREATE TABLE continuity_correction_events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + continuity_object_id uuid NOT NULL, + action text NOT NULL, + reason text NULL, + before_snapshot jsonb NOT NULL, + after_snapshot jsonb NOT NULL, + payload jsonb NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (id, user_id), + CONSTRAINT continuity_correction_events_object_fkey + FOREIGN KEY (continuity_object_id, user_id) + REFERENCES continuity_objects(id, user_id) + ON DELETE CASCADE, + CONSTRAINT continuity_correction_events_action_check + CHECK (action IN (""" + _CORRECTION_ACTIONS_SQL + """)), + CONSTRAINT continuity_correction_events_reason_length_check + CHECK (reason IS NULL OR (char_length(reason) >= 1 AND char_length(reason) <= 500)) + ) + """, + ( + "CREATE INDEX continuity_correction_events_user_object_created_idx " + "ON continuity_correction_events (user_id, continuity_object_id, created_at DESC, id DESC)" + ), + ( + "CREATE INDEX continuity_correction_events_user_action_created_idx " + "ON continuity_correction_events (user_id, action, created_at DESC, id DESC)" + ), +) + +_UPGRADE_TRIGGER_STATEMENTS = ( + """ + CREATE TRIGGER continuity_correction_events_append_only + BEFORE UPDATE OR DELETE ON continuity_correction_events + FOR EACH ROW + EXECUTE FUNCTION app.reject_continuity_correction_mutation(); + """, +) + +_UPGRADE_GRANT_STATEMENTS = ( + "GRANT UPDATE ON continuity_objects TO alicebot_app", + "GRANT SELECT, INSERT ON continuity_correction_events TO alicebot_app", +) + +_UPGRADE_POLICY_STATEMENTS = ( + """ + CREATE POLICY continuity_objects_update_own ON continuity_objects + FOR UPDATE + USING (user_id = app.current_user_id()) + WITH CHECK (user_id = app.current_user_id()); + """, + """ + CREATE POLICY continuity_correction_events_read_own ON continuity_correction_events + FOR SELECT + USING (user_id = app.current_user_id()); + """, + """ + CREATE POLICY continuity_correction_events_insert_own ON continuity_correction_events + FOR INSERT + WITH CHECK (user_id = app.current_user_id()); + """, +) + +_DOWNGRADE_STATEMENTS = ( + "DROP POLICY IF EXISTS continuity_correction_events_insert_own ON continuity_correction_events", + "DROP POLICY IF EXISTS continuity_correction_events_read_own ON continuity_correction_events", + "DROP POLICY IF EXISTS continuity_objects_update_own ON continuity_objects", + "REVOKE UPDATE ON continuity_objects FROM alicebot_app", + "DROP TRIGGER IF EXISTS continuity_correction_events_append_only ON continuity_correction_events", + "DROP TABLE IF EXISTS continuity_correction_events", + "DROP FUNCTION IF EXISTS app.reject_continuity_correction_mutation()", + "DROP INDEX IF EXISTS continuity_objects_user_superseded_by_idx", + "DROP INDEX IF EXISTS continuity_objects_user_supersedes_idx", + "DROP INDEX IF EXISTS continuity_objects_user_status_updated_idx", + "ALTER TABLE continuity_objects DROP CONSTRAINT IF EXISTS continuity_objects_superseded_by_not_self_check", + "ALTER TABLE continuity_objects DROP CONSTRAINT IF EXISTS continuity_objects_supersedes_not_self_check", + "ALTER TABLE continuity_objects DROP CONSTRAINT IF EXISTS continuity_objects_superseded_by_fkey", + "ALTER TABLE continuity_objects DROP CONSTRAINT IF EXISTS continuity_objects_supersedes_fkey", + "ALTER TABLE continuity_objects DROP COLUMN IF EXISTS superseded_by_object_id", + "ALTER TABLE continuity_objects DROP COLUMN IF EXISTS supersedes_object_id", + "ALTER TABLE continuity_objects DROP COLUMN IF EXISTS last_confirmed_at", + "ALTER TABLE continuity_objects DROP CONSTRAINT continuity_objects_status_check", + ( + "ALTER TABLE continuity_objects " + "ADD CONSTRAINT continuity_objects_status_check " + "CHECK (status IN ('active', 'completed', 'cancelled', 'superseded', 'stale'))" + ), +) + + +def _execute_statements(statements: tuple[str, ...]) -> None: + for statement in statements: + op.execute(statement) + + +def _enable_row_level_security() -> None: + for table_name in _RLS_TABLES: + op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") + op.execute(f"ALTER TABLE {table_name} FORCE ROW LEVEL SECURITY") + + +def upgrade() -> None: + _execute_statements(_UPGRADE_BOOTSTRAP_STATEMENTS) + _execute_statements(_UPGRADE_SCHEMA_STATEMENTS) + _execute_statements(_UPGRADE_TRIGGER_STATEMENTS) + _execute_statements(_UPGRADE_GRANT_STATEMENTS) + _enable_row_level_security() + _execute_statements(_UPGRADE_POLICY_STATEMENTS) + + +def downgrade() -> None: + _execute_statements(_DOWNGRADE_STATEMENTS) diff --git a/apps/api/alembic/versions/20260408_0043_phase10_identity_workspace_bootstrap.py b/apps/api/alembic/versions/20260408_0043_phase10_identity_workspace_bootstrap.py new file mode 100644 index 0000000..5a4fb03 --- /dev/null +++ b/apps/api/alembic/versions/20260408_0043_phase10_identity_workspace_bootstrap.py @@ -0,0 +1,250 @@ +"""Add Phase 10 Sprint 1 hosted identity/workspace bootstrap control-plane tables.""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260408_0043" +down_revision = "20260330_0042" +branch_labels = None +depends_on = None + + +_UPGRADE_STATEMENTS = ( + """ + CREATE TABLE beta_cohorts ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + cohort_key text NOT NULL UNIQUE, + description text NULL, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT beta_cohorts_key_length_check + CHECK (char_length(cohort_key) >= 1 AND char_length(cohort_key) <= 120) + ) + """, + """ + CREATE TABLE feature_flags ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + flag_key text NOT NULL, + cohort_key text NULL REFERENCES beta_cohorts(cohort_key) ON DELETE SET NULL, + enabled boolean NOT NULL DEFAULT false, + description text NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT feature_flags_key_length_check + CHECK (char_length(flag_key) >= 1 AND char_length(flag_key) <= 120) + ) + """, + ( + "CREATE UNIQUE INDEX feature_flags_global_key_uidx " + "ON feature_flags (flag_key) WHERE cohort_key IS NULL" + ), + ( + "CREATE UNIQUE INDEX feature_flags_scoped_key_uidx " + "ON feature_flags (flag_key, cohort_key) WHERE cohort_key IS NOT NULL" + ), + ( + "CREATE INDEX feature_flags_enabled_idx " + "ON feature_flags (enabled, flag_key, cohort_key)" + ), + """ + CREATE TABLE user_accounts ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + email text NOT NULL UNIQUE, + display_name text NULL, + beta_cohort_key text NULL REFERENCES beta_cohorts(cohort_key) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT user_accounts_email_length_check + CHECK (char_length(email) >= 3 AND char_length(email) <= 320) + ) + """, + """ + CREATE TABLE workspaces ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + owner_user_account_id uuid NOT NULL REFERENCES user_accounts(id) ON DELETE CASCADE, + slug text NOT NULL UNIQUE, + name text NOT NULL, + bootstrap_status text NOT NULL DEFAULT 'pending', + bootstrapped_at timestamptz NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT workspaces_slug_length_check + CHECK (char_length(slug) >= 3 AND char_length(slug) <= 120), + CONSTRAINT workspaces_name_length_check + CHECK (char_length(name) >= 1 AND char_length(name) <= 160), + CONSTRAINT workspaces_bootstrap_status_check + CHECK (bootstrap_status IN ('pending', 'ready')) + ) + """, + ( + "CREATE INDEX workspaces_owner_created_idx " + "ON workspaces (owner_user_account_id, created_at DESC, id DESC)" + ), + """ + CREATE TABLE workspace_members ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + user_account_id uuid NOT NULL REFERENCES user_accounts(id) ON DELETE CASCADE, + role text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (workspace_id, user_account_id), + CONSTRAINT workspace_members_role_check + CHECK (role IN ('owner', 'member')) + ) + """, + ( + "CREATE UNIQUE INDEX workspace_members_single_owner_uidx " + "ON workspace_members (workspace_id) WHERE role = 'owner'" + ), + ( + "CREATE INDEX workspace_members_user_created_idx " + "ON workspace_members (user_account_id, created_at DESC, id DESC)" + ), + """ + CREATE TABLE magic_link_challenges ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + email text NOT NULL, + challenge_token_hash text NOT NULL UNIQUE, + status text NOT NULL, + expires_at timestamptz NOT NULL, + consumed_at timestamptz NULL, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT magic_link_challenges_status_check + CHECK (status IN ('pending', 'consumed', 'expired')) + ) + """, + ( + "CREATE INDEX magic_link_challenges_email_status_idx " + "ON magic_link_challenges (email, status, expires_at DESC, created_at DESC)" + ), + """ + CREATE TABLE devices ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_account_id uuid NOT NULL REFERENCES user_accounts(id) ON DELETE CASCADE, + workspace_id uuid NULL REFERENCES workspaces(id) ON DELETE SET NULL, + device_key text NOT NULL, + device_label text NOT NULL, + status text NOT NULL DEFAULT 'active', + last_seen_at timestamptz NULL, + revoked_at timestamptz NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (user_account_id, device_key), + CONSTRAINT devices_status_check CHECK (status IN ('active', 'revoked')), + CONSTRAINT devices_label_length_check + CHECK (char_length(device_label) >= 1 AND char_length(device_label) <= 120) + ) + """, + ( + "CREATE INDEX devices_user_workspace_status_idx " + "ON devices (user_account_id, workspace_id, status, created_at DESC, id DESC)" + ), + """ + CREATE TABLE device_link_challenges ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_account_id uuid NOT NULL REFERENCES user_accounts(id) ON DELETE CASCADE, + workspace_id uuid NULL REFERENCES workspaces(id) ON DELETE SET NULL, + device_key text NOT NULL, + device_label text NOT NULL, + challenge_token_hash text NOT NULL UNIQUE, + status text NOT NULL, + expires_at timestamptz NOT NULL, + confirmed_at timestamptz NULL, + device_id uuid NULL REFERENCES devices(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT device_link_challenges_status_check + CHECK (status IN ('pending', 'confirmed', 'expired')), + CONSTRAINT device_link_challenges_label_length_check + CHECK (char_length(device_label) >= 1 AND char_length(device_label) <= 120) + ) + """, + ( + "CREATE INDEX device_link_challenges_user_device_status_idx " + "ON device_link_challenges (user_account_id, device_key, status, expires_at DESC, created_at DESC)" + ), + """ + CREATE TABLE auth_sessions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_account_id uuid NOT NULL REFERENCES user_accounts(id) ON DELETE CASCADE, + workspace_id uuid NULL REFERENCES workspaces(id) ON DELETE SET NULL, + device_id uuid NULL REFERENCES devices(id) ON DELETE SET NULL, + session_token_hash text NOT NULL UNIQUE, + status text NOT NULL DEFAULT 'active', + expires_at timestamptz NOT NULL, + revoked_at timestamptz NULL, + last_seen_at timestamptz NULL, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT auth_sessions_status_check + CHECK (status IN ('active', 'revoked', 'expired')) + ) + """, + ( + "CREATE INDEX auth_sessions_user_status_idx " + "ON auth_sessions (user_account_id, status, expires_at DESC, created_at DESC)" + ), + """ + CREATE TABLE user_preferences ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_account_id uuid NOT NULL UNIQUE REFERENCES user_accounts(id) ON DELETE CASCADE, + timezone text NOT NULL DEFAULT 'UTC', + brief_preferences jsonb NOT NULL DEFAULT '{}'::jsonb, + quiet_hours jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT user_preferences_timezone_length_check + CHECK (char_length(timezone) >= 1 AND char_length(timezone) <= 120) + ) + """, + "INSERT INTO beta_cohorts (cohort_key, description) VALUES ('p10-beta', 'Phase 10 hosted beta cohort') ON CONFLICT (cohort_key) DO NOTHING", + """ + INSERT INTO feature_flags (flag_key, cohort_key, enabled, description) + VALUES + ('hosted_onboarding', NULL, true, 'Hosted onboarding surface foundation'), + ('hosted_settings', NULL, true, 'Hosted settings surface foundation'), + ('telegram_linking', 'p10-beta', false, 'Reserved for P10-S2 Telegram linkage') + ON CONFLICT DO NOTHING + """, +) + +_UPGRADE_GRANT_STATEMENTS = ( + "GRANT SELECT, INSERT, UPDATE, DELETE ON beta_cohorts TO alicebot_app", + "GRANT SELECT, INSERT, UPDATE, DELETE ON feature_flags TO alicebot_app", + "GRANT SELECT, INSERT, UPDATE, DELETE ON user_accounts TO alicebot_app", + "GRANT SELECT, INSERT, UPDATE, DELETE ON workspaces TO alicebot_app", + "GRANT SELECT, INSERT, UPDATE, DELETE ON workspace_members TO alicebot_app", + "GRANT SELECT, INSERT, UPDATE, DELETE ON magic_link_challenges TO alicebot_app", + "GRANT SELECT, INSERT, UPDATE, DELETE ON devices TO alicebot_app", + "GRANT SELECT, INSERT, UPDATE, DELETE ON device_link_challenges TO alicebot_app", + "GRANT SELECT, INSERT, UPDATE, DELETE ON auth_sessions TO alicebot_app", + "GRANT SELECT, INSERT, UPDATE, DELETE ON user_preferences TO alicebot_app", +) + +_DOWNGRADE_STATEMENTS = ( + "DROP TABLE IF EXISTS user_preferences", + "DROP TABLE IF EXISTS auth_sessions", + "DROP TABLE IF EXISTS device_link_challenges", + "DROP TABLE IF EXISTS devices", + "DROP TABLE IF EXISTS magic_link_challenges", + "DROP INDEX IF EXISTS workspace_members_single_owner_uidx", + "DROP TABLE IF EXISTS workspace_members", + "DROP TABLE IF EXISTS workspaces", + "DROP TABLE IF EXISTS user_accounts", + "DROP INDEX IF EXISTS feature_flags_scoped_key_uidx", + "DROP INDEX IF EXISTS feature_flags_global_key_uidx", + "DROP TABLE IF EXISTS feature_flags", + "DROP TABLE IF EXISTS beta_cohorts", +) + + +def _execute_statements(statements: tuple[str, ...]) -> None: + for statement in statements: + op.execute(statement) + + +def upgrade() -> None: + _execute_statements(_UPGRADE_STATEMENTS) + _execute_statements(_UPGRADE_GRANT_STATEMENTS) + + +def downgrade() -> None: + _execute_statements(_DOWNGRADE_STATEMENTS) diff --git a/apps/api/alembic/versions/20260408_0044_phase10_telegram_transport.py b/apps/api/alembic/versions/20260408_0044_phase10_telegram_transport.py new file mode 100644 index 0000000..25afbc8 --- /dev/null +++ b/apps/api/alembic/versions/20260408_0044_phase10_telegram_transport.py @@ -0,0 +1,217 @@ +"""Add Phase 10 Sprint 2 Telegram transport tables and routing receipts.""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260408_0044" +down_revision = "20260408_0043" +branch_labels = None +depends_on = None + + +_UPGRADE_STATEMENTS = ( + """ + CREATE TABLE channel_identities ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_account_id uuid NOT NULL REFERENCES user_accounts(id) ON DELETE CASCADE, + workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + channel_type text NOT NULL, + external_user_id text NOT NULL, + external_chat_id text NOT NULL, + external_username text NULL, + status text NOT NULL DEFAULT 'linked', + linked_at timestamptz NOT NULL DEFAULT now(), + unlinked_at timestamptz NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT channel_identities_channel_type_check + CHECK (channel_type IN ('telegram')), + CONSTRAINT channel_identities_status_check + CHECK (status IN ('linked', 'unlinked')), + CONSTRAINT channel_identities_external_user_id_length_check + CHECK (char_length(external_user_id) >= 1 AND char_length(external_user_id) <= 160), + CONSTRAINT channel_identities_external_chat_id_length_check + CHECK (char_length(external_chat_id) >= 1 AND char_length(external_chat_id) <= 160) + ) + """, + ( + "CREATE UNIQUE INDEX channel_identities_linked_external_chat_uidx " + "ON channel_identities (channel_type, external_chat_id) " + "WHERE status = 'linked'" + ), + ( + "CREATE INDEX channel_identities_user_workspace_idx " + "ON channel_identities (user_account_id, workspace_id, created_at DESC, id DESC)" + ), + """ + CREATE TABLE channel_link_challenges ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_account_id uuid NOT NULL REFERENCES user_accounts(id) ON DELETE CASCADE, + workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + channel_type text NOT NULL, + challenge_token_hash text NOT NULL UNIQUE, + link_code text NOT NULL UNIQUE, + status text NOT NULL, + expires_at timestamptz NOT NULL, + confirmed_at timestamptz NULL, + channel_identity_id uuid NULL REFERENCES channel_identities(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT channel_link_challenges_channel_type_check + CHECK (channel_type IN ('telegram')), + CONSTRAINT channel_link_challenges_status_check + CHECK (status IN ('pending', 'confirmed', 'expired', 'cancelled')), + CONSTRAINT channel_link_challenges_link_code_length_check + CHECK (char_length(link_code) >= 6 AND char_length(link_code) <= 32) + ) + """, + ( + "CREATE INDEX channel_link_challenges_user_workspace_status_idx " + "ON channel_link_challenges (user_account_id, workspace_id, channel_type, status, created_at DESC, id DESC)" + ), + """ + CREATE TABLE channel_threads ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + channel_type text NOT NULL, + external_thread_key text NOT NULL, + channel_identity_id uuid NULL REFERENCES channel_identities(id) ON DELETE SET NULL, + last_message_at timestamptz NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (workspace_id, channel_type, external_thread_key), + CONSTRAINT channel_threads_channel_type_check + CHECK (channel_type IN ('telegram')), + CONSTRAINT channel_threads_external_thread_key_length_check + CHECK (char_length(external_thread_key) >= 1 AND char_length(external_thread_key) <= 240) + ) + """, + ( + "CREATE INDEX channel_threads_workspace_last_message_idx " + "ON channel_threads (workspace_id, channel_type, last_message_at DESC, created_at DESC, id DESC)" + ), + """ + CREATE TABLE channel_messages ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id uuid NULL REFERENCES workspaces(id) ON DELETE SET NULL, + channel_thread_id uuid NULL REFERENCES channel_threads(id) ON DELETE SET NULL, + channel_identity_id uuid NULL REFERENCES channel_identities(id) ON DELETE SET NULL, + channel_type text NOT NULL, + direction text NOT NULL, + provider_update_id text NULL, + provider_message_id text NULL, + external_chat_id text NULL, + external_user_id text NULL, + message_text text NULL, + normalized_payload jsonb NOT NULL DEFAULT '{}'::jsonb, + route_status text NOT NULL, + idempotency_key text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + received_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (channel_type, direction, idempotency_key), + CONSTRAINT channel_messages_channel_type_check + CHECK (channel_type IN ('telegram')), + CONSTRAINT channel_messages_direction_check + CHECK (direction IN ('inbound', 'outbound')), + CONSTRAINT channel_messages_route_status_check + CHECK (route_status IN ('resolved', 'unresolved')), + CONSTRAINT channel_messages_idempotency_key_length_check + CHECK (char_length(idempotency_key) >= 16 AND char_length(idempotency_key) <= 160) + ) + """, + ( + "CREATE UNIQUE INDEX channel_messages_inbound_update_uidx " + "ON channel_messages (channel_type, provider_update_id) " + "WHERE direction = 'inbound' AND provider_update_id IS NOT NULL" + ), + ( + "CREATE INDEX channel_messages_workspace_created_idx " + "ON channel_messages (workspace_id, channel_type, created_at DESC, id DESC)" + ), + ( + "CREATE INDEX channel_messages_thread_created_idx " + "ON channel_messages (channel_thread_id, created_at DESC, id DESC)" + ), + """ + CREATE TABLE chat_intents ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + channel_message_id uuid NOT NULL REFERENCES channel_messages(id) ON DELETE CASCADE, + channel_thread_id uuid NULL REFERENCES channel_threads(id) ON DELETE SET NULL, + intent_kind text NOT NULL, + status text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (channel_message_id, intent_kind), + CONSTRAINT chat_intents_intent_kind_check + CHECK (intent_kind IN ('inbound_message')), + CONSTRAINT chat_intents_status_check + CHECK (status IN ('pending', 'recorded')) + ) + """, + ( + "CREATE INDEX chat_intents_workspace_created_idx " + "ON chat_intents (workspace_id, created_at DESC, id DESC)" + ), + """ + CREATE TABLE channel_delivery_receipts ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + channel_message_id uuid NOT NULL UNIQUE REFERENCES channel_messages(id) ON DELETE CASCADE, + channel_type text NOT NULL, + status text NOT NULL, + provider_receipt_id text NULL, + failure_code text NULL, + failure_detail text NULL, + recorded_at timestamptz NOT NULL DEFAULT now(), + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT channel_delivery_receipts_channel_type_check + CHECK (channel_type IN ('telegram')), + CONSTRAINT channel_delivery_receipts_status_check + CHECK (status IN ('delivered', 'failed', 'simulated')) + ) + """, + ( + "CREATE INDEX channel_delivery_receipts_workspace_recorded_idx " + "ON channel_delivery_receipts (workspace_id, channel_type, recorded_at DESC, id DESC)" + ), +) + +_UPGRADE_GRANT_STATEMENTS = ( + "GRANT SELECT, INSERT, UPDATE, DELETE ON channel_identities TO alicebot_app", + "GRANT SELECT, INSERT, UPDATE, DELETE ON channel_link_challenges TO alicebot_app", + "GRANT SELECT, INSERT, UPDATE, DELETE ON channel_threads TO alicebot_app", + "GRANT SELECT, INSERT, UPDATE, DELETE ON channel_messages TO alicebot_app", + "GRANT SELECT, INSERT, UPDATE, DELETE ON chat_intents TO alicebot_app", + "GRANT SELECT, INSERT, UPDATE, DELETE ON channel_delivery_receipts TO alicebot_app", +) + +_DOWNGRADE_STATEMENTS = ( + "DROP TABLE IF EXISTS channel_delivery_receipts", + "DROP TABLE IF EXISTS chat_intents", + "DROP INDEX IF EXISTS channel_messages_thread_created_idx", + "DROP INDEX IF EXISTS channel_messages_workspace_created_idx", + "DROP INDEX IF EXISTS channel_messages_inbound_update_uidx", + "DROP TABLE IF EXISTS channel_messages", + "DROP INDEX IF EXISTS channel_threads_workspace_last_message_idx", + "DROP TABLE IF EXISTS channel_threads", + "DROP INDEX IF EXISTS channel_link_challenges_user_workspace_status_idx", + "DROP TABLE IF EXISTS channel_link_challenges", + "DROP INDEX IF EXISTS channel_identities_user_workspace_idx", + "DROP INDEX IF EXISTS channel_identities_linked_external_chat_uidx", + "DROP TABLE IF EXISTS channel_identities", +) + + +def _execute_statements(statements: tuple[str, ...]) -> None: + for statement in statements: + op.execute(statement) + + +def upgrade() -> None: + _execute_statements(_UPGRADE_STATEMENTS) + _execute_statements(_UPGRADE_GRANT_STATEMENTS) + + +def downgrade() -> None: + _execute_statements(_DOWNGRADE_STATEMENTS) diff --git a/apps/api/alembic/versions/20260408_0045_phase10_chat_continuity_approvals.py b/apps/api/alembic/versions/20260408_0045_phase10_chat_continuity_approvals.py new file mode 100644 index 0000000..2493b5f --- /dev/null +++ b/apps/api/alembic/versions/20260408_0045_phase10_chat_continuity_approvals.py @@ -0,0 +1,149 @@ +"""Add Phase 10 Sprint 3 Telegram continuity routing, approvals, and review persistence.""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260408_0045" +down_revision = "20260408_0044" +branch_labels = None +depends_on = None + + +_UPGRADE_STATEMENTS = ( + """ + ALTER TABLE chat_intents + ADD COLUMN intent_payload jsonb NOT NULL DEFAULT '{}'::jsonb, + ADD COLUMN result_payload jsonb NOT NULL DEFAULT '{}'::jsonb, + ADD COLUMN handled_at timestamptz NULL + """, + "ALTER TABLE chat_intents DROP CONSTRAINT IF EXISTS chat_intents_intent_kind_check", + """ + ALTER TABLE chat_intents + ADD CONSTRAINT chat_intents_intent_kind_check + CHECK ( + intent_kind IN ( + 'inbound_message', + 'capture', + 'recall', + 'resume', + 'correction', + 'open_loops', + 'open_loop_review', + 'approvals', + 'approval_approve', + 'approval_reject', + 'unknown' + ) + ) + """, + "ALTER TABLE chat_intents DROP CONSTRAINT IF EXISTS chat_intents_status_check", + """ + ALTER TABLE chat_intents + ADD CONSTRAINT chat_intents_status_check + CHECK (status IN ('pending', 'recorded', 'handled', 'failed')) + """, + """ + CREATE TABLE approval_challenges ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + approval_id uuid NOT NULL REFERENCES approvals(id) ON DELETE CASCADE, + channel_message_id uuid NULL REFERENCES channel_messages(id) ON DELETE SET NULL, + status text NOT NULL, + challenge_prompt text NOT NULL, + challenge_payload jsonb NOT NULL DEFAULT '{}'::jsonb, + resolved_at timestamptz NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT approval_challenges_status_check + CHECK (status IN ('pending', 'approved', 'rejected', 'dismissed')) + ) + """, + ( + "CREATE UNIQUE INDEX approval_challenges_workspace_approval_pending_uidx " + "ON approval_challenges (workspace_id, approval_id) " + "WHERE status = 'pending'" + ), + ( + "CREATE INDEX approval_challenges_workspace_created_idx " + "ON approval_challenges (workspace_id, created_at DESC, id DESC)" + ), + """ + CREATE TABLE open_loop_reviews ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + continuity_object_id uuid NOT NULL REFERENCES continuity_objects(id) ON DELETE CASCADE, + channel_message_id uuid NULL REFERENCES channel_messages(id) ON DELETE SET NULL, + correction_event_id uuid NULL REFERENCES continuity_correction_events(id) ON DELETE SET NULL, + review_action text NOT NULL, + note text NULL, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT open_loop_reviews_action_check + CHECK (review_action IN ('done', 'deferred', 'still_blocked')) + ) + """, + ( + "CREATE INDEX open_loop_reviews_workspace_created_idx " + "ON open_loop_reviews (workspace_id, created_at DESC, id DESC)" + ), + ( + "CREATE INDEX open_loop_reviews_object_created_idx " + "ON open_loop_reviews (continuity_object_id, created_at DESC, id DESC)" + ), +) + +_UPGRADE_GRANT_STATEMENTS = ( + "GRANT SELECT, INSERT, UPDATE, DELETE ON approval_challenges TO alicebot_app", + "GRANT SELECT, INSERT, UPDATE, DELETE ON open_loop_reviews TO alicebot_app", +) + +_DOWNGRADE_STATEMENTS = ( + "DROP INDEX IF EXISTS open_loop_reviews_object_created_idx", + "DROP INDEX IF EXISTS open_loop_reviews_workspace_created_idx", + "DROP TABLE IF EXISTS open_loop_reviews", + "DROP INDEX IF EXISTS approval_challenges_workspace_created_idx", + "DROP INDEX IF EXISTS approval_challenges_workspace_approval_pending_uidx", + "DROP TABLE IF EXISTS approval_challenges", + "ALTER TABLE chat_intents DROP CONSTRAINT IF EXISTS chat_intents_status_check", + """ + UPDATE chat_intents + SET status = 'recorded' + WHERE status IN ('handled', 'failed') + """, + """ + ALTER TABLE chat_intents + ADD CONSTRAINT chat_intents_status_check + CHECK (status IN ('pending', 'recorded')) + """, + "ALTER TABLE chat_intents DROP CONSTRAINT IF EXISTS chat_intents_intent_kind_check", + """ + DELETE FROM chat_intents + WHERE intent_kind <> 'inbound_message' + """, + """ + ALTER TABLE chat_intents + ADD CONSTRAINT chat_intents_intent_kind_check + CHECK (intent_kind IN ('inbound_message')) + """, + """ + ALTER TABLE chat_intents + DROP COLUMN IF EXISTS handled_at, + DROP COLUMN IF EXISTS result_payload, + DROP COLUMN IF EXISTS intent_payload + """, +) + + +def _execute_statements(statements: tuple[str, ...]) -> None: + for statement in statements: + op.execute(statement) + + +def upgrade() -> None: + _execute_statements(_UPGRADE_STATEMENTS) + _execute_statements(_UPGRADE_GRANT_STATEMENTS) + + +def downgrade() -> None: + _execute_statements(_DOWNGRADE_STATEMENTS) diff --git a/apps/api/alembic/versions/20260408_0046_phase10_daily_brief_notifications.py b/apps/api/alembic/versions/20260408_0046_phase10_daily_brief_notifications.py new file mode 100644 index 0000000..8ba6ca9 --- /dev/null +++ b/apps/api/alembic/versions/20260408_0046_phase10_daily_brief_notifications.py @@ -0,0 +1,215 @@ +"""Add Phase 10 Sprint 4 daily brief jobs, notification subscriptions, and scheduled receipt metadata.""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260408_0046" +down_revision = "20260408_0045" +branch_labels = None +depends_on = None + + +_UPGRADE_STATEMENTS = ( + """ + CREATE TABLE notification_subscriptions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + channel_type text NOT NULL, + channel_identity_id uuid NOT NULL REFERENCES channel_identities(id) ON DELETE CASCADE, + notifications_enabled boolean NOT NULL DEFAULT TRUE, + daily_brief_enabled boolean NOT NULL DEFAULT TRUE, + daily_brief_window_start text NOT NULL DEFAULT '07:00', + open_loop_prompts_enabled boolean NOT NULL DEFAULT TRUE, + waiting_for_prompts_enabled boolean NOT NULL DEFAULT TRUE, + stale_prompts_enabled boolean NOT NULL DEFAULT TRUE, + timezone text NOT NULL DEFAULT 'UTC', + quiet_hours_enabled boolean NOT NULL DEFAULT FALSE, + quiet_hours_start text NOT NULL DEFAULT '22:00', + quiet_hours_end text NOT NULL DEFAULT '07:00', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (workspace_id, channel_type), + UNIQUE (channel_identity_id, channel_type), + CONSTRAINT notification_subscriptions_channel_type_check + CHECK (channel_type IN ('telegram')), + CONSTRAINT notification_subscriptions_window_start_format_check + CHECK (daily_brief_window_start ~ '^(?:[01][0-9]|2[0-3]):[0-5][0-9]$'), + CONSTRAINT notification_subscriptions_quiet_start_format_check + CHECK (quiet_hours_start ~ '^(?:[01][0-9]|2[0-3]):[0-5][0-9]$'), + CONSTRAINT notification_subscriptions_quiet_end_format_check + CHECK (quiet_hours_end ~ '^(?:[01][0-9]|2[0-3]):[0-5][0-9]$') + ) + """, + ( + "CREATE INDEX notification_subscriptions_workspace_updated_idx " + "ON notification_subscriptions (workspace_id, channel_type, updated_at DESC, id DESC)" + ), + """ + CREATE TABLE continuity_briefs ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + channel_type text NOT NULL, + channel_identity_id uuid NOT NULL REFERENCES channel_identities(id) ON DELETE CASCADE, + brief_kind text NOT NULL, + assembly_version text NOT NULL, + summary jsonb NOT NULL DEFAULT '{}'::jsonb, + brief_payload jsonb NOT NULL DEFAULT '{}'::jsonb, + message_text text NOT NULL, + compiled_at timestamptz NOT NULL DEFAULT now(), + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT continuity_briefs_channel_type_check + CHECK (channel_type IN ('telegram')), + CONSTRAINT continuity_briefs_kind_check + CHECK (brief_kind IN ('daily_brief')) + ) + """, + ( + "CREATE INDEX continuity_briefs_workspace_compiled_idx " + "ON continuity_briefs (workspace_id, channel_type, compiled_at DESC, id DESC)" + ), + """ + CREATE TABLE daily_brief_jobs ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + channel_type text NOT NULL, + channel_identity_id uuid NOT NULL REFERENCES channel_identities(id) ON DELETE CASCADE, + job_kind text NOT NULL, + prompt_kind text NULL, + prompt_id text NULL, + continuity_object_id uuid NULL REFERENCES continuity_objects(id) ON DELETE SET NULL, + continuity_brief_id uuid NULL REFERENCES continuity_briefs(id) ON DELETE SET NULL, + schedule_slot text NOT NULL, + idempotency_key text NOT NULL, + due_at timestamptz NOT NULL, + status text NOT NULL, + suppression_reason text NULL, + attempt_count integer NOT NULL DEFAULT 0, + delivery_receipt_id uuid NULL, + payload jsonb NOT NULL DEFAULT '{}'::jsonb, + result_payload jsonb NOT NULL DEFAULT '{}'::jsonb, + attempted_at timestamptz NULL, + completed_at timestamptz NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT daily_brief_jobs_channel_type_check + CHECK (channel_type IN ('telegram')), + CONSTRAINT daily_brief_jobs_kind_check + CHECK (job_kind IN ('daily_brief', 'open_loop_prompt')), + CONSTRAINT daily_brief_jobs_prompt_kind_check + CHECK (prompt_kind IS NULL OR prompt_kind IN ('waiting_for', 'stale')), + CONSTRAINT daily_brief_jobs_status_check + CHECK ( + status IN ( + 'scheduled', + 'delivered', + 'simulated', + 'suppressed_quiet_hours', + 'suppressed_disabled', + 'suppressed_outside_window', + 'failed' + ) + ), + CONSTRAINT daily_brief_jobs_attempt_count_check + CHECK (attempt_count >= 0), + CONSTRAINT daily_brief_jobs_prompt_required_for_open_loop_check + CHECK ( + (job_kind = 'daily_brief' AND prompt_id IS NULL) + OR + (job_kind = 'open_loop_prompt' AND prompt_id IS NOT NULL) + ), + CONSTRAINT daily_brief_jobs_workspace_idempotency_unique + UNIQUE (workspace_id, channel_type, idempotency_key) + ) + """, + ( + "CREATE INDEX daily_brief_jobs_workspace_due_idx " + "ON daily_brief_jobs (workspace_id, channel_type, due_at DESC, id DESC)" + ), + ( + "CREATE INDEX daily_brief_jobs_workspace_status_due_idx " + "ON daily_brief_jobs (workspace_id, channel_type, status, due_at DESC, id DESC)" + ), + ( + "CREATE INDEX daily_brief_jobs_workspace_prompt_slot_idx " + "ON daily_brief_jobs (workspace_id, prompt_id, schedule_slot, created_at DESC, id DESC)" + ), + "ALTER TABLE channel_delivery_receipts DROP CONSTRAINT IF EXISTS channel_delivery_receipts_status_check", + """ + ALTER TABLE channel_delivery_receipts + ADD CONSTRAINT channel_delivery_receipts_status_check + CHECK (status IN ('delivered', 'failed', 'simulated', 'suppressed')) + """, + """ + ALTER TABLE channel_delivery_receipts + ADD COLUMN scheduled_job_id uuid NULL REFERENCES daily_brief_jobs(id) ON DELETE SET NULL, + ADD COLUMN scheduler_job_kind text NULL, + ADD COLUMN scheduled_for timestamptz NULL, + ADD COLUMN schedule_slot text NULL, + ADD COLUMN notification_policy jsonb NOT NULL DEFAULT '{}'::jsonb + """, + """ + ALTER TABLE channel_delivery_receipts + ADD CONSTRAINT channel_delivery_receipts_scheduler_job_kind_check + CHECK (scheduler_job_kind IS NULL OR scheduler_job_kind IN ('daily_brief', 'open_loop_prompt')) + """, + ( + "CREATE INDEX channel_delivery_receipts_workspace_scheduler_idx " + "ON channel_delivery_receipts (workspace_id, scheduled_for DESC, id DESC)" + ), +) + +_UPGRADE_GRANT_STATEMENTS = ( + "GRANT SELECT, INSERT, UPDATE, DELETE ON notification_subscriptions TO alicebot_app", + "GRANT SELECT, INSERT, UPDATE, DELETE ON continuity_briefs TO alicebot_app", + "GRANT SELECT, INSERT, UPDATE, DELETE ON daily_brief_jobs TO alicebot_app", +) + +_DOWNGRADE_STATEMENTS = ( + "DROP INDEX IF EXISTS channel_delivery_receipts_workspace_scheduler_idx", + "ALTER TABLE channel_delivery_receipts DROP CONSTRAINT IF EXISTS channel_delivery_receipts_scheduler_job_kind_check", + """ + ALTER TABLE channel_delivery_receipts + DROP COLUMN IF EXISTS notification_policy, + DROP COLUMN IF EXISTS schedule_slot, + DROP COLUMN IF EXISTS scheduled_for, + DROP COLUMN IF EXISTS scheduler_job_kind, + DROP COLUMN IF EXISTS scheduled_job_id + """, + "ALTER TABLE channel_delivery_receipts DROP CONSTRAINT IF EXISTS channel_delivery_receipts_status_check", + """ + UPDATE channel_delivery_receipts + SET status = 'failed', + failure_code = COALESCE(failure_code, 'suppressed_status_downgrade'), + failure_detail = COALESCE(failure_detail, 'suppressed receipt downgraded during migration rollback') + WHERE status = 'suppressed' + """, + """ + ALTER TABLE channel_delivery_receipts + ADD CONSTRAINT channel_delivery_receipts_status_check + CHECK (status IN ('delivered', 'failed', 'simulated')) + """, + "DROP INDEX IF EXISTS daily_brief_jobs_workspace_prompt_slot_idx", + "DROP INDEX IF EXISTS daily_brief_jobs_workspace_status_due_idx", + "DROP INDEX IF EXISTS daily_brief_jobs_workspace_due_idx", + "DROP TABLE IF EXISTS daily_brief_jobs", + "DROP INDEX IF EXISTS continuity_briefs_workspace_compiled_idx", + "DROP TABLE IF EXISTS continuity_briefs", + "DROP INDEX IF EXISTS notification_subscriptions_workspace_updated_idx", + "DROP TABLE IF EXISTS notification_subscriptions", +) + + +def _execute_statements(statements: tuple[str, ...]) -> None: + for statement in statements: + op.execute(statement) + + +def upgrade() -> None: + _execute_statements(_UPGRADE_STATEMENTS) + _execute_statements(_UPGRADE_GRANT_STATEMENTS) + + +def downgrade() -> None: + _execute_statements(_DOWNGRADE_STATEMENTS) diff --git a/apps/api/alembic/versions/20260409_0047_phase10_beta_hardening_launch.py b/apps/api/alembic/versions/20260409_0047_phase10_beta_hardening_launch.py new file mode 100644 index 0000000..c67d780 --- /dev/null +++ b/apps/api/alembic/versions/20260409_0047_phase10_beta_hardening_launch.py @@ -0,0 +1,207 @@ +"""Add Phase 10 Sprint 5 beta hardening telemetry and evidence fields.""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260409_0047" +down_revision = "20260408_0046" +branch_labels = None +depends_on = None + + +_UPGRADE_STATEMENTS = ( + """ + CREATE TABLE chat_telemetry ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_account_id uuid NOT NULL REFERENCES user_accounts(id) ON DELETE CASCADE, + workspace_id uuid NULL REFERENCES workspaces(id) ON DELETE SET NULL, + channel_message_id uuid NULL REFERENCES channel_messages(id) ON DELETE SET NULL, + daily_brief_job_id uuid NULL REFERENCES daily_brief_jobs(id) ON DELETE SET NULL, + delivery_receipt_id uuid NULL REFERENCES channel_delivery_receipts(id) ON DELETE SET NULL, + flow_kind text NOT NULL, + event_kind text NOT NULL, + status text NOT NULL, + route_path text NOT NULL, + rollout_flag_key text NULL, + rollout_flag_state text NULL, + rate_limit_key text NULL, + rate_limit_window_seconds integer NULL, + rate_limit_max_requests integer NULL, + retry_after_seconds integer NULL, + abuse_signal text NULL, + evidence jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT chat_telemetry_flow_kind_check + CHECK (flow_kind IN ('chat_handle', 'scheduler_daily_brief', 'scheduler_open_loop_prompt')), + CONSTRAINT chat_telemetry_event_kind_check + CHECK (event_kind IN ('attempt', 'result', 'rollout_block', 'rate_limited', 'abuse_block', 'incident')), + CONSTRAINT chat_telemetry_status_check + CHECK ( + status IN ( + 'ok', + 'failed', + 'blocked_rollout', + 'rate_limited', + 'abuse_blocked', + 'suppressed', + 'simulated', + 'delivered' + ) + ), + CONSTRAINT chat_telemetry_route_path_length_check + CHECK (char_length(route_path) >= 1 AND char_length(route_path) <= 200), + CONSTRAINT chat_telemetry_rate_limit_window_positive_check + CHECK (rate_limit_window_seconds IS NULL OR rate_limit_window_seconds > 0), + CONSTRAINT chat_telemetry_rate_limit_max_positive_check + CHECK (rate_limit_max_requests IS NULL OR rate_limit_max_requests > 0), + CONSTRAINT chat_telemetry_retry_after_non_negative_check + CHECK (retry_after_seconds IS NULL OR retry_after_seconds >= 0) + ) + """, + ( + "CREATE INDEX chat_telemetry_workspace_created_idx " + "ON chat_telemetry (workspace_id, created_at DESC, id DESC)" + ), + ( + "CREATE INDEX chat_telemetry_flow_status_created_idx " + "ON chat_telemetry (flow_kind, status, created_at DESC, id DESC)" + ), + """ + ALTER TABLE workspaces + ADD COLUMN support_status text NOT NULL DEFAULT 'healthy', + ADD COLUMN support_notes jsonb NOT NULL DEFAULT '{}'::jsonb, + ADD COLUMN onboarding_last_error_code text NULL, + ADD COLUMN onboarding_last_error_detail text NULL, + ADD COLUMN onboarding_last_error_at timestamptz NULL, + ADD COLUMN onboarding_error_count integer NOT NULL DEFAULT 0, + ADD COLUMN rollout_evidence jsonb NOT NULL DEFAULT '{}'::jsonb, + ADD COLUMN rate_limit_evidence jsonb NOT NULL DEFAULT '{}'::jsonb, + ADD COLUMN incident_evidence jsonb NOT NULL DEFAULT '{}'::jsonb + """, + """ + ALTER TABLE workspaces + ADD CONSTRAINT workspaces_support_status_check + CHECK (support_status IN ('healthy', 'needs_attention', 'blocked')) + """, + """ + ALTER TABLE workspaces + ADD CONSTRAINT workspaces_onboarding_error_count_non_negative_check + CHECK (onboarding_error_count >= 0) + """, + ( + "CREATE INDEX workspaces_support_status_updated_idx " + "ON workspaces (support_status, updated_at DESC, id DESC)" + ), + """ + ALTER TABLE channel_delivery_receipts + ADD COLUMN rollout_flag_state text NOT NULL DEFAULT 'enabled', + ADD COLUMN support_evidence jsonb NOT NULL DEFAULT '{}'::jsonb, + ADD COLUMN rate_limit_evidence jsonb NOT NULL DEFAULT '{}'::jsonb, + ADD COLUMN incident_evidence jsonb NOT NULL DEFAULT '{}'::jsonb + """, + """ + ALTER TABLE channel_delivery_receipts + ADD CONSTRAINT channel_delivery_receipts_rollout_flag_state_check + CHECK (rollout_flag_state IN ('enabled', 'blocked')) + """, + ( + "CREATE INDEX channel_delivery_receipts_rollout_recorded_idx " + "ON channel_delivery_receipts (rollout_flag_state, recorded_at DESC, id DESC)" + ), + """ + ALTER TABLE daily_brief_jobs + ADD COLUMN rollout_flag_state text NOT NULL DEFAULT 'enabled', + ADD COLUMN support_evidence jsonb NOT NULL DEFAULT '{}'::jsonb, + ADD COLUMN rate_limit_evidence jsonb NOT NULL DEFAULT '{}'::jsonb, + ADD COLUMN incident_evidence jsonb NOT NULL DEFAULT '{}'::jsonb + """, + """ + ALTER TABLE daily_brief_jobs + ADD CONSTRAINT daily_brief_jobs_rollout_flag_state_check + CHECK (rollout_flag_state IN ('enabled', 'blocked')) + """, + ( + "CREATE INDEX daily_brief_jobs_rollout_due_idx " + "ON daily_brief_jobs (rollout_flag_state, due_at DESC, id DESC)" + ), + """ + INSERT INTO beta_cohorts (cohort_key, description) + VALUES ('p10-ops', 'Phase 10 hosted beta operator cohort') + ON CONFLICT (cohort_key) DO NOTHING + """, + """ + INSERT INTO feature_flags (flag_key, cohort_key, enabled, description) + VALUES + ('hosted_admin_read', 'p10-beta', true, 'Hosted admin visibility for beta operations'), + ('hosted_chat_handle_enabled', 'p10-beta', true, 'Rollout gate for hosted telegram chat handling'), + ('hosted_scheduler_delivery_enabled', 'p10-beta', true, 'Rollout gate for hosted scheduler-driven deliveries'), + ('hosted_abuse_controls_enabled', 'p10-beta', true, 'Enable hosted abuse controls for chat and scheduler paths'), + ('hosted_rate_limits_enabled', 'p10-beta', true, 'Enable hosted rate limiting controls'), + ('hosted_admin_read', 'p10-ops', true, 'Hosted admin visibility for beta operators'), + ('hosted_admin_operator', 'p10-ops', true, 'Hosted admin operator authorization'), + ('hosted_chat_handle_enabled', 'p10-ops', true, 'Rollout gate for hosted telegram chat handling'), + ('hosted_scheduler_delivery_enabled', 'p10-ops', true, 'Rollout gate for hosted scheduler-driven deliveries'), + ('hosted_abuse_controls_enabled', 'p10-ops', true, 'Enable hosted abuse controls for chat and scheduler paths'), + ('hosted_rate_limits_enabled', 'p10-ops', true, 'Enable hosted rate limiting controls') + ON CONFLICT DO NOTHING + """, +) + +_UPGRADE_GRANT_STATEMENTS = ( + "GRANT SELECT, INSERT, UPDATE, DELETE ON chat_telemetry TO alicebot_app", +) + +_DOWNGRADE_STATEMENTS = ( + "DROP INDEX IF EXISTS daily_brief_jobs_rollout_due_idx", + "ALTER TABLE daily_brief_jobs DROP CONSTRAINT IF EXISTS daily_brief_jobs_rollout_flag_state_check", + """ + ALTER TABLE daily_brief_jobs + DROP COLUMN IF EXISTS incident_evidence, + DROP COLUMN IF EXISTS rate_limit_evidence, + DROP COLUMN IF EXISTS support_evidence, + DROP COLUMN IF EXISTS rollout_flag_state + """, + "DROP INDEX IF EXISTS channel_delivery_receipts_rollout_recorded_idx", + "ALTER TABLE channel_delivery_receipts DROP CONSTRAINT IF EXISTS channel_delivery_receipts_rollout_flag_state_check", + """ + ALTER TABLE channel_delivery_receipts + DROP COLUMN IF EXISTS incident_evidence, + DROP COLUMN IF EXISTS rate_limit_evidence, + DROP COLUMN IF EXISTS support_evidence, + DROP COLUMN IF EXISTS rollout_flag_state + """, + "DROP INDEX IF EXISTS workspaces_support_status_updated_idx", + "ALTER TABLE workspaces DROP CONSTRAINT IF EXISTS workspaces_onboarding_error_count_non_negative_check", + "ALTER TABLE workspaces DROP CONSTRAINT IF EXISTS workspaces_support_status_check", + """ + ALTER TABLE workspaces + DROP COLUMN IF EXISTS incident_evidence, + DROP COLUMN IF EXISTS rate_limit_evidence, + DROP COLUMN IF EXISTS rollout_evidence, + DROP COLUMN IF EXISTS onboarding_error_count, + DROP COLUMN IF EXISTS onboarding_last_error_at, + DROP COLUMN IF EXISTS onboarding_last_error_detail, + DROP COLUMN IF EXISTS onboarding_last_error_code, + DROP COLUMN IF EXISTS support_notes, + DROP COLUMN IF EXISTS support_status + """, + "DROP INDEX IF EXISTS chat_telemetry_flow_status_created_idx", + "DROP INDEX IF EXISTS chat_telemetry_workspace_created_idx", + "DROP TABLE IF EXISTS chat_telemetry", +) + + +def _execute_statements(statements: tuple[str, ...]) -> None: + for statement in statements: + op.execute(statement) + + +def upgrade() -> None: + _execute_statements(_UPGRADE_STATEMENTS) + _execute_statements(_UPGRADE_GRANT_STATEMENTS) + + +def downgrade() -> None: + _execute_statements(_DOWNGRADE_STATEMENTS) diff --git a/apps/api/alembic/versions/20260410_0048_memory_trust_classes.py b/apps/api/alembic/versions/20260410_0048_memory_trust_classes.py new file mode 100644 index 0000000..54c530b --- /dev/null +++ b/apps/api/alembic/versions/20260410_0048_memory_trust_classes.py @@ -0,0 +1,88 @@ +"""Add trust classes and promotion metadata to typed memories.""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260410_0048" +down_revision = "20260409_0047" +branch_labels = None +depends_on = None + +MEMORY_TRUST_CLASSES = ( + "deterministic", + "llm_single_source", + "llm_corroborated", + "human_curated", +) + +MEMORY_PROMOTION_ELIGIBILITIES = ( + "promotable", + "not_promotable", +) + +_MEMORY_TRUST_CLASSES_SQL = ", ".join(f"'{value}'" for value in MEMORY_TRUST_CLASSES) +_MEMORY_PROMOTION_ELIGIBILITIES_SQL = ", ".join( + f"'{value}'" for value in MEMORY_PROMOTION_ELIGIBILITIES +) + +_UPGRADE_STATEMENTS = ( + """ + ALTER TABLE memories + ADD COLUMN trust_class text NOT NULL DEFAULT 'deterministic', + ADD COLUMN promotion_eligibility text NOT NULL DEFAULT 'promotable', + ADD COLUMN evidence_count integer NULL, + ADD COLUMN independent_source_count integer NULL, + ADD COLUMN extracted_by_model text NULL, + ADD COLUMN trust_reason text NULL + """, + f""" + ALTER TABLE memories + ADD CONSTRAINT memories_trust_class_check + CHECK (trust_class IN ({_MEMORY_TRUST_CLASSES_SQL})) + """, + f""" + ALTER TABLE memories + ADD CONSTRAINT memories_promotion_eligibility_check + CHECK (promotion_eligibility IN ({_MEMORY_PROMOTION_ELIGIBILITIES_SQL})) + """, + """ + ALTER TABLE memories + ADD CONSTRAINT memories_evidence_count_non_negative_check + CHECK (evidence_count IS NULL OR evidence_count >= 0) + """, + """ + ALTER TABLE memories + ADD CONSTRAINT memories_independent_source_count_non_negative_check + CHECK (independent_source_count IS NULL OR independent_source_count >= 0) + """, + """ + CREATE INDEX memories_user_trust_class_updated_idx + ON memories (user_id, trust_class, updated_at) + """, +) + +_DOWNGRADE_STATEMENTS = ( + "DROP INDEX IF EXISTS memories_user_trust_class_updated_idx", + "ALTER TABLE memories DROP CONSTRAINT IF EXISTS memories_independent_source_count_non_negative_check", + "ALTER TABLE memories DROP CONSTRAINT IF EXISTS memories_evidence_count_non_negative_check", + "ALTER TABLE memories DROP CONSTRAINT IF EXISTS memories_promotion_eligibility_check", + "ALTER TABLE memories DROP CONSTRAINT IF EXISTS memories_trust_class_check", + "ALTER TABLE memories DROP COLUMN IF EXISTS trust_reason", + "ALTER TABLE memories DROP COLUMN IF EXISTS extracted_by_model", + "ALTER TABLE memories DROP COLUMN IF EXISTS independent_source_count", + "ALTER TABLE memories DROP COLUMN IF EXISTS evidence_count", + "ALTER TABLE memories DROP COLUMN IF EXISTS promotion_eligibility", + "ALTER TABLE memories DROP COLUMN IF EXISTS trust_class", +) + + +def upgrade() -> None: + for statement in _UPGRADE_STATEMENTS: + op.execute(statement) + + +def downgrade() -> None: + for statement in _DOWNGRADE_STATEMENTS: + op.execute(statement) diff --git a/apps/api/alembic/versions/20260410_0049_continuity_object_lifecycle_flags.py b/apps/api/alembic/versions/20260410_0049_continuity_object_lifecycle_flags.py new file mode 100644 index 0000000..a68f925 --- /dev/null +++ b/apps/api/alembic/versions/20260410_0049_continuity_object_lifecycle_flags.py @@ -0,0 +1,54 @@ +"""Separate preservation, searchability, and promotability lifecycle flags.""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260410_0049" +down_revision = "20260410_0048" +branch_labels = None +depends_on = None + +_UPGRADE_STATEMENTS = ( + "ALTER TABLE continuity_objects ADD COLUMN is_preserved boolean NOT NULL DEFAULT TRUE", + "ALTER TABLE continuity_objects ADD COLUMN is_searchable boolean NOT NULL DEFAULT TRUE", + "ALTER TABLE continuity_objects ADD COLUMN is_promotable boolean NOT NULL DEFAULT TRUE", + ( + "UPDATE continuity_objects " + "SET is_searchable = CASE WHEN object_type = 'Note' THEN FALSE ELSE TRUE END, " + " is_promotable = CASE " + " WHEN object_type IN ('Decision', 'Commitment', 'WaitingFor', 'Blocker', 'NextAction') THEN TRUE " + " ELSE FALSE " + " END" + ), + ( + "CREATE INDEX continuity_objects_user_searchable_updated_idx " + "ON continuity_objects (user_id, is_searchable, updated_at DESC, id DESC)" + ), + ( + "CREATE INDEX continuity_objects_user_promotable_updated_idx " + "ON continuity_objects (user_id, is_promotable, updated_at DESC, id DESC)" + ), +) + +_DOWNGRADE_STATEMENTS = ( + "DROP INDEX IF EXISTS continuity_objects_user_promotable_updated_idx", + "DROP INDEX IF EXISTS continuity_objects_user_searchable_updated_idx", + "ALTER TABLE continuity_objects DROP COLUMN IF EXISTS is_promotable", + "ALTER TABLE continuity_objects DROP COLUMN IF EXISTS is_searchable", + "ALTER TABLE continuity_objects DROP COLUMN IF EXISTS is_preserved", +) + + +def _execute_statements(statements: tuple[str, ...]) -> None: + for statement in statements: + op.execute(statement) + + +def upgrade() -> None: + _execute_statements(_UPGRADE_STATEMENTS) + + +def downgrade() -> None: + _execute_statements(_DOWNGRADE_STATEMENTS) diff --git a/apps/api/src/alicebot_api/__init__.py b/apps/api/src/alicebot_api/__init__.py new file mode 100644 index 0000000..3415b20 --- /dev/null +++ b/apps/api/src/alicebot_api/__init__.py @@ -0,0 +1,5 @@ +"""AliceBot foundation API package.""" + +__version__ = "0.1.0" + +__all__ = ["__version__"] diff --git a/apps/api/src/alicebot_api/__main__.py b/apps/api/src/alicebot_api/__main__.py new file mode 100644 index 0000000..08c446f --- /dev/null +++ b/apps/api/src/alicebot_api/__main__.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from alicebot_api.cli import main + + +if __name__ == "__main__": + raise SystemExit(main()) + diff --git a/apps/api/src/alicebot_api/approvals.py b/apps/api/src/alicebot_api/approvals.py new file mode 100644 index 0000000..eb5210f --- /dev/null +++ b/apps/api/src/alicebot_api/approvals.py @@ -0,0 +1,607 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from typing import cast +from uuid import UUID + +from alicebot_api.contracts import ( + APPROVAL_LIST_ORDER, + APPROVAL_REQUEST_VERSION_V0, + APPROVAL_RESOLUTION_VERSION_V0, + TRACE_KIND_APPROVAL_REQUEST, + TRACE_KIND_APPROVAL_RESOLUTION, + ApprovalApproveInput, + ApprovalDetailResponse, + ApprovalListResponse, + ApprovalListSummary, + ApprovalRecord, + ApprovalRejectInput, + ApprovalResolutionAction, + ApprovalResolutionOutcome, + ApprovalResolutionRecord, + ApprovalResolutionRequestTracePayload, + ApprovalResolutionResponse, + ApprovalResolutionStateTracePayload, + ApprovalResolutionSummaryTracePayload, + ApprovalRequestCreateInput, + ApprovalRequestCreateResponse, + ApprovalRequestTraceSummary, + ApprovalRoutingRecord, + TaskCreateInput, + TaskStepCreateInput, + ToolRoutingRequestInput, +) +from alicebot_api.store import ApprovalRow, ContinuityStore, JsonObject +from alicebot_api.tasks import ( + DEFAULT_TASK_STEP_KIND, + DEFAULT_TASK_STEP_SEQUENCE_NO, + create_task_step_for_governed_request, + create_task_for_governed_request, + sync_task_step_with_approval, + task_step_lifecycle_trace_events, + task_step_outcome_snapshot, + task_step_status_for_routing_decision, + sync_task_with_approval, + task_lifecycle_trace_events, + task_status_for_routing_decision, + validate_linked_task_step_for_approval, +) +from alicebot_api.tools import route_tool_invocation + + +class ApprovalNotFoundError(LookupError): + """Raised when an approval record is not visible inside the current user scope.""" + + +class ApprovalResolutionConflictError(RuntimeError): + """Raised when a visible approval record is no longer pending.""" + + +def _serialize_resolution(row: ApprovalRow) -> ApprovalResolutionRecord | None: + if row["resolved_at"] is None or row["resolved_by_user_id"] is None: + return None + return { + "resolved_at": row["resolved_at"].isoformat(), + "resolved_by_user_id": str(row["resolved_by_user_id"]), + } + + +def serialize_approval_row(row: ApprovalRow) -> ApprovalRecord: + payload: ApprovalRecord = { + "id": str(row["id"]), + "thread_id": str(row["thread_id"]), + "task_step_id": None if row["task_step_id"] is None else str(row["task_step_id"]), + "status": cast(str, row["status"]), + "request": cast(dict[str, object], row["request"]), + "tool": cast(dict[str, object], row["tool"]), + "routing": cast(ApprovalRoutingRecord, row["routing"]), + "created_at": row["created_at"].isoformat(), + "resolution": _serialize_resolution(row), + } + task_run_id = cast(UUID | None, row.get("task_run_id")) + if task_run_id is not None: + payload["task_run_id"] = str(task_run_id) + return payload + + +_serialize_approval = serialize_approval_row + + +def _resume_task_run_after_resolution( + store: ContinuityStore, + *, + approval: ApprovalRow, +) -> tuple[str, str, str | None, str | None, str] | None: + task_run_id = cast(UUID | None, approval.get("task_run_id")) + if task_run_id is None: + return None + + task_run = store.get_task_run_optional(task_run_id) + if task_run is None: + return None + if cast(str, task_run["status"]) != "waiting_approval": + return None + + checkpoint = cast(JsonObject, task_run["checkpoint"]) + if not isinstance(checkpoint, dict): + checkpoint = {} + updated_checkpoint = dict(checkpoint) + updated_checkpoint["wait_for_signal"] = False + updated_checkpoint["waiting_approval_id"] = None + updated_checkpoint["resolved_approval_id"] = str(approval["id"]) + updated_checkpoint["approval_resolution_status"] = cast(str, approval["status"]) + + next_status = "queued" if approval["status"] == "approved" else "failed" + next_stop_reason = None if next_status == "queued" else "approval_rejected" + next_failure_class = None if next_status == "queued" else "approval" + next_retry_posture = "none" if next_status == "queued" else "terminal" + transitions = updated_checkpoint.get("transitions") + if isinstance(transitions, list): + history = [entry for entry in transitions if isinstance(entry, dict)] + else: + history = [] + transition_entry = { + "sequence_no": len(history) + 1, + "source": "approval_resolution", + "at": datetime.now(UTC).isoformat(), + "previous_status": cast(str, task_run["status"]), + "status": next_status, + "previous_stop_reason": cast(str | None, task_run["stop_reason"]), + "stop_reason": next_stop_reason, + "failure_class": next_failure_class, + "retry_count": int(task_run["retry_count"]), + "retry_cap": int(task_run["retry_cap"]), + "retry_posture": next_retry_posture, + } + history.append(transition_entry) + updated_checkpoint["transitions"] = history + updated_checkpoint["last_transition"] = transition_entry + updated = store.update_task_run_optional( + task_run_id=task_run_id, + status=next_status, + checkpoint=updated_checkpoint, + tick_count=int(task_run["tick_count"]), + step_count=int(task_run["step_count"]), + retry_count=int(task_run["retry_count"]), + retry_cap=int(task_run["retry_cap"]), + retry_posture=next_retry_posture, + failure_class=next_failure_class, + stop_reason=next_stop_reason, + ) + if updated is None: + return None + return ( + cast(str, task_run["status"]), + cast(str, updated["status"]), + cast(str | None, updated["stop_reason"]), + cast(str | None, updated["failure_class"]), + cast(str, updated["retry_posture"]), + ) + + +def _append_trace_events( + store: ContinuityStore, + *, + trace_id: UUID, + trace_events: list[tuple[str, dict[str, object]]], +) -> None: + for sequence_no, (kind, payload) in enumerate(trace_events, start=1): + store.append_trace_event( + trace_id=trace_id, + sequence_no=sequence_no, + kind=kind, + payload=payload, + ) + + +def _resolution_outcome( + *, + requested_action: ApprovalResolutionAction, + current_status: str, +) -> ApprovalResolutionOutcome: + if ( + requested_action == "approve" + and current_status == "approved" + ) or ( + requested_action == "reject" + and current_status == "rejected" + ): + return "duplicate_rejected" + return "conflict_rejected" + + +def _resolution_error( + approval_id: UUID, + *, + requested_action: ApprovalResolutionAction, + current_status: str, +) -> ApprovalResolutionConflictError: + if ( + requested_action == "approve" + and current_status == "approved" + ) or ( + requested_action == "reject" + and current_status == "rejected" + ): + return ApprovalResolutionConflictError(f"approval {approval_id} was already {current_status}") + + requested_status = "approved" if requested_action == "approve" else "rejected" + return ApprovalResolutionConflictError( + f"approval {approval_id} was already {current_status} and cannot be {requested_status}" + ) + + +def _resolve_approval( + store: ContinuityStore, + *, + user_id: UUID, + approval_id: UUID, + requested_action: ApprovalResolutionAction, + resolved_status: str, +) -> ApprovalResolutionResponse: + del user_id + + approval = store.get_approval_optional(approval_id) + if approval is None: + raise ApprovalNotFoundError(f"approval {approval_id} was not found") + validate_linked_task_step_for_approval( + store, + approval_id=approval_id, + task_step_id=cast(UUID | None, approval["task_step_id"]), + ) + + previous_status = cast(str, approval["status"]) + current = approval + outcome: ApprovalResolutionOutcome + + if approval["status"] == "pending": + resolved = store.resolve_approval_optional( + approval_id=approval_id, + status=resolved_status, + ) + if resolved is None: + current = store.get_approval_optional(approval_id) + if current is None: + raise ApprovalNotFoundError(f"approval {approval_id} was not found") + outcome = _resolution_outcome( + requested_action=requested_action, + current_status=cast(str, current["status"]), + ) + else: + current = resolved + outcome = "resolved" + else: + outcome = _resolution_outcome( + requested_action=requested_action, + current_status=previous_status, + ) + + trace = store.create_trace( + user_id=current["user_id"], + thread_id=current["thread_id"], + kind=TRACE_KIND_APPROVAL_RESOLUTION, + compiler_version=APPROVAL_RESOLUTION_VERSION_V0, + status="completed", + limits={ + "order": list(APPROVAL_LIST_ORDER), + "requested_action": requested_action, + "outcome": outcome, + }, + ) + + resolution = _serialize_resolution(current) + linked_task_step_id = None if current["task_step_id"] is None else str(current["task_step_id"]) + request_payload: ApprovalResolutionRequestTracePayload = { + "approval_id": str(approval_id), + "task_step_id": linked_task_step_id, + "requested_action": requested_action, + } + state_payload: ApprovalResolutionStateTracePayload = { + "approval_id": str(current["id"]), + "task_step_id": linked_task_step_id, + "requested_action": requested_action, + "previous_status": previous_status, + "outcome": outcome, + "current_status": cast(str, current["status"]), + "resolved_at": None if resolution is None else resolution["resolved_at"], + "resolved_by_user_id": None if resolution is None else resolution["resolved_by_user_id"], + } + summary_payload: ApprovalResolutionSummaryTracePayload = { + "approval_id": str(current["id"]), + "task_step_id": linked_task_step_id, + "requested_action": requested_action, + "outcome": outcome, + "final_status": cast(str, current["status"]), + } + task_transition = sync_task_with_approval( + store, + approval_id=current["id"], + approval_status=cast(str, current["status"]), + ) + task_step_transition = sync_task_step_with_approval( + store, + approval_id=current["id"], + task_step_id=cast(UUID | None, current["task_step_id"]), + approval_status=cast(str, current["status"]), + trace_id=trace["id"], + trace_kind=TRACE_KIND_APPROVAL_RESOLUTION, + ) + run_transition = _resume_task_run_after_resolution(store, approval=current) + trace_events: list[tuple[str, dict[str, object]]] = [ + ("approval.resolution.request", cast(dict[str, object], request_payload)), + ("approval.resolution.state", cast(dict[str, object], state_payload)), + ("approval.resolution.summary", cast(dict[str, object], summary_payload)), + ] + if run_transition is not None: + ( + previous_run_status, + current_run_status, + run_stop_reason, + run_failure_class, + run_retry_posture, + ) = run_transition + trace_events.append( + ( + "approval.resolution.run", + { + "approval_id": str(current["id"]), + "task_run_id": str(cast(UUID, current.get("task_run_id"))), + "previous_status": previous_run_status, + "current_status": current_run_status, + "stop_reason": run_stop_reason, + "failure_class": run_failure_class, + "retry_posture": run_retry_posture, + }, + ) + ) + trace_events.extend( + task_lifecycle_trace_events( + task=task_transition.task, + previous_status=task_transition.previous_status, + source="approval_resolution", + ) + ) + trace_events.extend( + task_step_lifecycle_trace_events( + task_step=task_step_transition.task_step, + previous_status=task_step_transition.previous_status, + source="approval_resolution", + ) + ) + _append_trace_events(store, trace_id=trace["id"], trace_events=trace_events) + + if outcome != "resolved": + raise _resolution_error( + approval_id, + requested_action=requested_action, + current_status=cast(str, current["status"]), + ) + + return { + "approval": _serialize_approval(current), + "trace": { + "trace_id": str(trace["id"]), + "trace_event_count": len(trace_events), + }, + } + + +def submit_approval_request( + store: ContinuityStore, + *, + user_id: UUID, + request: ApprovalRequestCreateInput, +) -> ApprovalRequestCreateResponse: + routing = route_tool_invocation( + store, + user_id=user_id, + request=ToolRoutingRequestInput( + thread_id=request.thread_id, + tool_id=request.tool_id, + action=request.action, + scope=request.scope, + domain_hint=request.domain_hint, + risk_hint=request.risk_hint, + attributes=request.attributes, + ), + ) + + thread = store.get_thread_optional(request.thread_id) + if thread is None: + raise RuntimeError("validated thread disappeared before approval request trace creation") + + approval_persist_requested = routing["decision"] == "approval_required" + approval = None + approval_created = False + if routing["decision"] == "approval_required": + try: + approval_row = store.create_approval( + thread_id=request.thread_id, + tool_id=request.tool_id, + task_run_id=request.task_run_id, + task_step_id=None, + status="pending", + request=routing["request"], + tool=routing["tool"], + routing={ + "decision": routing["decision"], + "reasons": routing["reasons"], + "trace": routing["trace"], + }, + routing_trace_id=UUID(routing["trace"]["trace_id"]), + ) + except TypeError: + approval_row = store.create_approval( + thread_id=request.thread_id, + tool_id=request.tool_id, + task_step_id=None, + status="pending", + request=routing["request"], + tool=routing["tool"], + routing={ + "decision": routing["decision"], + "reasons": routing["reasons"], + "trace": routing["trace"], + }, + routing_trace_id=UUID(routing["trace"]["trace_id"]), + ) + approval = _serialize_approval(approval_row) + approval_created = True + + task = create_task_for_governed_request( + store, + request=TaskCreateInput( + thread_id=request.thread_id, + tool_id=request.tool_id, + status=task_status_for_routing_decision(routing["decision"]), + request=routing["request"], + tool=routing["tool"], + latest_approval_id=None if approval is None else UUID(approval["id"]), + ), + )["task"] + + trace = store.create_trace( + user_id=thread["user_id"], + thread_id=thread["id"], + kind=TRACE_KIND_APPROVAL_REQUEST, + compiler_version=APPROVAL_REQUEST_VERSION_V0, + status="completed", + limits={ + "order": list(APPROVAL_LIST_ORDER), + "persisted": approval_persist_requested, + }, + ) + task_step = create_task_step_for_governed_request( + store, + request=TaskStepCreateInput( + task_id=UUID(task["id"]), + sequence_no=DEFAULT_TASK_STEP_SEQUENCE_NO, + kind=DEFAULT_TASK_STEP_KIND, + status=task_step_status_for_routing_decision(routing["decision"]), + request=routing["request"], + outcome=task_step_outcome_snapshot( + routing_decision=routing["decision"], + approval_id=None if approval is None else approval["id"], + approval_status=None if approval is None else approval["status"], + execution_id=None, + execution_status=None, + blocked_reason=None, + ), + trace_id=trace["id"], + trace_kind=TRACE_KIND_APPROVAL_REQUEST, + ), + )["task_step"] + if approval is not None: + updated_approval = store.update_approval_task_step_optional( + approval_id=UUID(approval["id"]), + task_step_id=UUID(task_step["id"]), + ) + if updated_approval is None: + raise RuntimeError("approval disappeared while linking it to its originating task step") + approval = _serialize_approval(updated_approval) + + trace_events: list[tuple[str, dict[str, object]]] = [ + ("approval.request.request", request.as_payload()), + ( + "approval.request.routing", + { + "decision": routing["decision"], + "tool_id": routing["tool"]["id"], + "tool_key": routing["tool"]["tool_key"], + "tool_version": routing["tool"]["version"], + "routing_trace_id": routing["trace"]["trace_id"], + "routing_trace_event_count": routing["trace"]["trace_event_count"], + "reasons": routing["reasons"], + }, + ), + ( + "approval.request.persisted" if approval_created else "approval.request.skipped", + { + "approval_id": None if approval is None else approval["id"], + "task_step_id": None if approval is None else approval["task_step_id"], + "decision": routing["decision"], + "persisted": approval_created, + }, + ), + ( + "approval.request.summary", + { + "decision": routing["decision"], + "persisted": approval_created, + "approval_id": None if approval is None else approval["id"], + "task_step_id": None if approval is None else approval["task_step_id"], + }, + ), + ] + trace_events.extend( + task_lifecycle_trace_events( + task=task, + previous_status=None, + source="approval_request", + ) + ) + trace_events.extend( + task_step_lifecycle_trace_events( + task_step=task_step, + previous_status=None, + source="approval_request", + ) + ) + _append_trace_events(store, trace_id=trace["id"], trace_events=trace_events) + + trace_summary: ApprovalRequestTraceSummary = { + "trace_id": str(trace["id"]), + "trace_event_count": len(trace_events), + } + return { + "request": routing["request"], + "decision": routing["decision"], + "tool": routing["tool"], + "reasons": routing["reasons"], + "task": task, + "approval": approval, + "routing_trace": routing["trace"], + "trace": trace_summary, + } + + +def approve_approval_record( + store: ContinuityStore, + *, + user_id: UUID, + request: ApprovalApproveInput, +) -> ApprovalResolutionResponse: + return _resolve_approval( + store, + user_id=user_id, + approval_id=request.approval_id, + requested_action="approve", + resolved_status="approved", + ) + + +def reject_approval_record( + store: ContinuityStore, + *, + user_id: UUID, + request: ApprovalRejectInput, +) -> ApprovalResolutionResponse: + return _resolve_approval( + store, + user_id=user_id, + approval_id=request.approval_id, + requested_action="reject", + resolved_status="rejected", + ) + + +def list_approval_records( + store: ContinuityStore, + *, + user_id: UUID, +) -> ApprovalListResponse: + del user_id + + items = [_serialize_approval(row) for row in store.list_approvals()] + summary: ApprovalListSummary = { + "total_count": len(items), + "order": list(APPROVAL_LIST_ORDER), + } + return { + "items": items, + "summary": summary, + } + + +def get_approval_record( + store: ContinuityStore, + *, + user_id: UUID, + approval_id: UUID, +) -> ApprovalDetailResponse: + del user_id + + approval = store.get_approval_optional(approval_id) + if approval is None: + raise ApprovalNotFoundError(f"approval {approval_id} was not found") + return {"approval": _serialize_approval(approval)} diff --git a/apps/api/src/alicebot_api/artifacts.py b/apps/api/src/alicebot_api/artifacts.py new file mode 100644 index 0000000..9653594 --- /dev/null +++ b/apps/api/src/alicebot_api/artifacts.py @@ -0,0 +1,1339 @@ +from __future__ import annotations + +import io +from email import policy +from email.errors import MessageDefect, MessageError +from email.message import EmailMessage +from email.parser import BytesParser +import re +import zlib +from dataclasses import dataclass +from pathlib import Path +from typing import cast +from uuid import UUID +import xml.etree.ElementTree as ET +import zipfile + +import psycopg + +from alicebot_api.contracts import ( + TASK_ARTIFACT_LIST_ORDER, + TASK_ARTIFACT_CHUNK_LIST_ORDER, + TASK_ARTIFACT_CHUNK_RETRIEVAL_ORDER, + ArtifactScopedArtifactChunkRetrievalInput, + TaskArtifactChunkRetrievalItem, + TaskArtifactChunkRetrievalMatch, + TaskArtifactChunkRetrievalResponse, + TaskArtifactChunkRetrievalScope, + TaskArtifactChunkRetrievalScopeKind, + TaskArtifactChunkRetrievalSummary, + TaskArtifactCreateResponse, + TaskArtifactDetailResponse, + TaskArtifactChunkListResponse, + TaskArtifactChunkListSummary, + TaskArtifactChunkRecord, + TaskArtifactListResponse, + TaskArtifactRecord, + TaskArtifactIngestInput, + TaskArtifactIngestionResponse, + TaskArtifactRegisterInput, + TaskArtifactStatus, + TaskArtifactIngestionStatus, + TaskScopedArtifactChunkRetrievalInput, +) +from alicebot_api.store import ContinuityStore, TaskArtifactChunkRow, TaskArtifactRow +from alicebot_api.tasks import TaskNotFoundError +from alicebot_api.workspaces import TaskWorkspaceNotFoundError + +SUPPORTED_TEXT_ARTIFACT_MEDIA_TYPES = ("text/plain", "text/markdown") +SUPPORTED_PDF_ARTIFACT_MEDIA_TYPE = "application/pdf" +SUPPORTED_DOCX_ARTIFACT_MEDIA_TYPE = ( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" +) +SUPPORTED_RFC822_ARTIFACT_MEDIA_TYPE = "message/rfc822" +SUPPORTED_ARTIFACT_MEDIA_TYPES = ( + *SUPPORTED_TEXT_ARTIFACT_MEDIA_TYPES, + SUPPORTED_PDF_ARTIFACT_MEDIA_TYPE, + SUPPORTED_DOCX_ARTIFACT_MEDIA_TYPE, + SUPPORTED_RFC822_ARTIFACT_MEDIA_TYPE, +) +SUPPORTED_ARTIFACT_EXTENSIONS = { + ".txt": "text/plain", + ".text": "text/plain", + ".md": "text/markdown", + ".markdown": "text/markdown", + ".pdf": SUPPORTED_PDF_ARTIFACT_MEDIA_TYPE, + ".docx": SUPPORTED_DOCX_ARTIFACT_MEDIA_TYPE, + ".eml": SUPPORTED_RFC822_ARTIFACT_MEDIA_TYPE, +} +TASK_ARTIFACT_CHUNK_MAX_CHARS = 1000 +TASK_ARTIFACT_CHUNKING_RULE = "normalized_utf8_text_fixed_window_1000_chars_v1" +TASK_ARTIFACT_CHUNK_RETRIEVAL_MATCHING_RULE = ( + "casefolded_unicode_word_overlap_unique_query_terms_v1" +) +_LEXICAL_TERM_PATTERN = re.compile(r"\w+") +_PDF_INDIRECT_OBJECT_PATTERN = re.compile(rb"(?s)(\d+)\s+(\d+)\s+obj\b(.*?)\bendobj\b") +_PDF_REFERENCE_PATTERN = re.compile(rb"(\d+)\s+(\d+)\s+R") +_PDF_NUMERIC_TOKEN_PATTERN = re.compile(rb"[+-]?(?:\d+(?:\.\d+)?|\.\d+)") +_PDF_CONTENT_OPERATORS = { + b'"', + b"'", + b"*", + b"B", + b"BT", + b"BX", + b"B*", + b"BI", + b"BMC", + b"BDC", + b"b", + b"b*", + b"cm", + b"CS", + b"cs", + b"Do", + b"DP", + b"EI", + b"EMC", + b"ET", + b"EX", + b"f", + b"F", + b"f*", + b"G", + b"g", + b"gs", + b"h", + b"i", + b"ID", + b"j", + b"J", + b"K", + b"k", + b"l", + b"M", + b"m", + b"MP", + b"n", + b"q", + b"Q", + b"re", + b"RG", + b"rg", + b"ri", + b"s", + b"S", + b"SC", + b"sc", + b"SCN", + b"scn", + b"sh", + b"T*", + b"Tc", + b"Td", + b"TD", + b"Tf", + b"TJ", + b"Tj", + b"TL", + b"Tm", + b"Tr", + b"Ts", + b"Tw", + b"Tz", + b"v", + b"w", + b"W", + b"W*", + b"y", +} +_DOCX_DOCUMENT_XML_PATH = "word/document.xml" +_DOCX_WORDPROCESSING_NAMESPACE = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" +_DOCX_PARAGRAPH_TAG = f"{{{_DOCX_WORDPROCESSING_NAMESPACE}}}p" +_DOCX_TEXT_TAG = f"{{{_DOCX_WORDPROCESSING_NAMESPACE}}}t" +_DOCX_TAB_TAG = f"{{{_DOCX_WORDPROCESSING_NAMESPACE}}}tab" +_DOCX_BREAK_TAG = f"{{{_DOCX_WORDPROCESSING_NAMESPACE}}}br" +_DOCX_CARRIAGE_RETURN_TAG = f"{{{_DOCX_WORDPROCESSING_NAMESPACE}}}cr" +_DOCX_BODY_TAG = f"{{{_DOCX_WORDPROCESSING_NAMESPACE}}}body" +_RFC822_EMAIL_PARSE_POLICY = policy.default.clone(raise_on_defect=True) +_RFC822_EXTRACTED_HEADER_NAMES = ( + "From", + "To", + "Cc", + "Bcc", + "Reply-To", + "Subject", + "Date", + "Message-ID", +) + + +@dataclass(frozen=True, slots=True) +class _PdfObject: + object_id: int + generation: int + dictionary: bytes + stream: bytes | None + raw_content: bytes + + +class TaskArtifactNotFoundError(LookupError): + """Raised when a task artifact is not visible inside the current user scope.""" + + +class TaskArtifactAlreadyExistsError(RuntimeError): + """Raised when the same workspace-relative artifact path is registered twice.""" + + +class TaskArtifactValidationError(ValueError): + """Raised when a local artifact path cannot satisfy registration constraints.""" + + +class TaskArtifactChunkRetrievalValidationError(ValueError): + """Raised when an artifact chunk retrieval request cannot be evaluated safely.""" + + +def resolve_artifact_path(local_path: str) -> Path: + return Path(local_path).expanduser().resolve() + + +def ensure_artifact_path_is_rooted(*, workspace_path: Path, artifact_path: Path) -> None: + resolved_workspace_path = workspace_path.resolve() + resolved_artifact_path = artifact_path.resolve() + try: + resolved_artifact_path.relative_to(resolved_workspace_path) + except ValueError as exc: + raise TaskArtifactValidationError( + f"artifact path {resolved_artifact_path} escapes workspace root {resolved_workspace_path}" + ) from exc + + +def build_workspace_relative_artifact_path(*, workspace_path: Path, artifact_path: Path) -> str: + relative_path = artifact_path.relative_to(workspace_path).as_posix() + if relative_path in ("", "."): + raise TaskArtifactValidationError( + f"artifact path {artifact_path} must point to a file beneath workspace root {workspace_path}" + ) + return relative_path + + +def _require_existing_file(artifact_path: Path) -> None: + if not artifact_path.exists(): + raise TaskArtifactValidationError(f"artifact path {artifact_path} was not found") + if not artifact_path.is_file(): + raise TaskArtifactValidationError(f"artifact path {artifact_path} is not a regular file") + + +def _duplicate_registration_message(*, task_workspace_id: UUID, relative_path: str) -> str: + return ( + f"artifact {relative_path} is already registered for task workspace {task_workspace_id}" + ) + + +def serialize_task_artifact_row(row: TaskArtifactRow) -> TaskArtifactRecord: + return { + "id": str(row["id"]), + "task_id": str(row["task_id"]), + "task_workspace_id": str(row["task_workspace_id"]), + "status": cast(TaskArtifactStatus, row["status"]), + "ingestion_status": cast(TaskArtifactIngestionStatus, row["ingestion_status"]), + "relative_path": row["relative_path"], + "media_type_hint": row["media_type_hint"], + "created_at": row["created_at"].isoformat(), + "updated_at": row["updated_at"].isoformat(), + } + + +def serialize_task_artifact_chunk_row(row: TaskArtifactChunkRow) -> TaskArtifactChunkRecord: + return { + "id": str(row["id"]), + "task_artifact_id": str(row["task_artifact_id"]), + "sequence_no": row["sequence_no"], + "char_start": row["char_start"], + "char_end_exclusive": row["char_end_exclusive"], + "text": row["text"], + "created_at": row["created_at"].isoformat(), + "updated_at": row["updated_at"].isoformat(), + } + + +def infer_task_artifact_media_type(row: TaskArtifactRow) -> str | None: + if row["media_type_hint"] is not None: + return row["media_type_hint"] + + artifact_path = Path(row["relative_path"]) + return SUPPORTED_ARTIFACT_EXTENSIONS.get(artifact_path.suffix.lower()) + + +def resolve_supported_task_artifact_media_type(row: TaskArtifactRow) -> str: + media_type = infer_task_artifact_media_type(row) + if media_type in SUPPORTED_ARTIFACT_MEDIA_TYPES: + return cast(str, media_type) + + supported_types = ", ".join(SUPPORTED_ARTIFACT_MEDIA_TYPES) + raise TaskArtifactValidationError( + f"artifact {row['relative_path']} has unsupported media type " + f"{media_type or 'unknown'}; supported types: {supported_types}" + ) + + +def normalize_artifact_text(text: str) -> str: + return text.replace("\r\n", "\n").replace("\r", "\n") + + +def chunk_normalized_artifact_text( + text: str, + *, + chunk_size: int = TASK_ARTIFACT_CHUNK_MAX_CHARS, +) -> list[tuple[int, int, str]]: + chunks: list[tuple[int, int, str]] = [] + for char_start in range(0, len(text), chunk_size): + char_end_exclusive = min(char_start + chunk_size, len(text)) + chunks.append((char_start, char_end_exclusive, text[char_start:char_end_exclusive])) + return chunks + + +def _extract_text_from_utf8_artifact_bytes(*, relative_path: str, payload: bytes) -> str: + try: + return payload.decode("utf-8") + except UnicodeDecodeError as exc: + raise TaskArtifactValidationError( + f"artifact {relative_path} is not valid UTF-8 text" + ) from exc + + +def _extract_text_from_docx_paragraph(paragraph: ET.Element) -> str: + fragments: list[str] = [] + for element in paragraph.iter(): + if element.tag == _DOCX_TEXT_TAG: + fragments.append(element.text or "") + continue + if element.tag == _DOCX_TAB_TAG: + fragments.append("\t") + continue + if element.tag in {_DOCX_BREAK_TAG, _DOCX_CARRIAGE_RETURN_TAG}: + fragments.append("\n") + return "".join(fragments) + + +def _extract_text_from_docx_artifact_bytes(*, relative_path: str, payload: bytes) -> str: + try: + with zipfile.ZipFile(io.BytesIO(payload)) as archive: + document_xml = archive.read(_DOCX_DOCUMENT_XML_PATH) + except (KeyError, zipfile.BadZipFile) as exc: + raise TaskArtifactValidationError(f"artifact {relative_path} is not a valid DOCX") from exc + + try: + document_root = ET.fromstring(document_xml) + except ET.ParseError as exc: + raise TaskArtifactValidationError(f"artifact {relative_path} is not a valid DOCX") from exc + + document_body = document_root.find(_DOCX_BODY_TAG) + if document_body is None: + raise TaskArtifactValidationError(f"artifact {relative_path} is not a valid DOCX") + + paragraphs = [ + paragraph_text + for paragraph in document_body.iter(_DOCX_PARAGRAPH_TAG) + if (paragraph_text := _extract_text_from_docx_paragraph(paragraph)) != "" + ] + extracted_text = "\n".join(paragraphs).strip() + if extracted_text == "": + raise TaskArtifactValidationError( + f"artifact {relative_path} does not contain extractable DOCX text" + ) + return extracted_text + + +def _normalize_rfc822_header_value(value: str) -> str: + return re.sub(r"\s+", " ", value).strip() + + +def _parse_rfc822_email(*, relative_path: str, payload: bytes) -> EmailMessage: + try: + message = BytesParser(policy=_RFC822_EMAIL_PARSE_POLICY).parsebytes(payload) + except (MessageDefect, MessageError, ValueError, TypeError) as exc: + raise TaskArtifactValidationError( + f"artifact {relative_path} is not a valid RFC822 email" + ) from exc + return cast(EmailMessage, message) + + +def _extract_rfc822_header_lines(message: EmailMessage) -> list[str]: + header_lines: list[str] = [] + for header_name in _RFC822_EXTRACTED_HEADER_NAMES: + for header_value in message.get_all(header_name, failobj=[]): + normalized_value = _normalize_rfc822_header_value(str(header_value)) + if normalized_value != "": + header_lines.append(f"{header_name}: {normalized_value}") + return header_lines + + +def _is_extractable_rfc822_text_part(part: EmailMessage) -> bool: + if part.is_multipart(): + return False + if part.get_content_type() != "text/plain": + return False + if part.get_content_disposition() == "attachment": + return False + return part.get_filename() is None + + +def _extract_rfc822_part_text(*, relative_path: str, part: EmailMessage) -> str: + try: + payload = part.get_content() + except (MessageError, LookupError, UnicodeError, ValueError, TypeError) as exc: + raise TaskArtifactValidationError( + f"artifact {relative_path} is not a valid RFC822 email" + ) from exc + if not isinstance(payload, str): + raise TaskArtifactValidationError( + f"artifact {relative_path} is not a valid RFC822 email" + ) + return payload.strip() + + +def _iter_extractable_rfc822_text_parts(message: EmailMessage) -> list[EmailMessage]: + if _is_extractable_rfc822_text_part(message): + return [message] + if not message.is_multipart(): + return [] + + extractable_parts: list[EmailMessage] = [] + for child_part in message.iter_parts(): + child_email_part = cast(EmailMessage, child_part) + if child_email_part.get_content_maintype() == "message": + continue + extractable_parts.extend(_iter_extractable_rfc822_text_parts(child_email_part)) + return extractable_parts + + +def _extract_text_from_rfc822_artifact_bytes(*, relative_path: str, payload: bytes) -> str: + message = _parse_rfc822_email(relative_path=relative_path, payload=payload) + header_lines = _extract_rfc822_header_lines(message) + body_parts = [ + body_text + for part in _iter_extractable_rfc822_text_parts(message) + if (body_text := _extract_rfc822_part_text(relative_path=relative_path, part=part)) + != "" + ] + if not body_parts: + raise TaskArtifactValidationError( + f"artifact {relative_path} does not contain extractable RFC822 email text" + ) + + sections: list[str] = [] + if header_lines: + sections.append("\n".join(header_lines)) + sections.append("\n\n".join(body_parts)) + return "\n\n".join(sections) + + +def _extract_pdf_name(dictionary: bytes, key: bytes) -> bytes | None: + match = re.search(rb"/" + re.escape(key) + rb"\s*/([A-Za-z0-9_.#-]+)", dictionary) + if match is None: + return None + return match.group(1) + + +def _extract_pdf_reference(dictionary: bytes, key: bytes) -> tuple[int, int] | None: + match = re.search(rb"/" + re.escape(key) + rb"\s+(\d+)\s+(\d+)\s+R", dictionary) + if match is None: + return None + return int(match.group(1)), int(match.group(2)) + + +def _extract_pdf_reference_array(dictionary: bytes, key: bytes) -> list[tuple[int, int]]: + match = re.search(rb"/" + re.escape(key) + rb"\s*\[(.*?)\]", dictionary, re.DOTALL) + if match is None: + return [] + return [ + (int(ref_match.group(1)), int(ref_match.group(2))) + for ref_match in _PDF_REFERENCE_PATTERN.finditer(match.group(1)) + ] + + +def _extract_pdf_filter_names(dictionary: bytes) -> list[bytes]: + array_match = re.search(rb"/Filter\s*\[(.*?)\]", dictionary, re.DOTALL) + if array_match is not None: + return re.findall(rb"/([A-Za-z0-9_.#-]+)", array_match.group(1)) + + filter_name = _extract_pdf_name(dictionary, b"Filter") + if filter_name is None: + return [] + return [filter_name] + + +def _extract_pdf_stream_payload( + *, + relative_path: str, + dictionary: bytes, + body: bytes, + stream_start: int, +) -> bytes: + length_match = re.search(rb"/Length\s+(\d+)", dictionary) + if length_match is not None: + stream_length = int(length_match.group(1)) + stream_end = stream_start + stream_length + if stream_end <= len(body): + return body[stream_start:stream_end] + + stream_end = body.rfind(b"endstream") + if stream_end == -1 or stream_end < stream_start: + raise TaskArtifactValidationError( + f"artifact {relative_path} contains an unreadable PDF stream" + ) + + payload = body[stream_start:stream_end] + if payload.endswith(b"\r\n"): + return payload[:-2] + if payload.endswith((b"\n", b"\r")): + return payload[:-1] + return payload + + +def _parse_pdf_objects(*, relative_path: str, payload: bytes) -> dict[tuple[int, int], _PdfObject]: + objects: dict[tuple[int, int], _PdfObject] = {} + for match in _PDF_INDIRECT_OBJECT_PATTERN.finditer(payload): + object_id = int(match.group(1)) + generation = int(match.group(2)) + body = match.group(3).strip() + dictionary = body + stream: bytes | None = None + stream_index = body.find(b"stream") + if stream_index != -1: + dictionary = body[:stream_index].rstrip() + stream_start = stream_index + len(b"stream") + if body[stream_start : stream_start + 2] == b"\r\n": + stream_start += 2 + elif body[stream_start : stream_start + 1] in (b"\r", b"\n"): + stream_start += 1 + stream = _extract_pdf_stream_payload( + relative_path=relative_path, + dictionary=dictionary, + body=body, + stream_start=stream_start, + ) + objects[(object_id, generation)] = _PdfObject( + object_id=object_id, + generation=generation, + dictionary=dictionary, + stream=stream, + raw_content=body, + ) + + if not objects: + raise TaskArtifactValidationError(f"artifact {relative_path} is not a valid PDF") + return objects + + +def _read_pdf_literal_string(payload: bytes, start: int) -> tuple[bytes, int]: + cursor = start + 1 + depth = 1 + result = bytearray() + while cursor < len(payload): + current = payload[cursor] + if current == ord("\\"): + cursor += 1 + if cursor >= len(payload): + break + escaped = payload[cursor] + if escaped in b"nrtbf()\\": + result.extend( + { + ord("n"): b"\n", + ord("r"): b"\r", + ord("t"): b"\t", + ord("b"): b"\b", + ord("f"): b"\f", + ord("("): b"(", + ord(")"): b")", + ord("\\"): b"\\", + }[escaped] + ) + cursor += 1 + continue + if escaped in b"\r\n": + if escaped == ord("\r") and payload[cursor : cursor + 2] == b"\r\n": + cursor += 2 + else: + cursor += 1 + continue + if chr(escaped).isdigit(): + octal_digits = bytes([escaped]) + cursor += 1 + while cursor < len(payload) and len(octal_digits) < 3 and chr(payload[cursor]).isdigit(): + octal_digits += bytes([payload[cursor]]) + cursor += 1 + result.append(int(octal_digits, 8)) + continue + result.append(escaped) + cursor += 1 + continue + if current == ord("("): + depth += 1 + result.append(current) + cursor += 1 + continue + if current == ord(")"): + depth -= 1 + cursor += 1 + if depth == 0: + return bytes(result), cursor + result.append(current) + continue + result.append(current) + cursor += 1 + + raise TaskArtifactValidationError("PDF literal string terminated unexpectedly") + + +def _read_pdf_hex_string(payload: bytes, start: int) -> tuple[bytes, int]: + cursor = start + 1 + hex_digits = bytearray() + while cursor < len(payload): + current = payload[cursor] + if current == ord(">"): + cursor += 1 + break + if chr(current).isspace(): + cursor += 1 + continue + hex_digits.append(current) + cursor += 1 + + if len(hex_digits) % 2 == 1: + hex_digits.append(ord("0")) + return bytes.fromhex(hex_digits.decode("ascii")), cursor + + +def _skip_pdf_whitespace_and_comments(payload: bytes, start: int) -> int: + cursor = start + while cursor < len(payload): + current = payload[cursor] + if chr(current).isspace(): + cursor += 1 + continue + if current == ord("%"): + while cursor < len(payload) and payload[cursor] not in b"\r\n": + cursor += 1 + continue + break + return cursor + + +def _read_pdf_content_token(payload: bytes, start: int) -> tuple[object | None, int]: + cursor = _skip_pdf_whitespace_and_comments(payload, start) + if cursor >= len(payload): + return None, cursor + + current = payload[cursor] + if current == ord("("): + return _read_pdf_literal_string(payload, cursor) + if current == ord("<") and payload[cursor : cursor + 2] != b"<<": + return _read_pdf_hex_string(payload, cursor) + if current == ord("["): + items: list[object] = [] + cursor += 1 + while True: + cursor = _skip_pdf_whitespace_and_comments(payload, cursor) + if cursor >= len(payload): + raise TaskArtifactValidationError("PDF array terminated unexpectedly") + if payload[cursor] == ord("]"): + return items, cursor + 1 + item, cursor = _read_pdf_content_token(payload, cursor) + if item is None: + raise TaskArtifactValidationError("PDF array terminated unexpectedly") + items.append(item) + if current == ord("/"): + cursor += 1 + token_start = cursor + while cursor < len(payload) and not chr(payload[cursor]).isspace() and payload[cursor] not in b"()<>[]{}/%": + cursor += 1 + return payload[token_start - 1 : cursor], cursor + + token_start = cursor + while cursor < len(payload) and not chr(payload[cursor]).isspace() and payload[cursor] not in b"()<>[]{}/%": + cursor += 1 + return payload[token_start:cursor], cursor + + +def _decode_pdf_text_bytes(raw: bytes) -> str: + if raw.startswith(b"\xfe\xff"): + return raw[2:].decode("utf-16-be", errors="ignore") + if raw.startswith(b"\xff\xfe"): + return raw[2:].decode("utf-16-le", errors="ignore") + return raw.decode("latin-1", errors="ignore") + + +def _decode_pdf_text_operand(value: object | None) -> str: + if isinstance(value, bytes): + return _decode_pdf_text_bytes(value) + if isinstance(value, list): + return "".join( + _decode_pdf_text_bytes(item) for item in value if isinstance(item, bytes) + ) + return "" + + +def _pop_last_pdf_text_operand(operands: list[object]) -> object | None: + for index in range(len(operands) - 1, -1, -1): + candidate = operands[index] + if isinstance(candidate, (bytes, list)): + return operands.pop(index) + return None + + +def _extract_text_from_pdf_content_stream(stream: bytes) -> str: + operands: list[object] = [] + fragments: list[str] = [] + inside_text_block = False + pending_newline = False + cursor = 0 + + def request_newline() -> None: + nonlocal pending_newline + if fragments: + pending_newline = True + + def append_text(text: str) -> None: + nonlocal pending_newline + if text == "": + return + if pending_newline and fragments and fragments[-1] != "\n": + fragments.append("\n") + pending_newline = False + fragments.append(text) + + while True: + token, cursor = _read_pdf_content_token(stream, cursor) + if token is None: + break + if isinstance(token, list) or ( + isinstance(token, bytes) + and ( + token.startswith(b"/") + or _PDF_NUMERIC_TOKEN_PATTERN.fullmatch(token) is not None + or token in {b"true", b"false", b"null"} + ) + ): + operands.append(token) + continue + if not isinstance(token, bytes): + operands.append(token) + continue + + operator = token + if operator == b"BT": + inside_text_block = True + operands.clear() + continue + if operator == b"ET": + inside_text_block = False + operands.clear() + continue + if operator not in _PDF_CONTENT_OPERATORS: + operands.append(token) + continue + if not inside_text_block: + operands.clear() + continue + if operator in {b"T*", b"Td", b"TD", b"Tm"}: + request_newline() + operands.clear() + continue + if operator in {b"Tj", b"TJ"}: + append_text(_decode_pdf_text_operand(_pop_last_pdf_text_operand(operands))) + operands.clear() + continue + if operator in {b"'", b'"'}: + request_newline() + append_text(_decode_pdf_text_operand(_pop_last_pdf_text_operand(operands))) + operands.clear() + continue + operands.clear() + + return "".join(fragments).strip() + + +def _decode_pdf_stream(*, relative_path: str, pdf_object: _PdfObject) -> bytes: + if pdf_object.stream is None: + raise TaskArtifactValidationError( + f"artifact {relative_path} contains a PDF content reference without a stream" + ) + + filters = _extract_pdf_filter_names(pdf_object.dictionary) + if not filters: + return pdf_object.stream + if filters == [b"FlateDecode"]: + try: + return zlib.decompress(pdf_object.stream) + except zlib.error as exc: + raise TaskArtifactValidationError( + f"artifact {relative_path} contains an unreadable FlateDecode PDF stream" + ) from exc + + filter_names = ", ".join(f"/{name.decode('ascii', errors='ignore')}" for name in filters) + raise TaskArtifactValidationError( + f"artifact {relative_path} uses unsupported PDF stream filters {filter_names}" + ) + + +def _collect_pdf_page_refs( + *, + relative_path: str, + objects: dict[tuple[int, int], _PdfObject], + current_ref: tuple[int, int], + collected_refs: list[tuple[int, int]], + visited_refs: set[tuple[int, int]], +) -> None: + if current_ref in visited_refs: + return + visited_refs.add(current_ref) + current_object = objects.get(current_ref) + if current_object is None: + raise TaskArtifactValidationError( + f"artifact {relative_path} references a missing PDF object {current_ref[0]} {current_ref[1]} R" + ) + + object_type = _extract_pdf_name(current_object.dictionary, b"Type") + if object_type == b"Page": + collected_refs.append(current_ref) + return + if object_type != b"Pages": + raise TaskArtifactValidationError( + f"artifact {relative_path} uses unsupported PDF page tree structure" + ) + + child_refs = _extract_pdf_reference_array(current_object.dictionary, b"Kids") + if not child_refs: + raise TaskArtifactValidationError( + f"artifact {relative_path} uses unsupported PDF page tree structure" + ) + for child_ref in child_refs: + _collect_pdf_page_refs( + relative_path=relative_path, + objects=objects, + current_ref=child_ref, + collected_refs=collected_refs, + visited_refs=visited_refs, + ) + + +def _resolve_pdf_page_refs( + *, + relative_path: str, + objects: dict[tuple[int, int], _PdfObject], +) -> list[tuple[int, int]]: + catalog_ref = next( + ( + object_ref + for object_ref, pdf_object in objects.items() + if _extract_pdf_name(pdf_object.dictionary, b"Type") == b"Catalog" + ), + None, + ) + if catalog_ref is None: + raise TaskArtifactValidationError(f"artifact {relative_path} is not a valid PDF") + + pages_ref = _extract_pdf_reference(objects[catalog_ref].dictionary, b"Pages") + if pages_ref is None: + raise TaskArtifactValidationError( + f"artifact {relative_path} uses unsupported PDF page tree structure" + ) + + page_refs: list[tuple[int, int]] = [] + _collect_pdf_page_refs( + relative_path=relative_path, + objects=objects, + current_ref=pages_ref, + collected_refs=page_refs, + visited_refs=set(), + ) + return page_refs + + +def _extract_text_from_pdf_artifact_bytes(*, relative_path: str, payload: bytes) -> str: + if not payload.startswith(b"%PDF-"): + raise TaskArtifactValidationError(f"artifact {relative_path} is not a valid PDF") + + objects = _parse_pdf_objects(relative_path=relative_path, payload=payload) + page_refs = _resolve_pdf_page_refs(relative_path=relative_path, objects=objects) + page_fragments: list[str] = [] + for page_ref in page_refs: + page_object = objects[page_ref] + content_refs = _extract_pdf_reference_array(page_object.dictionary, b"Contents") + if not content_refs: + single_content_ref = _extract_pdf_reference(page_object.dictionary, b"Contents") + if single_content_ref is not None: + content_refs = [single_content_ref] + + stream_fragments: list[str] = [] + for content_ref in content_refs: + content_object = objects.get(content_ref) + if content_object is None: + raise TaskArtifactValidationError( + f"artifact {relative_path} references a missing PDF object {content_ref[0]} {content_ref[1]} R" + ) + extracted = _extract_text_from_pdf_content_stream( + _decode_pdf_stream(relative_path=relative_path, pdf_object=content_object) + ) + if extracted != "": + stream_fragments.append(extracted) + if stream_fragments: + page_fragments.append("\n".join(stream_fragments)) + + extracted_text = "\n".join(page_fragments).strip() + if extracted_text == "": + raise TaskArtifactValidationError( + f"artifact {relative_path} does not contain extractable PDF text" + ) + return extracted_text + + +def extract_artifact_text_from_bytes(*, relative_path: str, payload: bytes, media_type: str) -> str: + if media_type in SUPPORTED_TEXT_ARTIFACT_MEDIA_TYPES: + return _extract_text_from_utf8_artifact_bytes( + relative_path=relative_path, + payload=payload, + ) + if media_type == SUPPORTED_PDF_ARTIFACT_MEDIA_TYPE: + return _extract_text_from_pdf_artifact_bytes( + relative_path=relative_path, + payload=payload, + ) + if media_type == SUPPORTED_DOCX_ARTIFACT_MEDIA_TYPE: + return _extract_text_from_docx_artifact_bytes( + relative_path=relative_path, + payload=payload, + ) + if media_type == SUPPORTED_RFC822_ARTIFACT_MEDIA_TYPE: + return _extract_text_from_rfc822_artifact_bytes( + relative_path=relative_path, + payload=payload, + ) + raise TaskArtifactValidationError( + f"artifact {relative_path} has unsupported media type {media_type}" + ) + + +def extract_artifact_text(*, row: TaskArtifactRow, artifact_path: Path, media_type: str) -> str: + return extract_artifact_text_from_bytes( + relative_path=row["relative_path"], + payload=artifact_path.read_bytes(), + media_type=media_type, + ) + + +def resolve_registered_artifact_path(*, workspace_path: Path, relative_path: str) -> Path: + artifact_path = (workspace_path / relative_path).resolve() + ensure_artifact_path_is_rooted( + workspace_path=workspace_path, + artifact_path=artifact_path, + ) + return artifact_path + + +def build_task_artifact_chunk_list_summary( + chunk_rows: list[TaskArtifactChunkRow], + *, + media_type: str, +) -> TaskArtifactChunkListSummary: + total_characters = sum(len(row["text"]) for row in chunk_rows) + return { + "total_count": len(chunk_rows), + "total_characters": total_characters, + "media_type": media_type, + "chunking_rule": TASK_ARTIFACT_CHUNKING_RULE, + "order": list(TASK_ARTIFACT_CHUNK_LIST_ORDER), + } + + +def extract_unique_lexical_terms(text: str) -> list[str]: + terms: list[str] = [] + seen: set[str] = set() + for match in _LEXICAL_TERM_PATTERN.finditer(text.casefold()): + term = match.group(0) + if term in seen: + continue + seen.add(term) + terms.append(term) + return terms + + +def resolve_artifact_chunk_retrieval_query_terms(query: str) -> list[str]: + terms = extract_unique_lexical_terms(query) + if not terms: + raise TaskArtifactChunkRetrievalValidationError( + "artifact chunk retrieval query must include at least one word" + ) + return terms + + +def build_task_artifact_chunk_retrieval_scope( + *, + kind: str, + task_id: UUID, + task_artifact_id: UUID | None = None, +) -> TaskArtifactChunkRetrievalScope: + scope: TaskArtifactChunkRetrievalScope = { + "kind": cast(TaskArtifactChunkRetrievalScopeKind, kind), + "task_id": str(task_id), + } + if task_artifact_id is not None: + scope["task_artifact_id"] = str(task_artifact_id) + return scope + + +def build_task_artifact_chunk_retrieval_summary( + *, + total_count: int, + searched_artifact_count: int, + query: str, + query_terms: list[str], + scope: TaskArtifactChunkRetrievalScope, +) -> TaskArtifactChunkRetrievalSummary: + return { + "total_count": total_count, + "searched_artifact_count": searched_artifact_count, + "query": query, + "query_terms": list(query_terms), + "matching_rule": TASK_ARTIFACT_CHUNK_RETRIEVAL_MATCHING_RULE, + "order": list(TASK_ARTIFACT_CHUNK_RETRIEVAL_ORDER), + "scope": scope, + } + + +def match_artifact_chunk_text( + *, + query_terms: list[str], + chunk_text: str, +) -> TaskArtifactChunkRetrievalMatch | None: + first_positions: dict[str, int] = {} + for match in _LEXICAL_TERM_PATTERN.finditer(chunk_text.casefold()): + term = match.group(0) + if term not in first_positions: + first_positions[term] = match.start() + + matched_terms = [term for term in query_terms if term in first_positions] + if not matched_terms: + return None + + return { + "matched_query_terms": matched_terms, + "matched_query_term_count": len(matched_terms), + "first_match_char_start": min(first_positions[term] for term in matched_terms), + } + + +def serialize_task_artifact_chunk_retrieval_item( + *, + artifact_row: TaskArtifactRow, + chunk_row: TaskArtifactChunkRow, + match: TaskArtifactChunkRetrievalMatch, +) -> TaskArtifactChunkRetrievalItem: + return { + "id": str(chunk_row["id"]), + "task_id": str(artifact_row["task_id"]), + "task_artifact_id": str(chunk_row["task_artifact_id"]), + "relative_path": artifact_row["relative_path"], + "media_type": infer_task_artifact_media_type(artifact_row) or "unknown", + "sequence_no": chunk_row["sequence_no"], + "char_start": chunk_row["char_start"], + "char_end_exclusive": chunk_row["char_end_exclusive"], + "text": chunk_row["text"], + "match": match, + } + + +def retrieve_matching_task_artifact_chunks( + store: ContinuityStore, + *, + artifact_rows: list[TaskArtifactRow], + query_terms: list[str], +) -> tuple[list[TaskArtifactChunkRetrievalItem], int]: + matched_items_with_keys: list[ + tuple[tuple[int, int, str, int, str], TaskArtifactChunkRetrievalItem] + ] = [] + searched_artifact_count = 0 + + for artifact_row in artifact_rows: + if artifact_row["ingestion_status"] != "ingested": + continue + + searched_artifact_count += 1 + chunk_rows = store.list_task_artifact_chunks(artifact_row["id"]) + for chunk_row in chunk_rows: + match = match_artifact_chunk_text( + query_terms=query_terms, + chunk_text=chunk_row["text"], + ) + if match is None: + continue + + item = serialize_task_artifact_chunk_retrieval_item( + artifact_row=artifact_row, + chunk_row=chunk_row, + match=match, + ) + matched_items_with_keys.append( + ( + ( + -match["matched_query_term_count"], + match["first_match_char_start"], + artifact_row["relative_path"], + chunk_row["sequence_no"], + str(chunk_row["id"]), + ), + item, + ) + ) + + matched_items_with_keys.sort(key=lambda entry: entry[0]) + return [item for _, item in matched_items_with_keys], searched_artifact_count + + +def register_task_artifact_record( + store: ContinuityStore, + *, + user_id: UUID, + request: TaskArtifactRegisterInput, +) -> TaskArtifactCreateResponse: + del user_id + + workspace = store.get_task_workspace_optional(request.task_workspace_id) + if workspace is None: + raise TaskWorkspaceNotFoundError( + f"task workspace {request.task_workspace_id} was not found" + ) + + workspace_path = Path(workspace["local_path"]).expanduser().resolve() + artifact_path = resolve_artifact_path(request.local_path) + _require_existing_file(artifact_path) + ensure_artifact_path_is_rooted( + workspace_path=workspace_path, + artifact_path=artifact_path, + ) + relative_path = build_workspace_relative_artifact_path( + workspace_path=workspace_path, + artifact_path=artifact_path, + ) + + store.lock_task_artifacts(workspace["id"]) + existing = store.get_task_artifact_by_workspace_relative_path_optional( + task_workspace_id=workspace["id"], + relative_path=relative_path, + ) + if existing is not None: + raise TaskArtifactAlreadyExistsError( + _duplicate_registration_message( + task_workspace_id=workspace["id"], + relative_path=relative_path, + ) + ) + + try: + row = store.create_task_artifact( + task_id=workspace["task_id"], + task_workspace_id=workspace["id"], + status="registered", + ingestion_status="pending", + relative_path=relative_path, + media_type_hint=request.media_type_hint, + ) + except psycopg.errors.UniqueViolation as exc: + raise TaskArtifactAlreadyExistsError( + _duplicate_registration_message( + task_workspace_id=workspace["id"], + relative_path=relative_path, + ) + ) from exc + + return {"artifact": serialize_task_artifact_row(row)} + + +def list_task_artifact_records( + store: ContinuityStore, + *, + user_id: UUID, +) -> TaskArtifactListResponse: + del user_id + + items = [serialize_task_artifact_row(row) for row in store.list_task_artifacts()] + return { + "items": items, + "summary": { + "total_count": len(items), + "order": list(TASK_ARTIFACT_LIST_ORDER), + }, + } + + +def get_task_artifact_record( + store: ContinuityStore, + *, + user_id: UUID, + task_artifact_id: UUID, +) -> TaskArtifactDetailResponse: + del user_id + + row = store.get_task_artifact_optional(task_artifact_id) + if row is None: + raise TaskArtifactNotFoundError(f"task artifact {task_artifact_id} was not found") + return {"artifact": serialize_task_artifact_row(row)} + + +def ingest_task_artifact_record( + store: ContinuityStore, + *, + user_id: UUID, + request: TaskArtifactIngestInput, +) -> TaskArtifactIngestionResponse: + del user_id + + row = store.get_task_artifact_optional(request.task_artifact_id) + if row is None: + raise TaskArtifactNotFoundError(f"task artifact {request.task_artifact_id} was not found") + + store.lock_task_artifact_ingestion(row["id"]) + row = store.get_task_artifact_optional(request.task_artifact_id) + if row is None: + raise TaskArtifactNotFoundError(f"task artifact {request.task_artifact_id} was not found") + + media_type = resolve_supported_task_artifact_media_type(row) + chunk_rows = store.list_task_artifact_chunks(row["id"]) + if row["ingestion_status"] == "ingested": + return { + "artifact": serialize_task_artifact_row(row), + "summary": build_task_artifact_chunk_list_summary(chunk_rows, media_type=media_type), + } + + workspace = store.get_task_workspace_optional(row["task_workspace_id"]) + if workspace is None: + raise TaskWorkspaceNotFoundError( + f"task workspace {row['task_workspace_id']} was not found" + ) + + workspace_path = Path(workspace["local_path"]).expanduser().resolve() + artifact_path = resolve_registered_artifact_path( + workspace_path=workspace_path, + relative_path=row["relative_path"], + ) + _require_existing_file(artifact_path) + text = extract_artifact_text( + row=row, + artifact_path=artifact_path, + media_type=media_type, + ) + normalized_text = normalize_artifact_text(text) + for index, (char_start, char_end_exclusive, chunk_text) in enumerate( + chunk_normalized_artifact_text(normalized_text), + start=1, + ): + store.create_task_artifact_chunk( + task_artifact_id=row["id"], + sequence_no=index, + char_start=char_start, + char_end_exclusive=char_end_exclusive, + text=chunk_text, + ) + + artifact_row = store.update_task_artifact_ingestion_status( + task_artifact_id=row["id"], + ingestion_status="ingested", + ) + chunk_rows = store.list_task_artifact_chunks(row["id"]) + return { + "artifact": serialize_task_artifact_row(artifact_row), + "summary": build_task_artifact_chunk_list_summary(chunk_rows, media_type=media_type), + } + + +def list_task_artifact_chunk_records( + store: ContinuityStore, + *, + user_id: UUID, + task_artifact_id: UUID, +) -> TaskArtifactChunkListResponse: + del user_id + + row = store.get_task_artifact_optional(task_artifact_id) + if row is None: + raise TaskArtifactNotFoundError(f"task artifact {task_artifact_id} was not found") + + chunk_rows = store.list_task_artifact_chunks(task_artifact_id) + media_type = infer_task_artifact_media_type(row) or "unknown" + return { + "items": [serialize_task_artifact_chunk_row(chunk_row) for chunk_row in chunk_rows], + "summary": build_task_artifact_chunk_list_summary(chunk_rows, media_type=media_type), + } + + +def retrieve_task_scoped_artifact_chunk_records( + store: ContinuityStore, + *, + user_id: UUID, + request: TaskScopedArtifactChunkRetrievalInput, +) -> TaskArtifactChunkRetrievalResponse: + del user_id + + task = store.get_task_optional(request.task_id) + if task is None: + raise TaskNotFoundError(f"task {request.task_id} was not found") + + query_terms = resolve_artifact_chunk_retrieval_query_terms(request.query) + artifact_rows = store.list_task_artifacts_for_task(request.task_id) + items, searched_artifact_count = retrieve_matching_task_artifact_chunks( + store, + artifact_rows=artifact_rows, + query_terms=query_terms, + ) + scope = build_task_artifact_chunk_retrieval_scope( + kind="task", + task_id=request.task_id, + ) + return { + "items": items, + "summary": build_task_artifact_chunk_retrieval_summary( + total_count=len(items), + searched_artifact_count=searched_artifact_count, + query=request.query, + query_terms=query_terms, + scope=scope, + ), + } + + +def retrieve_artifact_scoped_artifact_chunk_records( + store: ContinuityStore, + *, + user_id: UUID, + request: ArtifactScopedArtifactChunkRetrievalInput, +) -> TaskArtifactChunkRetrievalResponse: + del user_id + + artifact_row = store.get_task_artifact_optional(request.task_artifact_id) + if artifact_row is None: + raise TaskArtifactNotFoundError(f"task artifact {request.task_artifact_id} was not found") + + query_terms = resolve_artifact_chunk_retrieval_query_terms(request.query) + items, searched_artifact_count = retrieve_matching_task_artifact_chunks( + store, + artifact_rows=[artifact_row], + query_terms=query_terms, + ) + scope = build_task_artifact_chunk_retrieval_scope( + kind="artifact", + task_id=artifact_row["task_id"], + task_artifact_id=artifact_row["id"], + ) + return { + "items": items, + "summary": build_task_artifact_chunk_retrieval_summary( + total_count=len(items), + searched_artifact_count=searched_artifact_count, + query=request.query, + query_terms=query_terms, + scope=scope, + ), + } diff --git a/apps/api/src/alicebot_api/calendar.py b/apps/api/src/alicebot_api/calendar.py new file mode 100644 index 0000000..bfc5ec9 --- /dev/null +++ b/apps/api/src/alicebot_api/calendar.py @@ -0,0 +1,669 @@ +from __future__ import annotations + +import json +import re +from datetime import UTC, date, datetime +from pathlib import Path +from urllib.error import HTTPError, URLError +from urllib.parse import quote, urlencode +from urllib.request import Request, urlopen +from uuid import UUID + +import psycopg + +from alicebot_api.artifacts import ( + SUPPORTED_TEXT_ARTIFACT_MEDIA_TYPES, + TaskArtifactAlreadyExistsError, + TaskArtifactValidationError, + ensure_artifact_path_is_rooted, + extract_artifact_text_from_bytes, + ingest_task_artifact_record, + register_task_artifact_record, +) +from alicebot_api.calendar_secret_manager import ( + CALENDAR_SECRET_MANAGER_KIND_FILE_V1, + CalendarSecretManager, + CalendarSecretManagerError, +) +from alicebot_api.contracts import ( + CALENDAR_ACCOUNT_LIST_ORDER, + CALENDAR_EVENT_LIST_ORDER, + CALENDAR_AUTH_KIND_OAUTH_ACCESS_TOKEN, + MAX_CALENDAR_EVENT_LIST_LIMIT, + CALENDAR_PROTECTED_CREDENTIAL_KIND, + CALENDAR_PROVIDER, + CALENDAR_READONLY_SCOPE, + CalendarAccountConnectInput, + CalendarAccountConnectResponse, + CalendarAccountDetailResponse, + CalendarAccountListResponse, + CalendarAccountRecord, + CalendarEventListInput, + CalendarEventListResponse, + CalendarEventSummaryRecord, + CalendarEventIngestInput, + CalendarEventIngestionResponse, + TaskArtifactIngestInput, + TaskArtifactRegisterInput, +) +from alicebot_api.store import ( + CalendarAccountRow, + ContinuityStore, + ContinuityStoreInvariantError, + JsonObject, +) +from alicebot_api.workspaces import TaskWorkspaceNotFoundError + +CALENDAR_EVENT_FETCH_TIMEOUT_SECONDS = 30 +CALENDAR_EVENT_ARTIFACT_ROOT = "calendar" +CALENDAR_EVENT_ARTIFACT_MEDIA_TYPE = SUPPORTED_TEXT_ARTIFACT_MEDIA_TYPES[0] +_PATH_SEGMENT_PATTERN = re.compile(r"[^A-Za-z0-9._-]+") + + +class CalendarAccountNotFoundError(LookupError): + """Raised when a Calendar account is not visible inside the current user scope.""" + + +class CalendarAccountAlreadyExistsError(RuntimeError): + """Raised when the same provider account is connected twice for one user.""" + + +class CalendarEventNotFoundError(LookupError): + """Raised when a Calendar event cannot be found in the current account.""" + + +class CalendarEventUnsupportedError(ValueError): + """Raised when Calendar content cannot be converted into the text artifact seam.""" + + +class CalendarEventFetchError(RuntimeError): + """Raised when the Calendar API call fails for non-deterministic upstream reasons.""" + + +class CalendarEventListValidationError(ValueError): + """Raised when Calendar event list query filters are invalid.""" + + +class CalendarCredentialNotFoundError(RuntimeError): + """Raised when Calendar protected credentials are missing for a visible account.""" + + +class CalendarCredentialInvalidError(RuntimeError): + """Raised when Calendar protected credentials are malformed for a visible account.""" + + +class CalendarCredentialPersistenceError(RuntimeError): + """Raised when Calendar protected credentials cannot be persisted.""" + + +class CalendarCredentialValidationError(ValueError): + """Raised when Calendar connect input contains an invalid credential payload.""" + + +def serialize_calendar_account_row(row: CalendarAccountRow) -> CalendarAccountRecord: + return { + "id": str(row["id"]), + "provider": CALENDAR_PROVIDER, + "auth_kind": CALENDAR_AUTH_KIND_OAUTH_ACCESS_TOKEN, + "provider_account_id": row["provider_account_id"], + "email_address": row["email_address"], + "display_name": row["display_name"], + "scope": row["scope"], + "created_at": row["created_at"].isoformat(), + "updated_at": row["updated_at"].isoformat(), + } + + +def _coerce_nonempty_string(value: object) -> str | None: + if not isinstance(value, str): + return None + normalized = value.strip() + if normalized == "": + return None + return normalized + + +def build_calendar_protected_credential_blob(*, access_token: str) -> dict[str, str]: + normalized_access_token = _coerce_nonempty_string(access_token) + if normalized_access_token is None: + raise CalendarCredentialValidationError("calendar access token must be non-empty") + return { + "credential_kind": CALENDAR_PROTECTED_CREDENTIAL_KIND, + "access_token": normalized_access_token, + } + + +def build_calendar_secret_ref(*, user_id: UUID, calendar_account_id: UUID) -> str: + return f"users/{user_id}/calendar-account-credentials/{calendar_account_id}.json" + + +def _parse_calendar_credential(*, calendar_account_id: UUID, credential_blob: object) -> str: + if not isinstance(credential_blob, dict): + raise CalendarCredentialInvalidError( + f"calendar account {calendar_account_id} has invalid protected credentials" + ) + credential_kind = credential_blob.get("credential_kind") + access_token = _coerce_nonempty_string(credential_blob.get("access_token")) + if credential_kind != CALENDAR_PROTECTED_CREDENTIAL_KIND or access_token is None: + raise CalendarCredentialInvalidError( + f"calendar account {calendar_account_id} has invalid protected credentials" + ) + return access_token + + +def _write_external_calendar_secret( + secret_manager: CalendarSecretManager, + *, + calendar_account_id: UUID, + secret_ref: str, + credential_blob: JsonObject, +) -> None: + try: + secret_manager.write_secret(secret_ref=secret_ref, payload=credential_blob) + except CalendarSecretManagerError as exc: + raise CalendarCredentialPersistenceError( + f"calendar account {calendar_account_id} protected credentials could not be persisted" + ) from exc + + +def resolve_calendar_access_token( + store: ContinuityStore, + secret_manager: CalendarSecretManager, + *, + calendar_account_id: UUID, +) -> str: + credential = store.get_calendar_account_credential_optional(calendar_account_id) + if credential is None: + raise CalendarCredentialNotFoundError( + f"calendar account {calendar_account_id} is missing protected credentials" + ) + if credential["auth_kind"] != CALENDAR_AUTH_KIND_OAUTH_ACCESS_TOKEN: + raise CalendarCredentialInvalidError( + f"calendar account {calendar_account_id} has invalid protected credentials" + ) + if credential["secret_manager_kind"] != CALENDAR_SECRET_MANAGER_KIND_FILE_V1: + raise CalendarCredentialInvalidError( + f"calendar account {calendar_account_id} has invalid protected credentials" + ) + secret_ref = _coerce_nonempty_string(credential["secret_ref"]) + if secret_ref is None: + raise CalendarCredentialInvalidError( + f"calendar account {calendar_account_id} has invalid protected credentials" + ) + + try: + payload = secret_manager.load_secret(secret_ref=secret_ref) + except CalendarSecretManagerError as exc: + message = str(exc) + if message.endswith("was not found"): + raise CalendarCredentialNotFoundError( + f"calendar account {calendar_account_id} is missing protected credentials" + ) from exc + raise CalendarCredentialInvalidError( + f"calendar account {calendar_account_id} has invalid protected credentials" + ) from exc + return _parse_calendar_credential( + calendar_account_id=calendar_account_id, + credential_blob=payload, + ) + + +def create_calendar_account_record( + store: ContinuityStore, + secret_manager: CalendarSecretManager, + *, + user_id: UUID, + request: CalendarAccountConnectInput, +) -> CalendarAccountConnectResponse: + del user_id + + existing = store.get_calendar_account_by_provider_account_id_optional(request.provider_account_id) + if existing is not None: + raise CalendarAccountAlreadyExistsError( + f"calendar account {request.provider_account_id} is already connected" + ) + + row: CalendarAccountRow | None = None + secret_ref: str | None = None + try: + row = store.create_calendar_account( + provider_account_id=request.provider_account_id, + email_address=request.email_address, + display_name=request.display_name, + scope=request.scope, + ) + credential_blob = build_calendar_protected_credential_blob( + access_token=request.access_token, + ) + secret_ref = build_calendar_secret_ref( + user_id=row["user_id"], + calendar_account_id=row["id"], + ) + _write_external_calendar_secret( + secret_manager, + calendar_account_id=row["id"], + secret_ref=secret_ref, + credential_blob=credential_blob, + ) + store.create_calendar_account_credential( + calendar_account_id=row["id"], + auth_kind=CALENDAR_AUTH_KIND_OAUTH_ACCESS_TOKEN, + credential_kind=credential_blob["credential_kind"], + secret_manager_kind=secret_manager.kind, + secret_ref=secret_ref, + credential_blob=None, + ) + except psycopg.errors.UniqueViolation as exc: + raise CalendarAccountAlreadyExistsError( + f"calendar account {request.provider_account_id} is already connected" + ) from exc + except CalendarCredentialPersistenceError: + raise + except (ContinuityStoreInvariantError, psycopg.Error) as exc: + if secret_ref is not None: + try: + secret_manager.delete_secret(secret_ref=secret_ref) + except CalendarSecretManagerError: + pass + raise CalendarCredentialPersistenceError( + "calendar protected credentials could not be persisted" + ) from exc + + return {"account": serialize_calendar_account_row(row)} + + +def list_calendar_account_records( + store: ContinuityStore, + *, + user_id: UUID, +) -> CalendarAccountListResponse: + del user_id + + items = [serialize_calendar_account_row(row) for row in store.list_calendar_accounts()] + return { + "items": items, + "summary": { + "total_count": len(items), + "order": list(CALENDAR_ACCOUNT_LIST_ORDER), + }, + } + + +def get_calendar_account_record( + store: ContinuityStore, + *, + user_id: UUID, + calendar_account_id: UUID, +) -> CalendarAccountDetailResponse: + del user_id + + row = store.get_calendar_account_optional(calendar_account_id) + if row is None: + raise CalendarAccountNotFoundError(f"calendar account {calendar_account_id} was not found") + return {"account": serialize_calendar_account_row(row)} + + +def _extract_optional_calendar_event_time(value: object) -> str | None: + if not isinstance(value, dict): + return None + date_time = _coerce_nonempty_string(value.get("dateTime")) + if date_time is not None: + return date_time + return _coerce_nonempty_string(value.get("date")) + + +def _serialize_calendar_event_summary(payload: object) -> CalendarEventSummaryRecord | None: + if not isinstance(payload, dict): + return None + provider_event_id = _coerce_nonempty_string(payload.get("id")) + if provider_event_id is None: + return None + return { + "provider_event_id": provider_event_id, + "status": _coerce_nonempty_string(payload.get("status")), + "summary": _coerce_nonempty_string(payload.get("summary")), + "start_time": _extract_optional_calendar_event_time(payload.get("start")), + "end_time": _extract_optional_calendar_event_time(payload.get("end")), + "html_link": _coerce_nonempty_string(payload.get("htmlLink")), + "updated_at": _coerce_nonempty_string(payload.get("updated")), + } + + +def _normalize_start_time_for_sort(start_time: str | None) -> str | None: + if start_time is None: + return None + + # All-day events use date-only values; normalize to midnight UTC. + if len(start_time) == 10: + try: + parsed_date = date.fromisoformat(start_time) + except ValueError: + return None + return datetime( + parsed_date.year, + parsed_date.month, + parsed_date.day, + tzinfo=UTC, + ).isoformat() + + normalized = start_time.replace("Z", "+00:00") + try: + parsed_datetime = datetime.fromisoformat(normalized) + except ValueError: + return None + if parsed_datetime.tzinfo is None: + parsed_datetime = parsed_datetime.replace(tzinfo=UTC) + return parsed_datetime.astimezone(UTC).isoformat() + + +def _calendar_event_sort_key(record: CalendarEventSummaryRecord) -> tuple[str, str]: + start_time = record["start_time"] + return ( + _normalize_start_time_for_sort(start_time) or "~", + record["provider_event_id"], + ) + + +def fetch_calendar_event_list_payload( + *, + access_token: str, + limit: int = MAX_CALENDAR_EVENT_LIST_LIMIT, + time_min: datetime | None = None, + time_max: datetime | None = None, +) -> list[JsonObject]: + query_params: dict[str, str] = { + "maxResults": str(limit), + "showDeleted": "false", + "singleEvents": "false", + } + if time_min is not None: + query_params["timeMin"] = time_min.isoformat() + if time_max is not None: + query_params["timeMax"] = time_max.isoformat() + query_string = urlencode(query_params) + request = Request( + f"https://www.googleapis.com/calendar/v3/calendars/primary/events?{query_string}", + headers={ + "Authorization": f"Bearer {access_token}", + "Accept": "application/json", + }, + method="GET", + ) + + try: + with urlopen(request, timeout=CALENDAR_EVENT_FETCH_TIMEOUT_SECONDS) as response: + payload = json.loads(response.read().decode("utf-8")) + except HTTPError as exc: + raise CalendarEventFetchError("calendar events could not be fetched") from exc + except (OSError, URLError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise CalendarEventFetchError("calendar events could not be fetched") from exc + + if not isinstance(payload, dict): + raise CalendarEventFetchError("calendar events could not be fetched") + raw_items = payload.get("items") + if not isinstance(raw_items, list): + raise CalendarEventFetchError("calendar events could not be fetched") + + items: list[JsonObject] = [] + for item in raw_items: + if isinstance(item, dict): + items.append(item) + return items + + +def list_calendar_event_records( + store: ContinuityStore, + secret_manager: CalendarSecretManager, + *, + user_id: UUID, + request: CalendarEventListInput, +) -> CalendarEventListResponse: + del user_id + + if request.time_min is not None and request.time_max is not None and request.time_min > request.time_max: + raise CalendarEventListValidationError("calendar event time_min must be less than or equal to time_max") + + account = store.get_calendar_account_optional(request.calendar_account_id) + if account is None: + raise CalendarAccountNotFoundError(f"calendar account {request.calendar_account_id} was not found") + + bounded_limit = max(1, min(request.limit, MAX_CALENDAR_EVENT_LIST_LIMIT)) + access_token = resolve_calendar_access_token( + store, + secret_manager, + calendar_account_id=request.calendar_account_id, + ) + raw_items = fetch_calendar_event_list_payload( + access_token=access_token, + limit=MAX_CALENDAR_EVENT_LIST_LIMIT, + time_min=request.time_min, + time_max=request.time_max, + ) + records = [ + record + for record in (_serialize_calendar_event_summary(item) for item in raw_items) + if record is not None + ] + items = sorted(records, key=_calendar_event_sort_key)[:bounded_limit] + return { + "account": serialize_calendar_account_row(account), + "items": items, + "summary": { + "total_count": len(items), + "limit": bounded_limit, + "order": list(CALENDAR_EVENT_LIST_ORDER), + "time_min": None if request.time_min is None else request.time_min.isoformat(), + "time_max": None if request.time_max is None else request.time_max.isoformat(), + }, + } + + +def _sanitize_path_segment(value: str) -> str: + sanitized = _PATH_SEGMENT_PATTERN.sub("_", value.strip()) + return sanitized.strip("._") or "event" + + +def build_calendar_event_artifact_relative_path( + *, + provider_account_id: str, + provider_event_id: str, +) -> str: + return ( + f"{CALENDAR_EVENT_ARTIFACT_ROOT}/" + f"{_sanitize_path_segment(provider_account_id)}/" + f"{_sanitize_path_segment(provider_event_id)}.txt" + ) + + +def fetch_calendar_event_payload(*, access_token: str, provider_event_id: str) -> JsonObject: + request = Request( + ( + "https://www.googleapis.com/calendar/v3/calendars/primary/events/" + f"{quote(provider_event_id, safe='')}" + ), + headers={ + "Authorization": f"Bearer {access_token}", + "Accept": "application/json", + }, + method="GET", + ) + + try: + with urlopen(request, timeout=CALENDAR_EVENT_FETCH_TIMEOUT_SECONDS) as response: + payload = json.loads(response.read().decode("utf-8")) + except HTTPError as exc: + if exc.code == 404: + raise CalendarEventNotFoundError( + f"calendar event {provider_event_id} was not found" + ) from exc + raise CalendarEventFetchError( + f"calendar event {provider_event_id} could not be fetched" + ) from exc + except (OSError, URLError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise CalendarEventFetchError( + f"calendar event {provider_event_id} could not be fetched" + ) from exc + + if not isinstance(payload, dict): + raise CalendarEventUnsupportedError( + f"calendar event {provider_event_id} is not supported for ingestion" + ) + return payload + + +def _extract_calendar_event_time( + *, + provider_event_id: str, + payload: JsonObject, + key: str, +) -> str: + field = payload.get(key) + if not isinstance(field, dict): + raise CalendarEventUnsupportedError( + f"calendar event {provider_event_id} is not supported for ingestion" + ) + date_time = _coerce_nonempty_string(field.get("dateTime")) + if date_time is not None: + return date_time + date = _coerce_nonempty_string(field.get("date")) + if date is not None: + return date + raise CalendarEventUnsupportedError( + f"calendar event {provider_event_id} is not supported for ingestion" + ) + + +def _optional_event_text(value: object) -> str: + normalized = _coerce_nonempty_string(value) + if normalized is None: + return "(none)" + return normalized.replace("\r\n", "\n").replace("\r", "\n") + + +def build_calendar_event_artifact_text(*, provider_event_id: str, payload: JsonObject) -> str: + source_event_id = _coerce_nonempty_string(payload.get("id")) + if source_event_id is None: + raise CalendarEventUnsupportedError( + f"calendar event {provider_event_id} is not supported for ingestion" + ) + start_value = _extract_calendar_event_time( + provider_event_id=provider_event_id, + payload=payload, + key="start", + ) + end_value = _extract_calendar_event_time( + provider_event_id=provider_event_id, + payload=payload, + key="end", + ) + organizer_email = None + organizer = payload.get("organizer") + if isinstance(organizer, dict): + organizer_email = _coerce_nonempty_string(organizer.get("email")) + + lines = [ + f"Provider: {CALENDAR_PROVIDER}", + f"Requested Event ID: {provider_event_id}", + f"Source Event ID: {source_event_id}", + f"Status: {_optional_event_text(payload.get('status'))}", + f"Summary: {_optional_event_text(payload.get('summary'))}", + f"Location: {_optional_event_text(payload.get('location'))}", + f"Start: {start_value}", + f"End: {end_value}", + f"Organizer Email: {_optional_event_text(organizer_email)}", + f"HTML Link: {_optional_event_text(payload.get('htmlLink'))}", + "Description:", + _optional_event_text(payload.get("description")), + ] + return "\n".join(lines).strip() + + +def ingest_calendar_event_record( + store: ContinuityStore, + secret_manager: CalendarSecretManager, + *, + user_id: UUID, + request: CalendarEventIngestInput, +) -> CalendarEventIngestionResponse: + account = store.get_calendar_account_optional(request.calendar_account_id) + if account is None: + raise CalendarAccountNotFoundError(f"calendar account {request.calendar_account_id} was not found") + + workspace = store.get_task_workspace_optional(request.task_workspace_id) + if workspace is None: + raise TaskWorkspaceNotFoundError( + f"task workspace {request.task_workspace_id} was not found" + ) + + access_token = resolve_calendar_access_token( + store, + secret_manager, + calendar_account_id=request.calendar_account_id, + ) + store.lock_task_artifacts(workspace["id"]) + + relative_path = build_calendar_event_artifact_relative_path( + provider_account_id=account["provider_account_id"], + provider_event_id=request.provider_event_id, + ) + existing_artifact = store.get_task_artifact_by_workspace_relative_path_optional( + task_workspace_id=request.task_workspace_id, + relative_path=relative_path, + ) + if existing_artifact is not None: + raise TaskArtifactAlreadyExistsError( + f"artifact {relative_path} is already registered for task workspace {request.task_workspace_id}" + ) + + event_payload = fetch_calendar_event_payload( + access_token=access_token, + provider_event_id=request.provider_event_id, + ) + artifact_text = build_calendar_event_artifact_text( + provider_event_id=request.provider_event_id, + payload=event_payload, + ) + artifact_bytes = artifact_text.encode("utf-8") + extract_artifact_text_from_bytes( + relative_path=relative_path, + payload=artifact_bytes, + media_type=CALENDAR_EVENT_ARTIFACT_MEDIA_TYPE, + ) + + workspace_path = Path(workspace["local_path"]).expanduser().resolve() + artifact_path = (workspace_path / relative_path).resolve() + ensure_artifact_path_is_rooted( + workspace_path=workspace_path, + artifact_path=artifact_path, + ) + artifact_path.parent.mkdir(parents=True, exist_ok=True) + if artifact_path.exists(): + raise TaskArtifactValidationError( + f"artifact path {artifact_path} already exists before Calendar ingestion registration" + ) + artifact_path.write_bytes(artifact_bytes) + + artifact_payload = register_task_artifact_record( + store, + user_id=user_id, + request=TaskArtifactRegisterInput( + task_workspace_id=request.task_workspace_id, + local_path=str(artifact_path), + media_type_hint=CALENDAR_EVENT_ARTIFACT_MEDIA_TYPE, + ), + ) + ingestion_payload = ingest_task_artifact_record( + store, + user_id=user_id, + request=TaskArtifactIngestInput(task_artifact_id=UUID(artifact_payload["artifact"]["id"])), + ) + return { + "account": serialize_calendar_account_row(account), + "event": { + "provider_event_id": request.provider_event_id, + "artifact_relative_path": ingestion_payload["artifact"]["relative_path"], + "media_type": CALENDAR_EVENT_ARTIFACT_MEDIA_TYPE, + }, + "artifact": ingestion_payload["artifact"], + "summary": ingestion_payload["summary"], + } diff --git a/apps/api/src/alicebot_api/calendar_secret_manager.py b/apps/api/src/alicebot_api/calendar_secret_manager.py new file mode 100644 index 0000000..129d0b7 --- /dev/null +++ b/apps/api/src/alicebot_api/calendar_secret_manager.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +from urllib.parse import unquote, urlparse + +from alicebot_api.store import JsonObject + +CALENDAR_SECRET_MANAGER_KIND_FILE_V1 = "file_v1" +SECRET_DIRECTORY_MODE = 0o700 +SECRET_FILE_MODE = 0o600 + + +class CalendarSecretManagerError(RuntimeError): + """Raised when the configured Calendar secret manager cannot service a request.""" + + +class CalendarSecretManager: + kind: str + + def load_secret(self, *, secret_ref: str) -> JsonObject: + raise NotImplementedError + + def write_secret(self, *, secret_ref: str, payload: JsonObject) -> None: + raise NotImplementedError + + def delete_secret(self, *, secret_ref: str) -> None: + raise NotImplementedError + + +class FileCalendarSecretManager(CalendarSecretManager): + kind = CALENDAR_SECRET_MANAGER_KIND_FILE_V1 + + def __init__(self, *, root: Path) -> None: + self._root = root.expanduser().resolve() + try: + self._root.mkdir(parents=True, exist_ok=True, mode=SECRET_DIRECTORY_MODE) + self._root.chmod(SECRET_DIRECTORY_MODE) + except OSError as exc: + raise CalendarSecretManagerError("calendar secret manager root is not writable") from exc + + def load_secret(self, *, secret_ref: str) -> JsonObject: + path = self._resolve_secret_path(secret_ref) + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError as exc: + raise CalendarSecretManagerError(f"calendar secret {secret_ref} was not found") from exc + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise CalendarSecretManagerError(f"calendar secret {secret_ref} could not be loaded") from exc + if not isinstance(payload, dict): + raise CalendarSecretManagerError(f"calendar secret {secret_ref} could not be loaded") + return payload + + def write_secret(self, *, secret_ref: str, payload: JsonObject) -> None: + path = self._resolve_secret_path(secret_ref) + self._ensure_private_directory(path.parent) + temp_path = path.with_name(f".{path.name}.{os.getpid()}.tmp") + try: + with os.fdopen( + os.open(temp_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, SECRET_FILE_MODE), + "w", + encoding="utf-8", + ) as secret_file: + secret_file.write(json.dumps(payload, sort_keys=True)) + temp_path.chmod(SECRET_FILE_MODE) + temp_path.replace(path) + path.chmod(SECRET_FILE_MODE) + except OSError as exc: + try: + temp_path.unlink(missing_ok=True) + except OSError: + pass + raise CalendarSecretManagerError(f"calendar secret {secret_ref} could not be written") from exc + + def delete_secret(self, *, secret_ref: str) -> None: + path = self._resolve_secret_path(secret_ref) + try: + path.unlink(missing_ok=True) + except OSError as exc: + raise CalendarSecretManagerError(f"calendar secret {secret_ref} could not be deleted") from exc + + def _resolve_secret_path(self, secret_ref: str) -> Path: + candidate = (self._root / secret_ref).resolve() + try: + candidate.relative_to(self._root) + except ValueError as exc: + raise CalendarSecretManagerError( + f"calendar secret {secret_ref} is outside the configured root" + ) from exc + return candidate + + def _ensure_private_directory(self, directory: Path) -> None: + try: + directory.mkdir(parents=True, exist_ok=True, mode=SECRET_DIRECTORY_MODE) + directory.chmod(SECRET_DIRECTORY_MODE) + except OSError as exc: + raise CalendarSecretManagerError( + "calendar secret directory permissions could not be secured" + ) from exc + + +def build_calendar_secret_manager(secret_manager_url: str) -> CalendarSecretManager: + if secret_manager_url.strip() == "": + raise ValueError("CALENDAR_SECRET_MANAGER_URL must be configured") + + parsed = urlparse(secret_manager_url) + if parsed.scheme != "file": + raise ValueError("CALENDAR_SECRET_MANAGER_URL must use the file:// scheme") + + root_path = Path(unquote(parsed.path or "/")) + if parsed.netloc not in ("", "localhost"): + root_path = Path(f"/{parsed.netloc}{root_path.as_posix()}") + + return FileCalendarSecretManager(root=root_path) diff --git a/apps/api/src/alicebot_api/chatgpt_import.py b/apps/api/src/alicebot_api/chatgpt_import.py new file mode 100644 index 0000000..42664df --- /dev/null +++ b/apps/api/src/alicebot_api/chatgpt_import.py @@ -0,0 +1,401 @@ +from __future__ import annotations + +import json +from pathlib import Path +from uuid import UUID + +from alicebot_api.importer_models import ( + ImporterNormalizedBatch, + ImporterNormalizedItem, + ImporterValidationError, + ImporterWorkspaceContext, + OBJECT_TYPE_TO_BODY_KEY, + OBJECT_TYPE_TO_PREFIX, + as_json_object, + dedupe_key_for_payload, + merge_json_objects, + normalize_object_type, + normalize_optional_text, + parse_optional_confidence, + parse_optional_status, +) +from alicebot_api.importers.common import ImportPersistenceConfig, import_normalized_batch +from alicebot_api.store import ContinuityStore, JsonObject + + +_DEFAULT_CONFIDENCE = 0.83 +_DEFAULT_DEDUPE_POSTURE = "workspace_conversation_message_fingerprint" +_PREFIX_TO_OBJECT_TYPE: tuple[tuple[str, str], ...] = ( + ("decision:", "Decision"), + ("next action:", "NextAction"), + ("next:", "NextAction"), + ("task:", "NextAction"), + ("commitment:", "Commitment"), + ("waiting for:", "WaitingFor"), + ("blocker:", "Blocker"), + ("fact:", "MemoryFact"), + ("remember:", "MemoryFact"), + ("note:", "Note"), +) + + +class ChatGPTImportValidationError(ImporterValidationError): + """Raised when a ChatGPT import payload is invalid.""" + + +def _truncate(value: str, *, max_length: int) -> str: + if len(value) <= max_length: + return value + return value[: max_length - 3].rstrip() + "..." + + +def _build_title(*, object_type: str, text: str, explicit_title: str | None) -> str: + if explicit_title is not None: + return _truncate(explicit_title, max_length=280) + prefix = OBJECT_TYPE_TO_PREFIX[object_type] + return _truncate(f"{prefix}: {text}", max_length=280) + + +def _build_raw_content(*, object_type: str, text: str) -> str: + prefix = OBJECT_TYPE_TO_PREFIX[object_type] + return f"{prefix}: {text}" + + +def _read_json(path: Path) -> object: + try: + return json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise ChatGPTImportValidationError( + f"invalid JSON at {path}: {exc.msg}" + ) from exc + + +def _normalize_message_text(value: object) -> str | None: + if isinstance(value, str): + return normalize_optional_text(value) + + if isinstance(value, list): + parts: list[str] = [] + for item in value: + normalized = _normalize_message_text(item) + if normalized is None: + continue + parts.append(normalized) + if not parts: + return None + return normalize_optional_text(" ".join(parts)) + + if isinstance(value, dict): + content = as_json_object(value) + for key in ("text", "content", "message"): + normalized = _normalize_message_text(content.get(key)) + if normalized is not None: + return normalized + normalized = _normalize_message_text(content.get("parts")) + if normalized is not None: + return normalized + return None + + return None + + +def _resolve_object_type_and_text(*, text: str, type_hint: object) -> tuple[str, str]: + hinted_type = normalize_object_type(type_hint) if type_hint is not None else None + if hinted_type is not None and hinted_type != "Note": + return hinted_type, text + + lowered = text.casefold() + for prefix, object_type in _PREFIX_TO_OBJECT_TYPE: + if not lowered.startswith(prefix): + continue + stripped = normalize_optional_text(text[len(prefix) :]) + if stripped is None: + raise ChatGPTImportValidationError("ChatGPT message content must not be empty") + return object_type, stripped + + if hinted_type is not None: + return hinted_type, text + + return "Note", text + + +def _extract_messages_from_simple_list(messages: object) -> list[JsonObject]: + if not isinstance(messages, list): + return [] + + output: list[JsonObject] = [] + for message in messages: + if not isinstance(message, dict): + continue + output.append(as_json_object(message)) + return output + + +def _extract_messages_from_mapping(mapping: object) -> list[JsonObject]: + if not isinstance(mapping, dict): + return [] + + nodes: list[tuple[float, str, JsonObject]] = [] + for key, raw_node in mapping.items(): + if not isinstance(raw_node, dict): + continue + node = as_json_object(raw_node) + message = as_json_object(node.get("message")) + if not message: + continue + + raw_created_at = message.get("create_time", node.get("create_time")) + created_at = 0.0 + if isinstance(raw_created_at, (int, float)): + created_at = float(raw_created_at) + elif isinstance(raw_created_at, str): + try: + created_at = float(raw_created_at.strip()) + except ValueError: + created_at = 0.0 + + message_id = normalize_optional_text(message.get("id")) or normalize_optional_text(key) or "" + nodes.append((created_at, message_id, message)) + + nodes.sort(key=lambda item: (item[0], item[1])) + return [node[2] for node in nodes] + + +def _extract_conversations(payload: object) -> list[JsonObject]: + if isinstance(payload, list): + return [as_json_object(item) for item in payload if isinstance(item, dict)] + + if not isinstance(payload, dict): + raise ChatGPTImportValidationError("ChatGPT source root must be a JSON object or array") + + payload_object = as_json_object(payload) + for key in ("conversations", "items", "records"): + raw_conversations = payload_object.get(key) + if raw_conversations is None: + continue + if not isinstance(raw_conversations, list): + raise ChatGPTImportValidationError(f"{key} must be a JSON array") + return [as_json_object(item) for item in raw_conversations if isinstance(item, dict)] + + if payload_object.get("mapping") is not None or payload_object.get("messages") is not None: + return [payload_object] + + raise ChatGPTImportValidationError( + "ChatGPT payload must include one of: conversations, items, records, mapping, or messages" + ) + + +def _extract_workspace_metadata(payload: object) -> tuple[str | None, str | None, str | None]: + if not isinstance(payload, dict): + return None, None, None + + payload_object = as_json_object(payload) + fixture_id = normalize_optional_text(payload_object.get("fixture_id")) + workspace_payload = as_json_object(payload_object.get("workspace")) + + workspace_id = normalize_optional_text( + workspace_payload.get("id") + ) + workspace_name = normalize_optional_text( + workspace_payload.get("name") + ) + + return fixture_id, workspace_id, workspace_name + + +def _conversation_messages(conversation: JsonObject) -> list[JsonObject]: + messages = _extract_messages_from_simple_list(conversation.get("messages")) + if messages: + return messages + return _extract_messages_from_mapping(conversation.get("mapping")) + + +def _message_role(message: JsonObject) -> str | None: + author = as_json_object(message.get("author")) + role = normalize_optional_text(author.get("role")) + if role is not None: + return role.casefold() + direct_role = normalize_optional_text(message.get("role")) + if direct_role is None: + return None + return direct_role.casefold() + + +def _message_text(message: JsonObject) -> str | None: + content_payload = message.get("content") + if isinstance(content_payload, dict): + content = as_json_object(content_payload) + parts = content.get("parts") + normalized = _normalize_message_text(parts) + if normalized is not None: + return normalized + normalized = _normalize_message_text(content.get("text")) + if normalized is not None: + return normalized + + for key in ("text", "message", "content"): + normalized = _normalize_message_text(message.get(key)) + if normalized is not None: + return normalized + + return None + + +def load_chatgpt_payload(source: str | Path) -> ImporterNormalizedBatch: + source_path = Path(source).expanduser().resolve() + if not source_path.exists(): + raise ChatGPTImportValidationError(f"ChatGPT source path does not exist: {source_path}") + + source_files = [source_path] if source_path.is_file() else sorted(source_path.rglob("*.json")) + if not source_files: + raise ChatGPTImportValidationError("no ChatGPT JSON files were found at the source path") + + fixture_id: str | None = None + workspace_id: str | None = None + workspace_name: str | None = None + + items: list[ImporterNormalizedItem] = [] + + for source_file in source_files: + payload = _read_json(source_file) + + maybe_fixture_id, maybe_workspace_id, maybe_workspace_name = _extract_workspace_metadata(payload) + if fixture_id is None: + fixture_id = maybe_fixture_id + if workspace_id is None: + workspace_id = maybe_workspace_id + if workspace_name is None: + workspace_name = maybe_workspace_name + + conversations = _extract_conversations(payload) + for conversation_index, conversation in enumerate(conversations, start=1): + conversation_id = normalize_optional_text( + conversation.get("id") + ) or f"conversation-{conversation_index}" + conversation_title = normalize_optional_text(conversation.get("title")) + conversation_project = normalize_optional_text(conversation.get("project")) + conversation_person = normalize_optional_text(conversation.get("person")) + + messages = _conversation_messages(conversation) + for message_index, message in enumerate(messages, start=1): + role = _message_role(message) + if role in {"system", "assistant", "user"}: + pass + elif role is not None: + continue + + text = _message_text(message) + if text is None: + continue + + object_type, object_text = _resolve_object_type_and_text( + text=text, + type_hint=message.get("object_type"), + ) + + status = parse_optional_status(message.get("status")) or "active" + confidence = parse_optional_confidence(message.get("confidence")) + if confidence is None: + confidence = _DEFAULT_CONFIDENCE + + message_id = normalize_optional_text(message.get("id")) or f"{conversation_id}:{message_index}" + source_item_id = f"{conversation_id}:{message_id}" + + explicit_title = normalize_optional_text(message.get("title")) + if explicit_title is None: + explicit_title = conversation_title + title = _build_title( + object_type=object_type, + text=object_text, + explicit_title=explicit_title, + ) + + body_key = OBJECT_TYPE_TO_BODY_KEY[object_type] + body: JsonObject = { + body_key: object_text, + "raw_import_text": object_text, + "chatgpt_role": role, + "chatgpt_conversation_id": conversation_id, + "chatgpt_message_id": message_id, + } + + source_provenance: JsonObject = { + "thread_id": conversation_id, + "chatgpt_conversation_id": conversation_id, + "chatgpt_message_id": message_id, + } + if role is not None: + source_provenance["chatgpt_role"] = role + if conversation_project is not None: + source_provenance["project"] = conversation_project + if conversation_person is not None: + source_provenance["person"] = conversation_person + + source_event_ids = [f"chatgpt-event:{conversation_id}:{message_id}"] + source_provenance["source_event_ids"] = source_event_ids + + dedupe_payload = merge_json_objects( + { + "workspace_id": workspace_id or source_path.stem, + "conversation_id": conversation_id, + "message_id": message_id, + "object_type": object_type, + "status": status, + "title": title, + "body": body, + }, + source_provenance, + ) + + items.append( + ImporterNormalizedItem( + source_item_id=source_item_id, + source_file=source_file.name, + object_type=object_type, + status=status, + raw_content=_build_raw_content(object_type=object_type, text=object_text), + title=title, + body=body, + confidence=confidence, + source_provenance=source_provenance, + dedupe_key=dedupe_key_for_payload(dedupe_payload), + ) + ) + + if not items: + raise ChatGPTImportValidationError("ChatGPT source did not contain any importable messages") + + resolved_workspace_id = workspace_id or f"chatgpt-{source_path.stem}" + return ImporterNormalizedBatch( + context=ImporterWorkspaceContext( + fixture_id=fixture_id, + workspace_id=resolved_workspace_id, + workspace_name=workspace_name, + source_path=str(source_path), + ), + items=items, + ) + + +def import_chatgpt_source( + store: ContinuityStore, + *, + user_id: UUID, + source: str | Path, +) -> JsonObject: + batch = load_chatgpt_payload(source) + return import_normalized_batch( + store, + user_id=user_id, + batch=batch, + config=ImportPersistenceConfig( + source_kind="chatgpt_import", + source_prefix="chatgpt", + admission_reason="chatgpt_import", + dedupe_key_field="chatgpt_dedupe_key", + dedupe_posture=_DEFAULT_DEDUPE_POSTURE, + ), + ) + + +__all__ = ["ChatGPTImportValidationError", "import_chatgpt_source", "load_chatgpt_payload"] diff --git a/apps/api/src/alicebot_api/chief_of_staff.py b/apps/api/src/alicebot_api/chief_of_staff.py new file mode 100644 index 0000000..f89c4dc --- /dev/null +++ b/apps/api/src/alicebot_api/chief_of_staff.py @@ -0,0 +1,3806 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from uuid import UUID + +from alicebot_api.continuity_open_loops import ( + compile_continuity_open_loop_dashboard, + compile_continuity_weekly_review, +) +from alicebot_api.continuity_recall import query_continuity_recall +from alicebot_api.continuity_resumption import compile_continuity_resumption_brief +from alicebot_api.contracts import ( + CHIEF_OF_STAFF_ACTION_HANDOFF_ACTIONS, + CHIEF_OF_STAFF_ACTION_HANDOFF_ITEM_ORDER, + CHIEF_OF_STAFF_ACTION_HANDOFF_SOURCE_ORDER, + CHIEF_OF_STAFF_EXECUTION_READINESS_POSTURE_ORDER, + CHIEF_OF_STAFF_EXECUTION_ROUTE_TARGET_ORDER, + CHIEF_OF_STAFF_EXECUTION_ROUTED_ITEM_ORDER, + CHIEF_OF_STAFF_EXECUTION_ROUTING_AUDIT_ORDER, + CHIEF_OF_STAFF_EXECUTION_ROUTING_TRANSITIONS, + CHIEF_OF_STAFF_ESCALATION_POSTURE_ORDER, + CHIEF_OF_STAFF_EXECUTION_POSTURE_ORDER, + CHIEF_OF_STAFF_FOLLOW_THROUGH_ITEM_ORDER, + CHIEF_OF_STAFF_FOLLOW_THROUGH_POSTURE_ORDER, + CHIEF_OF_STAFF_FOLLOW_THROUGH_RECOMMENDATION_ACTIONS, + CHIEF_OF_STAFF_HANDOFF_QUEUE_ITEM_ORDER, + CHIEF_OF_STAFF_HANDOFF_QUEUE_STATE_ORDER, + CHIEF_OF_STAFF_HANDOFF_OUTCOME_ORDER, + CHIEF_OF_STAFF_HANDOFF_OUTCOME_STATUSES, + CHIEF_OF_STAFF_HANDOFF_REVIEW_ACTIONS, + CHIEF_OF_STAFF_OUTCOME_HOTSPOT_ORDER, + CHIEF_OF_STAFF_PREPARATION_ITEM_ORDER, + CHIEF_OF_STAFF_PRIORITY_BRIEF_ASSEMBLY_VERSION_V0, + CHIEF_OF_STAFF_PRIORITY_ITEM_ORDER, + CHIEF_OF_STAFF_PRIORITY_POSTURE_ORDER, + CHIEF_OF_STAFF_RECOMMENDED_ACTION_TYPES, + CHIEF_OF_STAFF_RECOMMENDATION_OUTCOME_ORDER, + CHIEF_OF_STAFF_RECOMMENDATION_OUTCOMES, + CHIEF_OF_STAFF_RESUMPTION_RECOMMENDATION_ACTIONS, + CHIEF_OF_STAFF_RESUMPTION_SUPERVISION_ITEM_ORDER, + CHIEF_OF_STAFF_WEEKLY_REVIEW_GUIDANCE_ACTIONS, + CONTINUITY_OPEN_LOOP_POSTURE_ORDER, + DEFAULT_CHIEF_OF_STAFF_PRIORITY_LIMIT, + MAX_CHIEF_OF_STAFF_PRIORITY_LIMIT, + MAX_CONTINUITY_OPEN_LOOP_LIMIT, + MAX_CONTINUITY_RECALL_LIMIT, + MAX_CONTINUITY_RESUMPTION_OPEN_LOOP_LIMIT, + MAX_CONTINUITY_RESUMPTION_RECENT_CHANGES_LIMIT, + MAX_CONTINUITY_WEEKLY_REVIEW_LIMIT, + ChiefOfStaffActionHandoffAction, + ChiefOfStaffActionHandoffApprovalDraftRecord, + ChiefOfStaffActionHandoffBriefRecord, + ChiefOfStaffActionHandoffItem, + ChiefOfStaffActionHandoffRequestDraft, + ChiefOfStaffActionHandoffRequestTarget, + ChiefOfStaffActionHandoffSourceKind, + ChiefOfStaffActionHandoffTaskDraftRecord, + ChiefOfStaffDraftFollowUpRecord, + ChiefOfStaffEscalationPosture, + ChiefOfStaffEscalationPostureRecord, + ChiefOfStaffExecutionPostureRecord, + ChiefOfStaffExecutionReadinessPostureRecord, + ChiefOfStaffExecutionRouteTarget, + ChiefOfStaffExecutionRoutingActionCaptureResponse, + ChiefOfStaffExecutionRoutingActionInput, + ChiefOfStaffExecutionRoutingAuditRecord, + ChiefOfStaffExecutionRoutingSummary, + ChiefOfStaffExecutionRoutingTransition, + ChiefOfStaffFollowThroughItem, + ChiefOfStaffFollowThroughPosture, + ChiefOfStaffFollowThroughRecommendationAction, + ChiefOfStaffHandoffQueueGroups, + ChiefOfStaffHandoffQueueItem, + ChiefOfStaffHandoffQueueLifecycleState, + ChiefOfStaffHandoffQueueSummary, + ChiefOfStaffHandoffOutcomeCaptureInput, + ChiefOfStaffHandoffOutcomeCaptureResponse, + ChiefOfStaffHandoffOutcomeRecord, + ChiefOfStaffHandoffOutcomeStatus, + ChiefOfStaffHandoffOutcomeSummary, + ChiefOfStaffHandoffReviewAction, + ChiefOfStaffHandoffReviewActionCaptureResponse, + ChiefOfStaffHandoffReviewActionInput, + ChiefOfStaffHandoffReviewActionRecord, + ChiefOfStaffClosureQualityPosture, + ChiefOfStaffClosureQualitySummaryRecord, + ChiefOfStaffConversionSignalSummaryRecord, + ChiefOfStaffOutcomeHotspotRecord, + ChiefOfStaffPatternDriftPosture, + ChiefOfStaffPatternDriftSummaryRecord, + ChiefOfStaffPrepChecklistRecord, + ChiefOfStaffPreparationArtifactItem, + ChiefOfStaffPreparationBriefRecord, + ChiefOfStaffPreparationSectionSummary, + ChiefOfStaffPriorityLearningSummaryRecord, + ChiefOfStaffPriorityBriefRecord, + ChiefOfStaffPriorityBriefRequestInput, + ChiefOfStaffPriorityBriefResponse, + ChiefOfStaffPriorityItem, + ChiefOfStaffPriorityPosture, + ChiefOfStaffRecommendationOutcome, + ChiefOfStaffRecommendationOutcomeCaptureInput, + ChiefOfStaffRecommendationOutcomeCaptureResponse, + ChiefOfStaffRecommendationOutcomeRecord, + ChiefOfStaffRecommendationOutcomeSection, + ChiefOfStaffRecommendationOutcomeSummary, + ChiefOfStaffRecommendationConfidencePosture, + ChiefOfStaffRecommendedActionType, + ChiefOfStaffRecommendedNextAction, + ChiefOfStaffRoutedHandoffItemRecord, + ChiefOfStaffStaleIgnoredEscalationPostureRecord, + ChiefOfStaffResumptionRecommendationAction, + ChiefOfStaffResumptionSupervisionRecommendation, + ChiefOfStaffResumptionSupervisionRecord, + ChiefOfStaffSuggestedTalkingPointsRecord, + ChiefOfStaffPrioritySummary, + ChiefOfStaffWeeklyReviewBriefRecord, + ChiefOfStaffWeeklyReviewBriefSummary, + ChiefOfStaffWeeklyReviewGuidanceAction, + ChiefOfStaffWeeklyReviewGuidanceItem, + ChiefOfStaffWhatChangedSummaryRecord, + ContinuityOpenLoopDashboardQueryInput, + ContinuityOpenLoopPosture, + ContinuityRecallProvenanceReference, + ContinuityRecallQueryInput, + ContinuityRecallResultRecord, + ContinuityResumptionBriefRequestInput, + ContinuityWeeklyReviewRequestInput, + MemoryQualityGateStatus, +) +from alicebot_api.memory import get_memory_trust_dashboard_summary +from alicebot_api.store import ContinuityStore + + +class ChiefOfStaffValidationError(ValueError): + """Raised when a chief-of-staff request is invalid.""" + + +@dataclass(frozen=True, slots=True) +class _TrustConfidenceCap: + posture: ChiefOfStaffRecommendationConfidencePosture + reason: str + + +@dataclass(frozen=True, slots=True) +class _ActionHandoffCandidate: + source_kind: ChiefOfStaffActionHandoffSourceKind + source_reference_id: str | None + title: str + recommendation_action: ChiefOfStaffActionHandoffAction + priority_posture: ChiefOfStaffPriorityPosture | None + confidence_posture: ChiefOfStaffRecommendationConfidencePosture + rationale: str + provenance_references: list[ContinuityRecallProvenanceReference] + score: float + + +_ACTIONABLE_OBJECT_TYPES = {"Commitment", "WaitingFor", "Blocker", "NextAction"} +_CONFIDENCE_ORDER: dict[ChiefOfStaffRecommendationConfidencePosture, int] = { + "low": 0, + "medium": 1, + "high": 2, +} +_POSTURE_WEIGHT: dict[ChiefOfStaffPriorityPosture, float] = { + "urgent": 600.0, + "important": 500.0, + "waiting": 400.0, + "blocked": 300.0, + "stale": 200.0, + "defer": 100.0, +} +_FOLLOW_THROUGH_OVERDUE_HOURS = 24.0 +_FOLLOW_THROUGH_STALE_WAITING_FOR_HOURS = 72.0 +_FOLLOW_THROUGH_SLIPPED_COMMITMENT_HOURS = 48.0 +_FOLLOW_THROUGH_CLOSE_LOOP_HOURS = 168.0 +_FOLLOW_THROUGH_NUDGE_HOURS = 48.0 +_FOLLOW_THROUGH_ESCALATE_HOURS = 120.0 +_FOLLOW_THROUGH_ACTION_WEIGHT: dict[ChiefOfStaffFollowThroughRecommendationAction, int] = { + "defer": 1, + "close_loop_candidate": 2, + "nudge": 3, + "escalate": 4, +} +_FOLLOW_THROUGH_POSTURE_WEIGHT: dict[ChiefOfStaffFollowThroughPosture, int] = { + "slipped_commitment": 1, + "stale_waiting_for": 2, + "overdue": 3, +} +_PREPARATION_CONTEXT_LIMIT = 6 +_WHAT_CHANGED_LIMIT = 6 +_PREP_CHECKLIST_LIMIT = 6 +_SUGGESTED_TALKING_POINT_LIMIT = 6 +_RESUMPTION_SUPERVISION_LIMIT = 3 +_OUTCOME_HISTORY_LIMIT = MAX_CONTINUITY_RECALL_LIMIT +_OUTCOME_HOTSPOT_LIMIT = 3 +_OUTCOME_BODY_KIND = "chief_of_staff_recommendation_outcome" +_ACTION_HANDOFF_LIMIT = 4 +_ACTION_HANDOFF_SOURCE_WEIGHT: dict[ChiefOfStaffActionHandoffSourceKind, float] = { + "recommended_next_action": 1000.0, + "follow_through": 900.0, + "prep_checklist": 700.0, + "weekly_review": 500.0, +} +_ACTION_HANDOFF_SOURCE_RANK: dict[ChiefOfStaffActionHandoffSourceKind, int] = { + source_kind: index for index, source_kind in enumerate(CHIEF_OF_STAFF_ACTION_HANDOFF_SOURCE_ORDER) +} +_ACTION_HANDOFF_ACTION_SCOPE_MAP: dict[ChiefOfStaffActionHandoffSourceKind, str] = { + "recommended_next_action": "chief_of_staff_priority", + "follow_through": "chief_of_staff_follow_through", + "prep_checklist": "chief_of_staff_preparation", + "weekly_review": "chief_of_staff_weekly_review", +} +_HANDOFF_QUEUE_STALE_HOURS = 120.0 +_HANDOFF_QUEUE_EXPIRED_HOURS = 336.0 +_HANDOFF_REVIEW_ACTION_BODY_KIND = "chief_of_staff_handoff_review_action" +_EXECUTION_ROUTING_ACTION_BODY_KIND = "chief_of_staff_execution_routing_action" +_HANDOFF_OUTCOME_BODY_KIND = "chief_of_staff_handoff_outcome" +_FOLLOW_UP_ELIGIBLE_SOURCE_KINDS: set[ChiefOfStaffActionHandoffSourceKind] = { + "follow_through", + "weekly_review", +} +_ROUTE_TARGET_TO_FIELD: dict[ChiefOfStaffExecutionRouteTarget, str] = { + "task_workflow_draft": "task_workflow_draft_routed", + "approval_workflow_draft": "approval_workflow_draft_routed", + "follow_up_draft_only": "follow_up_draft_only_routed", +} +_HANDOFF_REVIEW_ACTION_TO_STATE: dict[ + ChiefOfStaffHandoffReviewAction, + ChiefOfStaffHandoffQueueLifecycleState, +] = { + "mark_ready": "ready", + "mark_pending_approval": "pending_approval", + "mark_executed": "executed", + "mark_stale": "stale", + "mark_expired": "expired", +} +_HANDOFF_QUEUE_STATE_EMPTY_MESSAGE: dict[ChiefOfStaffHandoffQueueLifecycleState, str] = { + "ready": "No ready handoff items for this scope.", + "pending_approval": "No handoff items are currently pending approval.", + "executed": "No handoff items are currently marked executed.", + "stale": "No stale handoff items are currently surfaced.", + "expired": "No expired handoff items are currently surfaced.", +} + + +def _is_offset_aware(value: datetime) -> bool: + return value.tzinfo is not None and value.utcoffset() is not None + + +def _normalize_optional_text(value: str | None) -> str | None: + if value is None: + return None + normalized = " ".join(value.split()).strip() + if not normalized: + return None + return normalized + + +def _validate_request(request: ChiefOfStaffPriorityBriefRequestInput) -> None: + if request.limit < 0 or request.limit > MAX_CHIEF_OF_STAFF_PRIORITY_LIMIT: + raise ChiefOfStaffValidationError( + f"limit must be between 0 and {MAX_CHIEF_OF_STAFF_PRIORITY_LIMIT}" + ) + + if request.since is None or request.until is None: + return + + if _is_offset_aware(request.since) != _is_offset_aware(request.until): + raise ChiefOfStaffValidationError( + "since and until must both include timezone offsets or both omit timezone offsets" + ) + + try: + if request.until < request.since: + raise ChiefOfStaffValidationError("until must be greater than or equal to since") + except TypeError as exc: + raise ChiefOfStaffValidationError( + "since and until must both include timezone offsets or both omit timezone offsets" + ) from exc + + +def _parse_timestamp(value: str) -> datetime: + normalized = value.replace("Z", "+00:00") + return datetime.fromisoformat(normalized) + + +def _age_hours_relative_to_latest(*, latest_created_at: datetime, item_created_at: datetime) -> float: + age_seconds = (latest_created_at - item_created_at).total_seconds() + return round(max(0.0, age_seconds) / 3600.0, 6) + + +def _build_open_loop_posture_map( + dashboard: dict[str, object], +) -> dict[str, ContinuityOpenLoopPosture]: + posture_by_id: dict[str, ContinuityOpenLoopPosture] = {} + for posture in CONTINUITY_OPEN_LOOP_POSTURE_ORDER: + section = dashboard[posture] + if not isinstance(section, dict): + continue + raw_items = section.get("items") + if not isinstance(raw_items, list): + continue + for item in raw_items: + if isinstance(item, dict): + item_id = item.get("id") + if isinstance(item_id, str): + posture_by_id[item_id] = posture + return posture_by_id + + +def _trust_confidence_cap( + *, + quality_gate_status: MemoryQualityGateStatus, + retrieval_status: str, +) -> _TrustConfidenceCap: + if quality_gate_status == "healthy": + posture: ChiefOfStaffRecommendationConfidencePosture = "high" + reason = "Memory quality gate is healthy, so recommendation confidence can remain high." + elif quality_gate_status == "needs_review": + posture = "medium" + reason = "Memory quality gate needs review, so recommendation confidence is capped at medium." + else: + posture = "low" + reason = ( + "Memory quality gate is weak (insufficient sample or degraded), " + "so recommendation confidence is capped at low." + ) + + if retrieval_status == "fail" and posture == "high": + posture = "medium" + reason = ( + "Memory quality gate is healthy but retrieval quality is failing; " + "recommendation confidence is capped at medium." + ) + + return _TrustConfidenceCap(posture=posture, reason=reason) + + +def _confidence_posture_from_score(score: float) -> ChiefOfStaffRecommendationConfidencePosture: + if score >= 0.8: + return "high" + if score >= 0.55: + return "medium" + return "low" + + +def _clamp_confidence_posture( + base: ChiefOfStaffRecommendationConfidencePosture, + cap: ChiefOfStaffRecommendationConfidencePosture, +) -> ChiefOfStaffRecommendationConfidencePosture: + if _CONFIDENCE_ORDER[base] <= _CONFIDENCE_ORDER[cap]: + return base + return cap + + +def _derive_priority_posture( + *, + item: ContinuityRecallResultRecord, + open_loop_posture: ContinuityOpenLoopPosture | None, + is_resumption_next_action: bool, + recent_change_index: int | None, +) -> ChiefOfStaffPriorityPosture: + lifecycle_status = item["status"] + + if lifecycle_status in {"completed", "cancelled", "superseded"}: + return "defer" + + if lifecycle_status == "stale" or open_loop_posture == "stale": + return "stale" + + if open_loop_posture == "waiting_for" or item["object_type"] == "WaitingFor": + return "waiting" + + if open_loop_posture == "blocker" or item["object_type"] == "Blocker": + return "blocked" + + if is_resumption_next_action: + return "urgent" + + if item["object_type"] == "NextAction" and recent_change_index is not None and recent_change_index == 0: + return "urgent" + + if item["object_type"] == "Commitment" and recent_change_index is not None and recent_change_index <= 1: + return "urgent" + + return "important" + + +def _confidence_score(item: ContinuityRecallResultRecord) -> float: + score = float(item["confidence"]) + if item["confirmation_status"] == "confirmed": + score += 0.1 + + provenance_posture = item["ordering"]["provenance_posture"] + if provenance_posture == "strong": + score += 0.1 + elif provenance_posture == "partial": + score += 0.05 + + freshness_posture = item["ordering"]["freshness_posture"] + if freshness_posture == "stale": + score -= 0.1 + elif freshness_posture == "superseded": + score -= 0.2 + + return min(1.0, max(0.0, score)) + + +def _ranking_score( + *, + item: ContinuityRecallResultRecord, + posture: ChiefOfStaffPriorityPosture, + age_hours: float, + is_resumption_next_action: bool, + recent_change_index: int | None, +) -> float: + score = _POSTURE_WEIGHT[posture] + score += float(item["relevance"]) * 0.2 + score += float(item["ordering"]["scope_match_count"]) * 3.0 + score += float(item["ordering"]["query_term_match_count"]) * 2.0 + score += float(item["ordering"]["confidence"]) + + if recent_change_index is not None: + score += max(0.0, 20.0 - float(recent_change_index)) + + if is_resumption_next_action: + score += 25.0 + + if posture in {"waiting", "blocked", "stale"}: + score += min(age_hours, 336.0) * 0.25 + + return round(score, 6) + + +def _posture_reason(posture: ChiefOfStaffPriorityPosture) -> str: + if posture == "urgent": + return "Marked urgent because this item is a deterministic immediate focus from resumption signals." + if posture == "important": + return "Marked important because this is active work with strong continuity relevance." + if posture == "waiting": + return "Marked waiting because this item is in waiting-for posture and requires follow-through tracking." + if posture == "blocked": + return "Marked blocked because this item has blocker posture and requires unblock action." + if posture == "stale": + return "Marked stale because freshness posture indicates this item is slipping." + return "Marked defer because lifecycle posture indicates it is not current active focus." + + +def _follow_through_action_for_age( + *, + age_hours: float, + close_loop_floor: float = _FOLLOW_THROUGH_CLOSE_LOOP_HOURS, + escalate_floor: float = _FOLLOW_THROUGH_ESCALATE_HOURS, + nudge_floor: float = _FOLLOW_THROUGH_NUDGE_HOURS, + prioritize_escalation: bool = False, +) -> ChiefOfStaffFollowThroughRecommendationAction: + if prioritize_escalation and age_hours >= escalate_floor: + return "escalate" + if age_hours >= close_loop_floor: + return "close_loop_candidate" + if age_hours >= escalate_floor: + return "escalate" + if age_hours >= nudge_floor: + return "nudge" + return "defer" + + +def _classify_follow_through_item( + *, + item: ContinuityRecallResultRecord, + open_loop_posture: ContinuityOpenLoopPosture | None, + age_hours: float, + priority_posture: ChiefOfStaffPriorityPosture, +) -> tuple[ + ChiefOfStaffFollowThroughPosture | None, + ChiefOfStaffFollowThroughRecommendationAction | None, + str | None, +]: + status = item["status"] + object_type = item["object_type"] + + if status in {"completed", "cancelled", "superseded"}: + return None, None, None + + if object_type == "Commitment": + if status == "stale" or age_hours >= _FOLLOW_THROUGH_SLIPPED_COMMITMENT_HOURS: + action = _follow_through_action_for_age(age_hours=age_hours) + if status == "stale" and action == "defer": + action = "nudge" + reason = ( + f"Commitment is slipping (status={status}, age={age_hours:.1f}h from latest scoped item), " + f"so action '{action}' is recommended." + ) + return "slipped_commitment", action, reason + return None, None, None + + if object_type == "WaitingFor": + if status == "stale" or open_loop_posture == "stale" or age_hours >= _FOLLOW_THROUGH_STALE_WAITING_FOR_HOURS: + action = _follow_through_action_for_age(age_hours=age_hours) + if status == "stale" and action == "defer": + action = "nudge" + reason = ( + f"Waiting-for item is stale (status={status}, age={age_hours:.1f}h from latest scoped item), " + f"so action '{action}' is recommended." + ) + return "stale_waiting_for", action, reason + + if age_hours >= _FOLLOW_THROUGH_OVERDUE_HOURS: + action = _follow_through_action_for_age( + age_hours=age_hours, + prioritize_escalation=True, + ) + reason = ( + f"Waiting-for follow-up is overdue ({age_hours:.1f}h from latest scoped item), " + f"so action '{action}' is recommended." + ) + return "overdue", action, reason + return None, None, None + + if object_type in {"NextAction", "Blocker"}: + overdue_from_age = age_hours >= _FOLLOW_THROUGH_OVERDUE_HOURS + overdue_from_priority = priority_posture in {"waiting", "blocked"} and age_hours >= _FOLLOW_THROUGH_NUDGE_HOURS + if overdue_from_age or overdue_from_priority: + action = _follow_through_action_for_age( + age_hours=age_hours, + prioritize_escalation=True, + ) + if priority_posture == "blocked" and action in {"defer", "nudge", "close_loop_candidate"}: + action = "escalate" + reason = ( + f"Execution follow-through is overdue (posture={priority_posture}, age={age_hours:.1f}h), " + f"so action '{action}' is recommended." + ) + return "overdue", action, reason + return None, None, None + + return None, None, None + + +def _follow_through_sort_key( + item: ChiefOfStaffFollowThroughItem, +) -> tuple[int, float, str, str]: + return ( + _FOLLOW_THROUGH_ACTION_WEIGHT[item["recommendation_action"]], + item["age_hours"], + item["created_at"], + item["id"], + ) + + +def _rank_follow_through_items( + items: list[ChiefOfStaffFollowThroughItem], + *, + limit: int, +) -> list[ChiefOfStaffFollowThroughItem]: + if limit <= 0: + return [] + + sorted_items = sorted( + items, + key=_follow_through_sort_key, + reverse=True, + ) + ranked: list[ChiefOfStaffFollowThroughItem] = [] + for rank, item in enumerate(sorted_items[:limit], start=1): + ranked_item = dict(item) + ranked_item["rank"] = rank + ranked.append(ranked_item) # type: ignore[arg-type] + return ranked + + +def _build_escalation_posture( + *, + all_follow_through_items: list[ChiefOfStaffFollowThroughItem], +) -> ChiefOfStaffEscalationPostureRecord: + action_counts: dict[ChiefOfStaffFollowThroughRecommendationAction, int] = { + "nudge": 0, + "defer": 0, + "escalate": 0, + "close_loop_candidate": 0, + } + for item in all_follow_through_items: + action_counts[item["recommendation_action"]] += 1 + + posture: ChiefOfStaffEscalationPosture + reason: str + if action_counts["escalate"] > 0: + posture = "critical" + reason = "At least one follow-through item requires escalation." + elif action_counts["nudge"] > 0: + posture = "elevated" + reason = "Follow-through items require nudges but no immediate escalations." + elif action_counts["close_loop_candidate"] > 0: + posture = "watch" + reason = "Only close-loop candidates are present; keep watch posture and confirm closure." + else: + posture = "watch" + reason = "No active follow-through escalations are present." + + if posture not in CHIEF_OF_STAFF_ESCALATION_POSTURE_ORDER: + posture = "watch" + + return { + "posture": posture, + "reason": reason, + "total_follow_through_count": len(all_follow_through_items), + "nudge_count": action_counts["nudge"], + "defer_count": action_counts["defer"], + "escalate_count": action_counts["escalate"], + "close_loop_candidate_count": action_counts["close_loop_candidate"], + } + + +def _draft_follow_up_sort_key( + item: ChiefOfStaffFollowThroughItem, +) -> tuple[int, int, float, str, str]: + return ( + _FOLLOW_THROUGH_ACTION_WEIGHT[item["recommendation_action"]], + _FOLLOW_THROUGH_POSTURE_WEIGHT[item["follow_through_posture"]], + item["age_hours"], + item["created_at"], + item["id"], + ) + + +def _build_draft_follow_up( + *, + all_follow_through_items: list[ChiefOfStaffFollowThroughItem], + thread_hint: str | None, +) -> ChiefOfStaffDraftFollowUpRecord: + if not all_follow_through_items: + return { + "status": "none", + "mode": "draft_only", + "approval_required": True, + "auto_send": False, + "reason": "No follow-through targets are currently queued for drafting.", + "target_metadata": { + "continuity_object_id": None, + "capture_event_id": None, + "object_type": None, + "priority_posture": None, + "follow_through_posture": None, + "recommendation_action": None, + "thread_id": thread_hint, + }, + "content": { + "subject": "", + "body": "", + }, + } + + target = sorted(all_follow_through_items, key=_draft_follow_up_sort_key, reverse=True)[0] + subject = f"Follow-up: {target['title']}" + body = "\n".join( + [ + f"Following up on: {target['title']}", + f"Current follow-through posture: {target['follow_through_posture']}", + f"Current priority posture: {target['current_priority_posture']}", + f"Recommended action: {target['recommendation_action']}", + f"Reason: {target['reason']}", + "", + "This draft is artifact-only and requires explicit approval before any external send.", + ] + ) + + return { + "status": "drafted", + "mode": "draft_only", + "approval_required": True, + "auto_send": False, + "reason": "Highest-severity follow-through item selected deterministically for operator review.", + "target_metadata": { + "continuity_object_id": target["id"], + "capture_event_id": target["capture_event_id"], + "object_type": target["object_type"], + "priority_posture": target["current_priority_posture"], + "follow_through_posture": target["follow_through_posture"], + "recommendation_action": target["recommendation_action"], + "thread_id": thread_hint, + }, + "content": { + "subject": subject, + "body": body, + }, + } + + +def _build_recommended_action( + *, + ranked_items: list[ChiefOfStaffPriorityItem], + trust_cap: ChiefOfStaffRecommendationConfidencePosture, +) -> ChiefOfStaffRecommendedNextAction: + target = next((item for item in ranked_items if item["priority_posture"] != "defer"), None) + + if target is None: + return { + "action_type": "capture_new_priority", + "title": "Capture one concrete next action", + "target_priority_id": None, + "priority_posture": None, + "confidence_posture": trust_cap, + "reason": "No active priority items are present, so capture one concrete next action to restore focus.", + "provenance_references": _synthetic_provenance_references( + source_kind="chief_of_staff_synthesis", + source_id="recommended_next_action_fallback", + ), + "deterministic_rank_key": "none", + } + + posture = target["priority_posture"] + action_type: ChiefOfStaffRecommendedActionType + if posture == "blocked": + action_type = "unblock_blocker" + elif posture == "waiting": + action_type = "follow_up_waiting_for" + elif posture == "stale": + action_type = "refresh_stale_item" + elif posture == "defer": + action_type = "review_and_defer" + elif target["object_type"] == "Commitment": + action_type = "progress_commitment" + else: + action_type = "execute_next_action" + + if action_type not in CHIEF_OF_STAFF_RECOMMENDED_ACTION_TYPES: + action_type = "review_and_defer" + + rationale_reasons = target["rationale"]["reasons"] + reason = rationale_reasons[0] if rationale_reasons else "Ranked highest by deterministic priority score." + + return { + "action_type": action_type, + "title": target["title"], + "target_priority_id": target["id"], + "priority_posture": posture, + "confidence_posture": target["confidence_posture"], + "reason": reason, + "provenance_references": target["rationale"]["provenance_references"], + "deterministic_rank_key": f"{target['rank']}:{target['id']}:{target['score']:.6f}", + } + + +def _build_preparation_section_summary( + *, + limit: int, + returned_count: int, + total_count: int, + order: list[str], +) -> ChiefOfStaffPreparationSectionSummary: + return { + "limit": limit, + "returned_count": returned_count, + "total_count": total_count, + "order": list(order), + } + + +def _serialize_preparation_item( + *, + source: ContinuityRecallResultRecord, + rank: int, + reason: str, + confidence_posture: ChiefOfStaffRecommendationConfidencePosture, +) -> ChiefOfStaffPreparationArtifactItem: + return { + "rank": rank, + "id": source["id"], + "capture_event_id": source["capture_event_id"], + "object_type": source["object_type"], + "status": source["status"], + "title": source["title"], + "reason": reason, + "confidence_posture": confidence_posture, + "provenance_references": source["provenance_references"], + "created_at": source["created_at"], + } + + +def _synthetic_provenance_references( + *, + source_kind: str, + source_id: str, +) -> list[ContinuityRecallProvenanceReference]: + return [ + { + "source_kind": source_kind, + "source_id": source_id, + } + ] + + +def _serialize_synthetic_preparation_item( + *, + synthetic_id: str, + rank: int, + title: str, + reason: str, + confidence_posture: ChiefOfStaffRecommendationConfidencePosture, +) -> ChiefOfStaffPreparationArtifactItem: + return { + "rank": rank, + "id": synthetic_id, + "capture_event_id": synthetic_id, + "object_type": "Note", + "status": "active", + "title": title, + "reason": reason, + "confidence_posture": confidence_posture, + "provenance_references": _synthetic_provenance_references( + source_kind="chief_of_staff_synthesis", + source_id=synthetic_id, + ), + "created_at": "1970-01-01T00:00:00+00:00", + } + + +def _preparation_reason_for_context(item: ContinuityRecallResultRecord) -> str: + object_type = item["object_type"] + if object_type == "Decision": + return "Decision context carried forward for deterministic meeting prep." + if object_type == "NextAction": + return "Immediate execution context included to reduce ambiguity at resume time." + if object_type == "WaitingFor": + return "Waiting-for dependency included so follow-up context is explicit before conversation." + if object_type == "Blocker": + return "Blocker context included so unblock discussion can happen immediately." + if object_type == "Commitment": + return "Active commitment included to anchor accountability in prep." + return "Relevant continuity context included for deterministic preparation." + + +def _build_preparation_brief( + *, + recall_items: list[ContinuityRecallResultRecord], + scope: dict[str, object], + last_decision: ContinuityRecallResultRecord | None, + open_loops: list[ContinuityRecallResultRecord], + next_action: ContinuityRecallResultRecord | None, + confidence_posture: ChiefOfStaffRecommendationConfidencePosture, + confidence_reason: str, +) -> ChiefOfStaffPreparationBriefRecord: + context_candidates = sorted( + [item for item in recall_items if item["status"] != "deleted"], + key=lambda item: (_parse_timestamp(item["created_at"]), item["id"]), + reverse=True, + ) + selected_context = context_candidates[:_PREPARATION_CONTEXT_LIMIT] + context_items = [ + _serialize_preparation_item( + source=item, + rank=index, + reason=_preparation_reason_for_context(item), + confidence_posture=confidence_posture, + ) + for index, item in enumerate(selected_context, start=1) + ] + + serialized_last_decision = ( + None + if last_decision is None + else _serialize_preparation_item( + source=last_decision, + rank=1, + reason="Latest scoped decision included to ground upcoming preparation context.", + confidence_posture=confidence_posture, + ) + ) + serialized_open_loops = [ + _serialize_preparation_item( + source=item, + rank=index, + reason="Open loop included so unresolved items are visible before resuming execution.", + confidence_posture=confidence_posture, + ) + for index, item in enumerate(open_loops[:_PREPARATION_CONTEXT_LIMIT], start=1) + ] + serialized_next_action = ( + None + if next_action is None + else _serialize_preparation_item( + source=next_action, + rank=1, + reason="Next action is included to keep immediate execution focus explicit after interruption.", + confidence_posture=confidence_posture, + ) + ) + + return { + "scope": scope, # type: ignore[typeddict-item] + "context_items": context_items, + "last_decision": serialized_last_decision, + "open_loops": serialized_open_loops, + "next_action": serialized_next_action, + "confidence_posture": confidence_posture, + "confidence_reason": confidence_reason, + "summary": _build_preparation_section_summary( + limit=_PREPARATION_CONTEXT_LIMIT, + returned_count=len(context_items), + total_count=len(context_candidates), + order=list(CHIEF_OF_STAFF_PREPARATION_ITEM_ORDER), + ), + } + + +def _build_what_changed_summary( + *, + recent_changes: list[ContinuityRecallResultRecord], + confidence_posture: ChiefOfStaffRecommendationConfidencePosture, + confidence_reason: str, +) -> ChiefOfStaffWhatChangedSummaryRecord: + selected_items = recent_changes[:_WHAT_CHANGED_LIMIT] + items = [ + _serialize_preparation_item( + source=item, + rank=index, + reason="Included from deterministic continuity recent-changes ordering.", + confidence_posture=confidence_posture, + ) + for index, item in enumerate(selected_items, start=1) + ] + return { + "items": items, + "confidence_posture": confidence_posture, + "confidence_reason": confidence_reason, + "summary": _build_preparation_section_summary( + limit=_WHAT_CHANGED_LIMIT, + returned_count=len(items), + total_count=len(recent_changes), + order=list(CHIEF_OF_STAFF_PREPARATION_ITEM_ORDER), + ), + } + + +def _build_prep_checklist( + *, + last_decision: ContinuityRecallResultRecord | None, + open_loops: list[ContinuityRecallResultRecord], + next_action: ContinuityRecallResultRecord | None, + confidence_posture: ChiefOfStaffRecommendationConfidencePosture, + confidence_reason: str, +) -> ChiefOfStaffPrepChecklistRecord: + checklist_items: list[ChiefOfStaffPreparationArtifactItem] = [] + checklist_candidates_count = 0 + seen_ids: set[str] = set() + + if last_decision is not None: + checklist_candidates_count += 1 + seen_ids.add(last_decision["id"]) + checklist_items.append( + _serialize_preparation_item( + source=last_decision, + rank=0, + reason="Review the latest decision assumptions before the upcoming conversation.", + confidence_posture=confidence_posture, + ) + ) + + for open_loop in open_loops: + checklist_candidates_count += 1 + if open_loop["id"] in seen_ids: + continue + seen_ids.add(open_loop["id"]) + checklist_items.append( + _serialize_preparation_item( + source=open_loop, + rank=0, + reason="Prepare a status check and explicit owner for this unresolved open loop.", + confidence_posture=confidence_posture, + ) + ) + + if next_action is not None: + checklist_candidates_count += 1 + if next_action["id"] not in seen_ids: + checklist_items.append( + _serialize_preparation_item( + source=next_action, + rank=0, + reason="Confirm the first executable step and owner before resuming.", + confidence_posture=confidence_posture, + ) + ) + seen_ids.add(next_action["id"]) + + if not checklist_items: + checklist_items.append( + _serialize_synthetic_preparation_item( + synthetic_id="prep-checklist-capture-next-action", + rank=0, + title="Capture one concrete next action", + reason="No scoped prep candidates are available; capture one explicit next action before resume.", + confidence_posture=confidence_posture, + ) + ) + + selected_items = checklist_items[:_PREP_CHECKLIST_LIMIT] + for rank, item in enumerate(selected_items, start=1): + item["rank"] = rank + + return { + "items": selected_items, + "confidence_posture": confidence_posture, + "confidence_reason": confidence_reason, + "summary": _build_preparation_section_summary( + limit=_PREP_CHECKLIST_LIMIT, + returned_count=len(selected_items), + total_count=max(checklist_candidates_count, len(selected_items)), + order=list(CHIEF_OF_STAFF_PREPARATION_ITEM_ORDER), + ), + } + + +def _build_suggested_talking_points( + *, + last_decision: ContinuityRecallResultRecord | None, + top_ranked_priority: ChiefOfStaffPriorityItem | None, + open_loops: list[ContinuityRecallResultRecord], + confidence_posture: ChiefOfStaffRecommendationConfidencePosture, + confidence_reason: str, +) -> ChiefOfStaffSuggestedTalkingPointsRecord: + talking_points: list[ChiefOfStaffPreparationArtifactItem] = [] + talking_point_candidates_count = 0 + seen_ids: set[str] = set() + + if last_decision is not None: + talking_point_candidates_count += 1 + seen_ids.add(last_decision["id"]) + talking_points.append( + _serialize_preparation_item( + source=last_decision, + rank=0, + reason="Use this decision as opening context to align assumptions quickly.", + confidence_posture=confidence_posture, + ) + ) + + if top_ranked_priority is not None: + talking_point_candidates_count += 1 + priority_id = top_ranked_priority["id"] + if priority_id not in seen_ids: + seen_ids.add(priority_id) + talking_points.append( + { + "rank": 0, + "id": priority_id, + "capture_event_id": top_ranked_priority["capture_event_id"], + "object_type": top_ranked_priority["object_type"], + "status": top_ranked_priority["status"], + "title": top_ranked_priority["title"], + "reason": "Lead with the top-ranked current priority to reduce ambiguity on what to do next.", + "confidence_posture": top_ranked_priority["confidence_posture"], + "provenance_references": top_ranked_priority["rationale"]["provenance_references"], + "created_at": top_ranked_priority["created_at"], + } + ) + + for open_loop in open_loops: + talking_point_candidates_count += 1 + if open_loop["id"] in seen_ids: + continue + seen_ids.add(open_loop["id"]) + talking_points.append( + _serialize_preparation_item( + source=open_loop, + rank=0, + reason="Raise this unresolved dependency explicitly and confirm a concrete follow-up path.", + confidence_posture=confidence_posture, + ) + ) + + if not talking_points: + talking_points.append( + _serialize_synthetic_preparation_item( + synthetic_id="talking-point-capture-next-action", + rank=0, + title="What is the single next action after this conversation?", + reason="No scoped continuity signals are available, so establish one explicit next action.", + confidence_posture=confidence_posture, + ) + ) + + selected_items = talking_points[:_SUGGESTED_TALKING_POINT_LIMIT] + for rank, item in enumerate(selected_items, start=1): + item["rank"] = rank + + return { + "items": selected_items, + "confidence_posture": confidence_posture, + "confidence_reason": confidence_reason, + "summary": _build_preparation_section_summary( + limit=_SUGGESTED_TALKING_POINT_LIMIT, + returned_count=len(selected_items), + total_count=max(talking_point_candidates_count, len(selected_items)), + order=list(CHIEF_OF_STAFF_PREPARATION_ITEM_ORDER), + ), + } + + +def _normalize_resumption_action( + action: str, +) -> ChiefOfStaffResumptionRecommendationAction: + if action in CHIEF_OF_STAFF_RESUMPTION_RECOMMENDATION_ACTIONS: + return action # type: ignore[return-value] + return "review_scope" + + +def _build_resumption_supervision( + *, + recommended_next_action: ChiefOfStaffRecommendedNextAction, + follow_through_items: list[ChiefOfStaffFollowThroughItem], + trust_cap: _TrustConfidenceCap, +) -> ChiefOfStaffResumptionSupervisionRecord: + recommendations: list[ChiefOfStaffResumptionSupervisionRecommendation] = [] + + recommendations.append( + { + "rank": 0, + "action": _normalize_resumption_action(recommended_next_action["action_type"]), + "title": recommended_next_action["title"], + "reason": recommended_next_action["reason"], + "confidence_posture": recommended_next_action["confidence_posture"], + "target_priority_id": recommended_next_action["target_priority_id"], + "provenance_references": recommended_next_action["provenance_references"], + } + ) + + if follow_through_items: + top_follow_through_item = follow_through_items[0] + recommendations.append( + { + "rank": 0, + "action": _normalize_resumption_action(top_follow_through_item["recommendation_action"]), + "title": f"Follow-through: {top_follow_through_item['title']}", + "reason": top_follow_through_item["reason"], + "confidence_posture": trust_cap.posture, + "target_priority_id": top_follow_through_item["id"], + "provenance_references": top_follow_through_item["provenance_references"], + } + ) + + if trust_cap.posture != "high": + recommendations.append( + { + "rank": 0, + "action": "review_scope", + "title": "Calibrate recommendation confidence before execution", + "reason": trust_cap.reason, + "confidence_posture": trust_cap.posture, + "target_priority_id": None, + "provenance_references": _synthetic_provenance_references( + source_kind="memory_trust_dashboard", + source_id="trust_confidence_cap", + ), + } + ) + + selected = recommendations[:_RESUMPTION_SUPERVISION_LIMIT] + for rank, recommendation in enumerate(selected, start=1): + recommendation["rank"] = rank + + return { + "recommendations": selected, + "confidence_posture": trust_cap.posture, + "confidence_reason": trust_cap.reason, + "summary": _build_preparation_section_summary( + limit=_RESUMPTION_SUPERVISION_LIMIT, + returned_count=len(selected), + total_count=len(recommendations), + order=list(CHIEF_OF_STAFF_RESUMPTION_SUPERVISION_ITEM_ORDER), + ), + } + + +def _parse_recommendation_outcome_record( + item: ContinuityRecallResultRecord, +) -> ChiefOfStaffRecommendationOutcomeRecord | None: + if item["object_type"] != "Note": + return None + + body = item["body"] + if not isinstance(body, dict): + return None + + kind = body.get("kind") + if kind != _OUTCOME_BODY_KIND: + return None + + raw_outcome = body.get("outcome") + if not isinstance(raw_outcome, str) or raw_outcome not in CHIEF_OF_STAFF_RECOMMENDATION_OUTCOMES: + return None + outcome: ChiefOfStaffRecommendationOutcome = raw_outcome # type: ignore[assignment] + + raw_action_type = body.get("recommendation_action_type") + if not isinstance(raw_action_type, str) or raw_action_type not in CHIEF_OF_STAFF_RECOMMENDED_ACTION_TYPES: + return None + recommendation_action_type: ChiefOfStaffRecommendedActionType = raw_action_type # type: ignore[assignment] + + recommendation_title = body.get("recommendation_title") + if not isinstance(recommendation_title, str): + return None + + rewritten_title = body.get("rewritten_title") + rewritten_title_value = rewritten_title if isinstance(rewritten_title, str) else None + + target_priority_id = body.get("target_priority_id") + target_priority_id_value = target_priority_id if isinstance(target_priority_id, str) else None + + rationale = body.get("rationale") + rationale_value = rationale if isinstance(rationale, str) else None + + return { + "id": item["id"], + "capture_event_id": item["capture_event_id"], + "outcome": outcome, + "recommendation_action_type": recommendation_action_type, + "recommendation_title": recommendation_title, + "rewritten_title": rewritten_title_value, + "target_priority_id": target_priority_id_value, + "rationale": rationale_value, + "provenance_references": item["provenance_references"], + "created_at": item["created_at"], + "updated_at": item["updated_at"], + } + + +def _outcome_sort_key(item: ChiefOfStaffRecommendationOutcomeRecord) -> tuple[str, str]: + return (item["created_at"], item["id"]) + + +def _outcome_counts( + items: list[ChiefOfStaffRecommendationOutcomeRecord], +) -> dict[ChiefOfStaffRecommendationOutcome, int]: + counts: dict[ChiefOfStaffRecommendationOutcome, int] = { + "accept": 0, + "defer": 0, + "ignore": 0, + "rewrite": 0, + } + for item in items: + counts[item["outcome"]] += 1 + return counts + + +def _build_outcome_hotspots( + *, + items: list[ChiefOfStaffRecommendationOutcomeRecord], + outcome: ChiefOfStaffRecommendationOutcome, +) -> list[ChiefOfStaffOutcomeHotspotRecord]: + counts_by_key: dict[str, int] = {} + for item in items: + if item["outcome"] != outcome: + continue + hotspot_key = item["target_priority_id"] or item["recommendation_action_type"] + counts_by_key[hotspot_key] = counts_by_key.get(hotspot_key, 0) + 1 + + hotspots = [ + {"key": key, "count": count} + for key, count in sorted( + counts_by_key.items(), + key=lambda entry: (-entry[1], entry[0]), + )[:_OUTCOME_HOTSPOT_LIMIT] + ] + return hotspots + + +def _list_recommendation_outcome_records( + recall_items: list[ContinuityRecallResultRecord], +) -> list[ChiefOfStaffRecommendationOutcomeRecord]: + all_outcome_items = [ + parsed + for parsed in ( + _parse_recommendation_outcome_record(item) + for item in recall_items + ) + if parsed is not None + ] + all_outcome_items.sort(key=_outcome_sort_key, reverse=True) + return all_outcome_items + + +def _build_recommendation_outcome_section( + *, + all_outcome_items: list[ChiefOfStaffRecommendationOutcomeRecord], + limit: int, +) -> ChiefOfStaffRecommendationOutcomeSection: + selected_items = all_outcome_items[:limit] if limit > 0 else [] + summary: ChiefOfStaffRecommendationOutcomeSummary = { + "returned_count": len(selected_items), + "total_count": len(all_outcome_items), + "outcome_counts": _outcome_counts(all_outcome_items), + "order": list(CHIEF_OF_STAFF_RECOMMENDATION_OUTCOME_ORDER), + } + return { + "items": selected_items, + "summary": summary, + } + + +def _build_priority_shift_explanation( + *, + counts: dict[ChiefOfStaffRecommendationOutcome, int], +) -> str: + accept_count = counts["accept"] + defer_count = counts["defer"] + ignore_count = counts["ignore"] + rewrite_count = counts["rewrite"] + override_count = ignore_count + rewrite_count + + if accept_count + defer_count + override_count == 0: + return ( + "No recommendation outcomes are captured yet; prioritization remains anchored to " + "current continuity and trust signals." + ) + if override_count > accept_count: + return ( + "Prioritization is shifting toward stricter confidence because ignore/rewrite outcomes " + "currently exceed accepted recommendations." + ) + if defer_count > 0 and defer_count >= accept_count: + return ( + "Prioritization is shifting toward pacing controls because deferred outcomes are " + "comparable to or above accepted recommendations." + ) + return ( + "Prioritization is reinforcing currently accepted recommendation patterns while tracking " + "defer/override hotspots." + ) + + +def _build_priority_learning_summary( + *, + all_outcome_items: list[ChiefOfStaffRecommendationOutcomeRecord], +) -> ChiefOfStaffPriorityLearningSummaryRecord: + counts = _outcome_counts(all_outcome_items) + total_count = len(all_outcome_items) + override_count = counts["ignore"] + counts["rewrite"] + + acceptance_rate = 0.0 if total_count == 0 else counts["accept"] / total_count + override_rate = 0.0 if total_count == 0 else override_count / total_count + + return { + "total_count": total_count, + "accept_count": counts["accept"], + "defer_count": counts["defer"], + "ignore_count": counts["ignore"], + "rewrite_count": counts["rewrite"], + "acceptance_rate": round(acceptance_rate, 6), + "override_rate": round(override_rate, 6), + "defer_hotspots": _build_outcome_hotspots(items=all_outcome_items, outcome="defer"), + "ignore_hotspots": _build_outcome_hotspots(items=all_outcome_items, outcome="ignore"), + "priority_shift_explanation": _build_priority_shift_explanation(counts=counts), + "hotspot_order": list(CHIEF_OF_STAFF_OUTCOME_HOTSPOT_ORDER), + } + + +def _build_pattern_drift_summary( + *, + learning_summary: ChiefOfStaffPriorityLearningSummaryRecord, +) -> ChiefOfStaffPatternDriftSummaryRecord: + total_count = learning_summary["total_count"] + override_count = learning_summary["ignore_count"] + learning_summary["rewrite_count"] + accept_count = learning_summary["accept_count"] + defer_count = learning_summary["defer_count"] + + posture: ChiefOfStaffPatternDriftPosture + reason: str + if total_count == 0: + posture = "insufficient_signal" + reason = "No recommendation outcomes are available yet, so drift posture is informational only." + elif override_count > accept_count: + posture = "drifting" + reason = "Overrides are outpacing accepts, so recommendation behavior is drifting and needs inspection." + elif accept_count > override_count and defer_count <= accept_count: + posture = "improving" + reason = "Accepted outcomes are leading with bounded defers/overrides, indicating improving recommendation fit." + else: + posture = "stable" + reason = "Outcome mix is balanced; recommendation behavior is stable with routine monitoring." + + return { + "posture": posture, + "reason": reason, + "supporting_signals": [ + f"Outcomes captured: {total_count}", + f"Accept={accept_count}, Defer={defer_count}, Ignore={learning_summary['ignore_count']}, Rewrite={learning_summary['rewrite_count']}", + f"Acceptance rate={learning_summary['acceptance_rate']:.6f}, Override rate={learning_summary['override_rate']:.6f}", + ], + } + + +def _build_weekly_review_brief( + *, + scope: dict[str, object], + weekly_rollup: dict[str, object], + follow_through_items: list[ChiefOfStaffFollowThroughItem], +) -> ChiefOfStaffWeeklyReviewBriefRecord: + action_counts: dict[ChiefOfStaffFollowThroughRecommendationAction, int] = { + "nudge": 0, + "defer": 0, + "escalate": 0, + "close_loop_candidate": 0, + } + for item in follow_through_items: + action_counts[item["recommendation_action"]] += 1 + + blocker_count = int(weekly_rollup.get("blocker_count", 0)) + stale_count = int(weekly_rollup.get("stale_count", 0)) + waiting_for_count = int(weekly_rollup.get("waiting_for_count", 0)) + next_action_count = int(weekly_rollup.get("next_action_count", 0)) + + guidance_candidates: list[ChiefOfStaffWeeklyReviewGuidanceItem] = [ + { + "rank": 0, + "action": "escalate", + "signal_count": action_counts["escalate"] + blocker_count, + "rationale": ( + f"Escalate where blockers ({blocker_count}) and escalate actions " + f"({action_counts['escalate']}) indicate execution risk." + ), + }, + { + "rank": 0, + "action": "close", + "signal_count": action_counts["close_loop_candidate"] + next_action_count, + "rationale": ( + f"Close loops where close candidates ({action_counts['close_loop_candidate']}) " + f"and actionable next steps ({next_action_count}) support deterministic closure." + ), + }, + { + "rank": 0, + "action": "defer", + "signal_count": action_counts["defer"] + stale_count + waiting_for_count, + "rationale": ( + f"Defer or park work where defer actions ({action_counts['defer']}), " + f"stale items ({stale_count}), and waiting-for load ({waiting_for_count}) are concentrated." + ), + }, + ] + guidance_candidates.sort( + key=lambda item: (item["signal_count"], item["action"]), + reverse=True, + ) + for rank, item in enumerate(guidance_candidates, start=1): + item["rank"] = rank + + summary: ChiefOfStaffWeeklyReviewBriefSummary = { + "guidance_order": list(CHIEF_OF_STAFF_WEEKLY_REVIEW_GUIDANCE_ACTIONS), + "guidance_item_order": ["signal_count_desc", "action_desc"], + } + return { + "scope": scope, # type: ignore[typeddict-item] + "rollup": weekly_rollup, # type: ignore[typeddict-item] + "guidance": guidance_candidates, + "summary": summary, + } + + +def _normalize_handoff_action( + *, + source_kind: ChiefOfStaffActionHandoffSourceKind, + action: str, +) -> ChiefOfStaffActionHandoffAction: + if source_kind == "weekly_review": + if action == "close": + return "weekly_review_close" + if action == "defer": + return "weekly_review_defer" + if action == "escalate": + return "weekly_review_escalate" + return "review_scope" + + if action in CHIEF_OF_STAFF_ACTION_HANDOFF_ACTIONS: + return action # type: ignore[return-value] + return "review_scope" + + +def _normalize_identifier_part(value: str) -> str: + normalized = "".join(ch.lower() if ch.isalnum() else "-" for ch in value.strip()) + while "--" in normalized: + normalized = normalized.replace("--", "-") + normalized = normalized.strip("-") + return normalized or "none" + + +def _action_handoff_sort_key(candidate: _ActionHandoffCandidate) -> tuple[float, int, str, str]: + return ( + -candidate.score, + _ACTION_HANDOFF_SOURCE_RANK[candidate.source_kind], + candidate.source_reference_id or "", + candidate.title, + ) + + +def _build_action_handoff_request_target( + *, + scope: dict[str, object], +) -> ChiefOfStaffActionHandoffRequestTarget: + thread_id = scope.get("thread_id") + task_id = scope.get("task_id") + project = scope.get("project") + person = scope.get("person") + return { + "thread_id": thread_id if isinstance(thread_id, str) else None, + "task_id": task_id if isinstance(task_id, str) else None, + "project": project if isinstance(project, str) else None, + "person": person if isinstance(person, str) else None, + } + + +def _build_action_handoff_request_draft( + *, + candidate: _ActionHandoffCandidate, + handoff_item_id: str, +) -> ChiefOfStaffActionHandoffRequestDraft: + domain_hint = "follow_through" if candidate.source_kind == "follow_through" else "planning" + if candidate.source_kind == "weekly_review": + domain_hint = "weekly_review" + + return { + "action": candidate.recommendation_action, + "scope": _ACTION_HANDOFF_ACTION_SCOPE_MAP[candidate.source_kind], + "domain_hint": domain_hint, + "risk_hint": "governed_handoff", + "attributes": { + "handoff_item_id": handoff_item_id, + "source_kind": candidate.source_kind, + "source_reference_id": candidate.source_reference_id, + "confidence_posture": candidate.confidence_posture, + "priority_posture": candidate.priority_posture, + "score": round(candidate.score, 6), + "rationale": candidate.rationale, + }, + } + + +def _build_action_handoff_task_draft( + *, + candidate: _ActionHandoffCandidate, + handoff_item_id: str, + target: ChiefOfStaffActionHandoffRequestTarget, + request_draft: ChiefOfStaffActionHandoffRequestDraft, +) -> ChiefOfStaffActionHandoffTaskDraftRecord: + return { + "status": "draft", + "mode": "governed_request_draft", + "approval_required": True, + "auto_execute": False, + "source_handoff_item_id": handoff_item_id, + "title": candidate.title, + "summary": ( + "Draft-only governed request assembled from chief-of-staff handoff artifacts; " + "requires explicit approval before any execution." + ), + "target": target, + "request": request_draft, + "rationale": candidate.rationale, + "provenance_references": candidate.provenance_references, + } + + +def _build_action_handoff_approval_draft( + *, + candidate: _ActionHandoffCandidate, + handoff_item_id: str, + request_draft: ChiefOfStaffActionHandoffRequestDraft, +) -> ChiefOfStaffActionHandoffApprovalDraftRecord: + return { + "status": "draft_only", + "mode": "approval_request_draft", + "decision": "approval_required", + "approval_required": True, + "auto_submit": False, + "source_handoff_item_id": handoff_item_id, + "request": request_draft, + "reason": ( + "Execution remains approval-bounded. This approval draft is artifact-only and must be " + "explicitly submitted and resolved before any side effect." + ), + "required_checks": [ + "operator_review_handoff_artifact", + "submit_governed_approval_request", + "explicit_approval_resolution", + ], + "provenance_references": candidate.provenance_references, + } + + +def _aggregate_provenance_references( + handoff_items: list[ChiefOfStaffActionHandoffItem], +) -> list[ContinuityRecallProvenanceReference]: + unique_keys: set[tuple[str, str]] = set() + for item in handoff_items: + for reference in item["provenance_references"]: + unique_keys.add((reference["source_kind"], reference["source_id"])) + return [ + { + "source_kind": source_kind, + "source_id": source_id, + } + for source_kind, source_id in sorted(unique_keys) + ] + + +def _build_execution_posture() -> ChiefOfStaffExecutionPostureRecord: + posture = "approval_bounded_artifact_only" + if posture not in CHIEF_OF_STAFF_EXECUTION_POSTURE_ORDER: + posture = CHIEF_OF_STAFF_EXECUTION_POSTURE_ORDER[0] # type: ignore[index] + + non_autonomous_guarantee = ( + "No task, approval, connector send, or external side effect is executed by this endpoint." + ) + return { + "posture": posture, # type: ignore[typeddict-item] + "approval_required": True, + "autonomous_execution": False, + "external_side_effects_allowed": False, + "default_routing_decision": "approval_required", + "required_operator_actions": [ + "review_handoff_items", + "submit_task_or_approval_request", + "resolve_approval_before_execution", + ], + "non_autonomous_guarantee": non_autonomous_guarantee, + "reason": "Chief-of-staff execution routing remains deterministic draft-only prep in P8-S31.", + } + + +def _build_action_handoff_artifacts( + *, + recommended_next_action: ChiefOfStaffRecommendedNextAction, + all_follow_through_items: list[ChiefOfStaffFollowThroughItem], + prep_checklist: ChiefOfStaffPrepChecklistRecord, + weekly_review_brief: ChiefOfStaffWeeklyReviewBriefRecord, + trust_cap: _TrustConfidenceCap, + scope: dict[str, object], +) -> tuple[ + ChiefOfStaffActionHandoffBriefRecord, + list[ChiefOfStaffActionHandoffItem], + ChiefOfStaffActionHandoffTaskDraftRecord, + ChiefOfStaffActionHandoffApprovalDraftRecord, + ChiefOfStaffExecutionPostureRecord, +]: + candidates: list[_ActionHandoffCandidate] = [] + + rec_priority = recommended_next_action["priority_posture"] + rec_priority_weight = 0.0 if rec_priority is None else _POSTURE_WEIGHT[rec_priority] + candidates.append( + _ActionHandoffCandidate( + source_kind="recommended_next_action", + source_reference_id=recommended_next_action["target_priority_id"], + title=recommended_next_action["title"], + recommendation_action=_normalize_handoff_action( + source_kind="recommended_next_action", + action=recommended_next_action["action_type"], + ), + priority_posture=rec_priority, + confidence_posture=recommended_next_action["confidence_posture"], + rationale=recommended_next_action["reason"], + provenance_references=recommended_next_action["provenance_references"], + score=round( + _ACTION_HANDOFF_SOURCE_WEIGHT["recommended_next_action"] + + rec_priority_weight + + (_CONFIDENCE_ORDER[recommended_next_action["confidence_posture"]] * 25.0), + 6, + ), + ) + ) + + if all_follow_through_items: + top_follow_through = all_follow_through_items[0] + follow_through_action = _normalize_handoff_action( + source_kind="follow_through", + action=top_follow_through["recommendation_action"], + ) + candidates.append( + _ActionHandoffCandidate( + source_kind="follow_through", + source_reference_id=top_follow_through["id"], + title=f"Follow-through: {top_follow_through['title']}", + recommendation_action=follow_through_action, + priority_posture=top_follow_through["current_priority_posture"], + confidence_posture=trust_cap.posture, + rationale=top_follow_through["reason"], + provenance_references=top_follow_through["provenance_references"], + score=round( + _ACTION_HANDOFF_SOURCE_WEIGHT["follow_through"] + + (_FOLLOW_THROUGH_ACTION_WEIGHT[top_follow_through["recommendation_action"]] * 30.0) + + top_follow_through["age_hours"], + 6, + ), + ) + ) + + if prep_checklist["items"]: + top_prep = prep_checklist["items"][0] + candidates.append( + _ActionHandoffCandidate( + source_kind="prep_checklist", + source_reference_id=top_prep["id"], + title=f"Preparation: {top_prep['title']}", + recommendation_action="review_scope", + priority_posture=None, + confidence_posture=top_prep["confidence_posture"], + rationale=top_prep["reason"], + provenance_references=top_prep["provenance_references"], + score=round( + _ACTION_HANDOFF_SOURCE_WEIGHT["prep_checklist"] + + max(0, _PREP_CHECKLIST_LIMIT - top_prep["rank"]), + 6, + ), + ) + ) + + if weekly_review_brief["guidance"]: + top_guidance = weekly_review_brief["guidance"][0] + candidates.append( + _ActionHandoffCandidate( + source_kind="weekly_review", + source_reference_id=f"weekly-{top_guidance['action']}", + title=f"Weekly review: {top_guidance['action']}", + recommendation_action=_normalize_handoff_action( + source_kind="weekly_review", + action=top_guidance["action"], + ), + priority_posture=None, + confidence_posture=trust_cap.posture, + rationale=top_guidance["rationale"], + provenance_references=_synthetic_provenance_references( + source_kind="continuity_weekly_review", + source_id=f"guidance-{top_guidance['action']}", + ), + score=round( + _ACTION_HANDOFF_SOURCE_WEIGHT["weekly_review"] + + (float(top_guidance["signal_count"]) * 20.0), + 6, + ), + ) + ) + + sorted_candidates = sorted(candidates, key=_action_handoff_sort_key) + selected_candidates = sorted_candidates[:_ACTION_HANDOFF_LIMIT] + + target = _build_action_handoff_request_target(scope=scope) + handoff_items: list[ChiefOfStaffActionHandoffItem] = [] + for rank, candidate in enumerate(selected_candidates, start=1): + source_ref = _normalize_identifier_part(candidate.source_reference_id or "none") + handoff_item_id = f"handoff-{rank}-{candidate.source_kind}-{source_ref}" + request_draft = _build_action_handoff_request_draft( + candidate=candidate, + handoff_item_id=handoff_item_id, + ) + task_draft = _build_action_handoff_task_draft( + candidate=candidate, + handoff_item_id=handoff_item_id, + target=target, + request_draft=request_draft, + ) + approval_draft = _build_action_handoff_approval_draft( + candidate=candidate, + handoff_item_id=handoff_item_id, + request_draft=request_draft, + ) + handoff_items.append( + { + "rank": rank, + "handoff_item_id": handoff_item_id, + "source_kind": candidate.source_kind, + "source_reference_id": candidate.source_reference_id, + "title": candidate.title, + "recommendation_action": candidate.recommendation_action, + "priority_posture": candidate.priority_posture, + "confidence_posture": candidate.confidence_posture, + "rationale": candidate.rationale, + "provenance_references": candidate.provenance_references, + "score": round(candidate.score, 6), + "task_draft": task_draft, + "approval_draft": approval_draft, + } + ) + + execution_posture = _build_execution_posture() + non_autonomous_guarantee = execution_posture["non_autonomous_guarantee"] + handoff_provenance = _aggregate_provenance_references(handoff_items=handoff_items) + active_sources = ", ".join(item["source_kind"] for item in handoff_items) + summary = ( + f"Prepared {len(handoff_items)} deterministic handoff items from {active_sources} signals. " + "All task and approval drafts remain artifact-only and approval-bounded." + ) + action_handoff_brief: ChiefOfStaffActionHandoffBriefRecord = { + "summary": summary, + "confidence_posture": trust_cap.posture, + "non_autonomous_guarantee": non_autonomous_guarantee, + "order": list(CHIEF_OF_STAFF_ACTION_HANDOFF_ITEM_ORDER), + "source_order": list(CHIEF_OF_STAFF_ACTION_HANDOFF_SOURCE_ORDER), + "provenance_references": handoff_provenance, + } + + if handoff_items: + task_draft = handoff_items[0]["task_draft"] + approval_draft = handoff_items[0]["approval_draft"] + else: + fallback_candidate = _ActionHandoffCandidate( + source_kind="recommended_next_action", + source_reference_id=None, + title="Capture one concrete next action", + recommendation_action="capture_new_priority", + priority_posture=None, + confidence_posture=trust_cap.posture, + rationale="No actionable handoff candidates were available in the scoped data.", + provenance_references=_synthetic_provenance_references( + source_kind="chief_of_staff_synthesis", + source_id="action_handoff_empty_fallback", + ), + score=0.0, + ) + fallback_request = _build_action_handoff_request_draft( + candidate=fallback_candidate, + handoff_item_id="handoff-fallback", + ) + task_draft = _build_action_handoff_task_draft( + candidate=fallback_candidate, + handoff_item_id="handoff-fallback", + target=target, + request_draft=fallback_request, + ) + approval_draft = _build_action_handoff_approval_draft( + candidate=fallback_candidate, + handoff_item_id="handoff-fallback", + request_draft=fallback_request, + ) + + return action_handoff_brief, handoff_items, task_draft, approval_draft, execution_posture + + +def _handoff_item_id_from_request_payload(request_payload: object) -> str | None: + if not isinstance(request_payload, dict): + return None + attributes = request_payload.get("attributes") + if not isinstance(attributes, dict): + return None + handoff_item_id = attributes.get("handoff_item_id") + if not isinstance(handoff_item_id, str): + return None + normalized = handoff_item_id.strip() + return normalized or None + + +def _build_governed_handoff_state_maps( + *, + store: ContinuityStore, +) -> tuple[set[str], set[str]]: + if not hasattr(store, "list_approvals") or not hasattr(store, "list_tasks"): + return set(), set() + + pending_approval_ids: set[str] = set() + executed_ids: set[str] = set() + + for approval in store.list_approvals(): + handoff_item_id = _handoff_item_id_from_request_payload(approval.get("request")) + if handoff_item_id is None: + continue + if approval["status"] == "pending": + pending_approval_ids.add(handoff_item_id) + + for task in store.list_tasks(): + handoff_item_id = _handoff_item_id_from_request_payload(task.get("request")) + if handoff_item_id is None: + continue + if task["status"] == "executed": + executed_ids.add(handoff_item_id) + elif task["status"] == "pending_approval": + pending_approval_ids.add(handoff_item_id) + + pending_approval_ids.difference_update(executed_ids) + return pending_approval_ids, executed_ids + + +def _parse_timestamp_optional(value: str | None) -> datetime | None: + if value is None: + return None + try: + return _parse_timestamp(value) + except ValueError: + return None + + +def _queue_age_hours( + *, + source_created_at: datetime | None, + latest_source_created_at: datetime | None, +) -> float | None: + if source_created_at is None or latest_source_created_at is None: + return None + return _age_hours_relative_to_latest( + latest_created_at=latest_source_created_at, + item_created_at=source_created_at, + ) + + +def _queue_state_for_age( + age_hours: float | None, +) -> ChiefOfStaffHandoffQueueLifecycleState | None: + if age_hours is None: + return None + if age_hours >= _HANDOFF_QUEUE_EXPIRED_HOURS: + return "expired" + if age_hours >= _HANDOFF_QUEUE_STALE_HOURS: + return "stale" + return None + + +def _available_handoff_review_actions( + state: ChiefOfStaffHandoffQueueLifecycleState, +) -> list[ChiefOfStaffHandoffReviewAction]: + return [ + action + for action in CHIEF_OF_STAFF_HANDOFF_REVIEW_ACTIONS + if _HANDOFF_REVIEW_ACTION_TO_STATE[action] != state + ] + + +def _infer_handoff_queue_state( + *, + handoff_item: ChiefOfStaffActionHandoffItem, + pending_approval_ids: set[str], + executed_ids: set[str], + source_status_by_id: dict[str, str], + source_created_at_by_id: dict[str, datetime], + latest_source_created_at: datetime | None, + follow_through_by_id: dict[str, ChiefOfStaffFollowThroughItem], +) -> tuple[ChiefOfStaffHandoffQueueLifecycleState, str, float | None]: + handoff_item_id = handoff_item["handoff_item_id"] + if handoff_item_id in executed_ids: + return "executed", "Linked governed task status is executed.", None + if handoff_item_id in pending_approval_ids: + return "pending_approval", "Linked governed approval/task is currently pending approval.", None + + source_reference_id = handoff_item["source_reference_id"] + source_created_at = ( + None + if source_reference_id is None + else source_created_at_by_id.get(source_reference_id) + ) + age_hours = _queue_age_hours( + source_created_at=source_created_at, + latest_source_created_at=latest_source_created_at, + ) + + if handoff_item["priority_posture"] == "stale": + return "stale", "Mapped source priority posture is stale.", age_hours + + if source_reference_id is not None and source_status_by_id.get(source_reference_id) == "stale": + return "stale", "Mapped source continuity object status is stale.", age_hours + + follow_through_item = ( + None + if source_reference_id is None + else follow_through_by_id.get(source_reference_id) + ) + if follow_through_item is not None: + follow_through_age = follow_through_item["age_hours"] + age_state = _queue_state_for_age(follow_through_age) + if follow_through_item["follow_through_posture"] == "stale_waiting_for": + return "stale", "Follow-through source is stale waiting-for and requires review.", follow_through_age + if age_state == "expired": + return "expired", "Follow-through source age exceeded deterministic expiration threshold.", follow_through_age + if age_state == "stale": + return "stale", "Follow-through source age exceeded deterministic stale threshold.", follow_through_age + + age_state = _queue_state_for_age(age_hours) + if age_state == "expired": + return "expired", "Source age exceeded deterministic expiration threshold.", age_hours + if age_state == "stale": + return "stale", "Source age exceeded deterministic stale threshold.", age_hours + + return "ready", "Handoff item is ready for explicit operator review.", age_hours + + +def _parse_handoff_review_action_record( + item: ContinuityRecallResultRecord, +) -> ChiefOfStaffHandoffReviewActionRecord | None: + if item["object_type"] != "Note": + return None + + body = item["body"] + if not isinstance(body, dict): + return None + + if body.get("kind") != _HANDOFF_REVIEW_ACTION_BODY_KIND: + return None + + handoff_item_id = body.get("handoff_item_id") + if not isinstance(handoff_item_id, str) or not handoff_item_id.strip(): + return None + + raw_review_action = body.get("review_action") + if ( + not isinstance(raw_review_action, str) + or raw_review_action not in CHIEF_OF_STAFF_HANDOFF_REVIEW_ACTIONS + ): + return None + review_action: ChiefOfStaffHandoffReviewAction = raw_review_action # type: ignore[assignment] + + raw_next_state = body.get("next_lifecycle_state") + if ( + not isinstance(raw_next_state, str) + or raw_next_state not in CHIEF_OF_STAFF_HANDOFF_QUEUE_STATE_ORDER + ): + return None + next_lifecycle_state: ChiefOfStaffHandoffQueueLifecycleState = raw_next_state # type: ignore[assignment] + + raw_previous_state = body.get("previous_lifecycle_state") + previous_lifecycle_state: ChiefOfStaffHandoffQueueLifecycleState | None + if raw_previous_state is None: + previous_lifecycle_state = None + elif ( + isinstance(raw_previous_state, str) + and raw_previous_state in CHIEF_OF_STAFF_HANDOFF_QUEUE_STATE_ORDER + ): + previous_lifecycle_state = raw_previous_state # type: ignore[assignment] + else: + return None + + raw_reason = body.get("reason") + reason = ( + raw_reason + if isinstance(raw_reason, str) and raw_reason.strip() + else "Lifecycle transition captured from explicit operator review action." + ) + + raw_note = body.get("note") + note = raw_note if isinstance(raw_note, str) and raw_note.strip() else None + + return { + "id": item["id"], + "capture_event_id": item["capture_event_id"], + "handoff_item_id": handoff_item_id.strip(), + "review_action": review_action, + "previous_lifecycle_state": previous_lifecycle_state, + "next_lifecycle_state": next_lifecycle_state, + "reason": reason, + "note": note, + "provenance_references": item["provenance_references"], + "created_at": item["created_at"], + "updated_at": item["updated_at"], + } + + +def _handoff_review_action_sort_key( + item: ChiefOfStaffHandoffReviewActionRecord, +) -> tuple[str, str]: + return (item["created_at"], item["id"]) + + +def _list_handoff_review_action_records( + recall_items: list[ContinuityRecallResultRecord], +) -> list[ChiefOfStaffHandoffReviewActionRecord]: + records = [ + parsed + for parsed in ( + _parse_handoff_review_action_record(item) + for item in recall_items + ) + if parsed is not None + ] + records.sort(key=_handoff_review_action_sort_key, reverse=True) + return records + + +def _empty_handoff_outcome_counts() -> dict[ChiefOfStaffHandoffOutcomeStatus, int]: + return { + "reviewed": 0, + "approved": 0, + "rejected": 0, + "rewritten": 0, + "executed": 0, + "ignored": 0, + "expired": 0, + } + + +def _parse_handoff_outcome_record( + item: ContinuityRecallResultRecord, +) -> ChiefOfStaffHandoffOutcomeRecord | None: + if item["object_type"] != "Note": + return None + + body = item["body"] + if not isinstance(body, dict): + return None + + if body.get("kind") != _HANDOFF_OUTCOME_BODY_KIND: + return None + + handoff_item_id = body.get("handoff_item_id") + if not isinstance(handoff_item_id, str) or not handoff_item_id.strip(): + return None + + raw_outcome_status = body.get("outcome_status") + if ( + not isinstance(raw_outcome_status, str) + or raw_outcome_status not in CHIEF_OF_STAFF_HANDOFF_OUTCOME_STATUSES + ): + return None + outcome_status: ChiefOfStaffHandoffOutcomeStatus = raw_outcome_status # type: ignore[assignment] + + raw_previous_outcome_status = body.get("previous_outcome_status") + previous_outcome_status: ChiefOfStaffHandoffOutcomeStatus | None + if raw_previous_outcome_status is None: + previous_outcome_status = None + elif ( + isinstance(raw_previous_outcome_status, str) + and raw_previous_outcome_status in CHIEF_OF_STAFF_HANDOFF_OUTCOME_STATUSES + ): + previous_outcome_status = raw_previous_outcome_status # type: ignore[assignment] + else: + return None + + raw_reason = body.get("reason") + reason = ( + raw_reason + if isinstance(raw_reason, str) and raw_reason.strip() + else "Routed handoff outcome captured as an explicit operator-authored immutable event." + ) + + raw_note = body.get("note") + note = raw_note if isinstance(raw_note, str) and raw_note.strip() else None + + return { + "id": item["id"], + "capture_event_id": item["capture_event_id"], + "handoff_item_id": handoff_item_id.strip(), + "outcome_status": outcome_status, + "previous_outcome_status": previous_outcome_status, + "is_latest_outcome": False, + "reason": reason, + "note": note, + "provenance_references": item["provenance_references"], + "created_at": item["created_at"], + "updated_at": item["updated_at"], + } + + +def _handoff_outcome_sort_key(item: ChiefOfStaffHandoffOutcomeRecord) -> tuple[str, str]: + return (item["created_at"], item["id"]) + + +def _list_handoff_outcome_records( + recall_items: list[ContinuityRecallResultRecord], +) -> list[ChiefOfStaffHandoffOutcomeRecord]: + parsed_records = [ + parsed + for parsed in ( + _parse_handoff_outcome_record(item) + for item in recall_items + ) + if parsed is not None + ] + parsed_records.sort(key=_handoff_outcome_sort_key, reverse=True) + + latest_by_handoff_item_id: set[str] = set() + records: list[ChiefOfStaffHandoffOutcomeRecord] = [] + for record in parsed_records: + materialized = dict(record) + handoff_item_id = record["handoff_item_id"] + materialized["is_latest_outcome"] = handoff_item_id not in latest_by_handoff_item_id + latest_by_handoff_item_id.add(handoff_item_id) + records.append(materialized) # type: ignore[arg-type] + return records + + +def _build_handoff_outcome_artifacts( + *, + all_handoff_outcomes: list[ChiefOfStaffHandoffOutcomeRecord], + limit: int, +) -> tuple[ + ChiefOfStaffHandoffOutcomeSummary, + list[ChiefOfStaffHandoffOutcomeRecord], + dict[ChiefOfStaffHandoffOutcomeStatus, int], +]: + selected_outcomes = all_handoff_outcomes[:limit] if limit > 0 else [] + status_counts = _empty_handoff_outcome_counts() + latest_status_counts = _empty_handoff_outcome_counts() + latest_total_count = 0 + + for record in all_handoff_outcomes: + status_counts[record["outcome_status"]] += 1 + if record["is_latest_outcome"]: + latest_status_counts[record["outcome_status"]] += 1 + latest_total_count += 1 + + summary: ChiefOfStaffHandoffOutcomeSummary = { + "returned_count": len(selected_outcomes), + "total_count": len(all_handoff_outcomes), + "latest_total_count": latest_total_count, + "status_counts": status_counts, + "latest_status_counts": latest_status_counts, + "status_order": list(CHIEF_OF_STAFF_HANDOFF_OUTCOME_STATUSES), + "order": list(CHIEF_OF_STAFF_HANDOFF_OUTCOME_ORDER), + } + return summary, selected_outcomes, latest_status_counts + + +def _safe_rate( + *, + numerator: int, + denominator: int, +) -> float: + if denominator <= 0: + return 0.0 + return round(numerator / denominator, 6) + + +def _build_conversion_signal_summary( + *, + total_handoff_count: int, + handoff_outcome_summary: ChiefOfStaffHandoffOutcomeSummary, + latest_status_counts: dict[ChiefOfStaffHandoffOutcomeStatus, int], +) -> ChiefOfStaffConversionSignalSummaryRecord: + latest_outcome_count = handoff_outcome_summary["latest_total_count"] + executed_count = latest_status_counts["executed"] + approved_count = latest_status_counts["approved"] + reviewed_count = latest_status_counts["reviewed"] + rewritten_count = latest_status_counts["rewritten"] + rejected_count = latest_status_counts["rejected"] + ignored_count = latest_status_counts["ignored"] + expired_count = latest_status_counts["expired"] + closed_loop_count = approved_count + executed_count + + return { + "total_handoff_count": total_handoff_count, + "latest_outcome_count": latest_outcome_count, + "executed_count": executed_count, + "approved_count": approved_count, + "reviewed_count": reviewed_count, + "rewritten_count": rewritten_count, + "rejected_count": rejected_count, + "ignored_count": ignored_count, + "expired_count": expired_count, + "recommendation_to_execution_conversion_rate": _safe_rate( + numerator=executed_count, + denominator=total_handoff_count, + ), + "recommendation_to_closure_conversion_rate": _safe_rate( + numerator=closed_loop_count, + denominator=total_handoff_count, + ), + "capture_coverage_rate": _safe_rate( + numerator=latest_outcome_count, + denominator=total_handoff_count, + ), + "explanation": ( + "Conversion signals are derived from latest immutable handoff outcomes per handoff item: " + f"executed={executed_count}, approved={approved_count}, reviewed={reviewed_count}, " + f"rewritten={rewritten_count}, rejected={rejected_count}, ignored={ignored_count}, " + f"expired={expired_count} over total_handoff_count={total_handoff_count}." + ), + } + + +def _build_closure_quality_summary( + *, + handoff_outcome_summary: ChiefOfStaffHandoffOutcomeSummary, + latest_status_counts: dict[ChiefOfStaffHandoffOutcomeStatus, int], +) -> ChiefOfStaffClosureQualitySummaryRecord: + latest_total_count = handoff_outcome_summary["latest_total_count"] + closed_loop_count = latest_status_counts["approved"] + latest_status_counts["executed"] + unresolved_count = latest_status_counts["reviewed"] + latest_status_counts["rewritten"] + rejected_count = latest_status_counts["rejected"] + ignored_count = latest_status_counts["ignored"] + expired_count = latest_status_counts["expired"] + negative_count = rejected_count + ignored_count + expired_count + + posture: ChiefOfStaffClosureQualityPosture + reason: str + if latest_total_count == 0: + posture = "insufficient_signal" + reason = "No handoff outcomes are captured yet, so closure quality remains informational." + elif negative_count > closed_loop_count: + posture = "critical" + reason = "Rejected/ignored/expired outcomes are currently outpacing closed-loop outcomes." + elif unresolved_count >= closed_loop_count or ignored_count > 0 or expired_count > 0: + posture = "watch" + reason = "Closure quality needs monitoring because unresolved, ignored, or expired outcomes are present." + else: + posture = "healthy" + reason = "Closed-loop outcomes are leading with bounded unresolved and ignored outcomes." + + return { + "posture": posture, + "reason": reason, + "closed_loop_count": closed_loop_count, + "unresolved_count": unresolved_count, + "rejected_count": rejected_count, + "ignored_count": ignored_count, + "expired_count": expired_count, + "closure_rate": _safe_rate( + numerator=closed_loop_count, + denominator=latest_total_count, + ), + "explanation": ( + "Closure quality uses the latest immutable outcome per handoff item; " + f"closure_rate={_safe_rate(numerator=closed_loop_count, denominator=latest_total_count):.6f} " + f"with closed_loop={closed_loop_count}, unresolved={unresolved_count}, " + f"rejected={rejected_count}, ignored={ignored_count}, expired={expired_count}." + ), + } + + +def _build_stale_ignored_escalation_posture( + *, + handoff_queue_summary: ChiefOfStaffHandoffQueueSummary, + latest_status_counts: dict[ChiefOfStaffHandoffOutcomeStatus, int], +) -> ChiefOfStaffStaleIgnoredEscalationPostureRecord: + stale_queue_count = handoff_queue_summary["stale_count"] + handoff_queue_summary["expired_count"] + ignored_count = latest_status_counts["ignored"] + expired_count = latest_status_counts["expired"] + trigger_count = stale_queue_count + ignored_count + expired_count + + posture: ChiefOfStaffEscalationPosture + reason: str + if trigger_count == 0: + posture = "watch" + reason = "No stale queue pressure or ignored/expired latest outcomes are currently detected." + elif trigger_count >= 3 or ignored_count + expired_count >= 2: + posture = "critical" + reason = "Stale queue pressure and ignored/expired outcomes are elevated and require immediate review." + else: + posture = "elevated" + reason = "Stale queue pressure or ignored/expired outcomes are present and should be reviewed." + + return { + "posture": posture, + "reason": reason, + "stale_queue_count": stale_queue_count, + "ignored_count": ignored_count, + "expired_count": expired_count, + "trigger_count": trigger_count, + "guidance_posture_explanation": ( + "Guidance posture is derived from stale queue load plus ignored/expired latest outcome counts so " + "operators can explicitly rebalance routing and follow-through focus." + ), + "supporting_signals": [ + f"stale_queue_count={stale_queue_count}", + f"ignored_count={ignored_count}", + f"expired_count={expired_count}", + f"trigger_count={trigger_count}", + ], + } + + +def _parse_execution_routing_audit_record( + item: ContinuityRecallResultRecord, +) -> ChiefOfStaffExecutionRoutingAuditRecord | None: + if item["object_type"] != "Note": + return None + + body = item["body"] + if not isinstance(body, dict): + return None + + if body.get("kind") != _EXECUTION_ROUTING_ACTION_BODY_KIND: + return None + + handoff_item_id = body.get("handoff_item_id") + if not isinstance(handoff_item_id, str) or not handoff_item_id.strip(): + return None + + raw_route_target = body.get("route_target") + if ( + not isinstance(raw_route_target, str) + or raw_route_target not in CHIEF_OF_STAFF_EXECUTION_ROUTE_TARGET_ORDER + ): + return None + route_target: ChiefOfStaffExecutionRouteTarget = raw_route_target # type: ignore[assignment] + + raw_transition = body.get("transition") + if ( + not isinstance(raw_transition, str) + or raw_transition not in CHIEF_OF_STAFF_EXECUTION_ROUTING_TRANSITIONS + ): + return None + transition: ChiefOfStaffExecutionRoutingTransition = raw_transition # type: ignore[assignment] + + previously_routed = bool(body.get("previously_routed", False)) + route_state = bool(body.get("route_state", True)) + + raw_reason = body.get("reason") + reason = ( + raw_reason + if isinstance(raw_reason, str) and raw_reason.strip() + else "Governed execution routing transition captured with draft-only posture." + ) + raw_note = body.get("note") + note = raw_note if isinstance(raw_note, str) and raw_note.strip() else None + + return { + "id": item["id"], + "capture_event_id": item["capture_event_id"], + "handoff_item_id": handoff_item_id.strip(), + "route_target": route_target, + "transition": transition, + "previously_routed": previously_routed, + "route_state": route_state, + "reason": reason, + "note": note, + "provenance_references": item["provenance_references"], + "created_at": item["created_at"], + "updated_at": item["updated_at"], + } + + +def _execution_routing_audit_sort_key( + item: ChiefOfStaffExecutionRoutingAuditRecord, +) -> tuple[str, str]: + return (item["created_at"], item["id"]) + + +def _list_execution_routing_audit_records( + recall_items: list[ContinuityRecallResultRecord], +) -> list[ChiefOfStaffExecutionRoutingAuditRecord]: + records = [ + parsed + for parsed in ( + _parse_execution_routing_audit_record(item) + for item in recall_items + ) + if parsed is not None + ] + records.sort(key=_execution_routing_audit_sort_key, reverse=True) + return records + + +def _build_execution_readiness_posture( + *, + execution_posture: ChiefOfStaffExecutionPostureRecord, +) -> ChiefOfStaffExecutionReadinessPostureRecord: + return { + "posture": CHIEF_OF_STAFF_EXECUTION_READINESS_POSTURE_ORDER[0], # type: ignore[index] + "approval_required": execution_posture["approval_required"], + "autonomous_execution": execution_posture["autonomous_execution"], + "external_side_effects_allowed": execution_posture["external_side_effects_allowed"], + "approval_path_visible": True, + "route_target_order": list(CHIEF_OF_STAFF_EXECUTION_ROUTE_TARGET_ORDER), + "required_route_targets": [ + "task_workflow_draft", + "approval_workflow_draft", + ], + "transition_order": list(CHIEF_OF_STAFF_EXECUTION_ROUTING_TRANSITIONS), + "non_autonomous_guarantee": execution_posture["non_autonomous_guarantee"], + "reason": ( + "Execution routing remains draft-only and approval-bounded; operators can explicitly route " + "handoff items into governed task/approval drafts with auditable transitions." + ), + } + + +def _build_execution_routing_artifacts( + *, + handoff_items: list[ChiefOfStaffActionHandoffItem], + routing_audit_trail: list[ChiefOfStaffExecutionRoutingAuditRecord], + draft_follow_up: ChiefOfStaffDraftFollowUpRecord, + execution_posture: ChiefOfStaffExecutionPostureRecord, +) -> tuple[ + ChiefOfStaffExecutionRoutingSummary, + list[ChiefOfStaffRoutedHandoffItemRecord], + ChiefOfStaffExecutionReadinessPostureRecord, +]: + latest_by_item_target: dict[tuple[str, ChiefOfStaffExecutionRouteTarget], ChiefOfStaffExecutionRoutingAuditRecord] = {} + latest_by_item: dict[str, ChiefOfStaffExecutionRoutingAuditRecord] = {} + for transition in routing_audit_trail: + item_id = transition["handoff_item_id"] + route_target = transition["route_target"] + key = (item_id, route_target) + if key not in latest_by_item_target: + latest_by_item_target[key] = transition + if item_id not in latest_by_item: + latest_by_item[item_id] = transition + + routed_handoff_items: list[ChiefOfStaffRoutedHandoffItemRecord] = [] + task_routed_count = 0 + approval_routed_count = 0 + follow_up_routed_count = 0 + + sorted_handoffs = sorted( + handoff_items, + key=lambda item: (item["rank"], item["handoff_item_id"]), + ) + for handoff_item in sorted_handoffs: + handoff_item_id = handoff_item["handoff_item_id"] + follow_up_applicable = handoff_item["source_kind"] in _FOLLOW_UP_ELIGIBLE_SOURCE_KINDS + available_targets: list[ChiefOfStaffExecutionRouteTarget] = [ + "task_workflow_draft", + "approval_workflow_draft", + ] + if follow_up_applicable: + available_targets.append("follow_up_draft_only") + + routed_targets: list[ChiefOfStaffExecutionRouteTarget] = [] + for route_target in CHIEF_OF_STAFF_EXECUTION_ROUTE_TARGET_ORDER: + if route_target not in available_targets: + continue + transition = latest_by_item_target.get((handoff_item_id, route_target)) + if transition is not None and transition["route_state"]: + routed_targets.append(route_target) + + task_routed = "task_workflow_draft" in routed_targets + approval_routed = "approval_workflow_draft" in routed_targets + follow_up_routed = "follow_up_draft_only" in routed_targets + task_routed_count += int(task_routed) + approval_routed_count += int(approval_routed) + follow_up_routed_count += int(follow_up_routed) + + routed_item: ChiefOfStaffRoutedHandoffItemRecord = { + "handoff_rank": handoff_item["rank"], + "handoff_item_id": handoff_item_id, + "title": handoff_item["title"], + "source_kind": handoff_item["source_kind"], + "recommendation_action": handoff_item["recommendation_action"], + "route_target_order": list(CHIEF_OF_STAFF_EXECUTION_ROUTE_TARGET_ORDER), + "available_route_targets": available_targets, + "routed_targets": routed_targets, + "is_routed": len(routed_targets) > 0, + "task_workflow_draft_routed": task_routed, + "approval_workflow_draft_routed": approval_routed, + "follow_up_draft_only_routed": follow_up_routed, + "follow_up_draft_only_applicable": follow_up_applicable, + "task_draft": handoff_item["task_draft"], + "approval_draft": handoff_item["approval_draft"], + "last_routing_transition": latest_by_item.get(handoff_item_id), + } + if follow_up_applicable: + routed_item["follow_up_draft"] = draft_follow_up + routed_handoff_items.append(routed_item) + + routed_handoff_count = sum(1 for item in routed_handoff_items if item["is_routed"]) + execution_routing_summary: ChiefOfStaffExecutionRoutingSummary = { + "total_handoff_count": len(routed_handoff_items), + "routed_handoff_count": routed_handoff_count, + "unrouted_handoff_count": len(routed_handoff_items) - routed_handoff_count, + "task_workflow_draft_count": task_routed_count, + "approval_workflow_draft_count": approval_routed_count, + "follow_up_draft_only_count": follow_up_routed_count, + "route_target_order": list(CHIEF_OF_STAFF_EXECUTION_ROUTE_TARGET_ORDER), + "routed_item_order": list(CHIEF_OF_STAFF_EXECUTION_ROUTED_ITEM_ORDER), + "audit_order": list(CHIEF_OF_STAFF_EXECUTION_ROUTING_AUDIT_ORDER), + "transition_order": list(CHIEF_OF_STAFF_EXECUTION_ROUTING_TRANSITIONS), + "approval_required": execution_posture["approval_required"], + "non_autonomous_guarantee": execution_posture["non_autonomous_guarantee"], + "reason": ( + "Routing transitions are explicit and auditable; task/approval/follow-up routes remain " + "draft-only until separately submitted through governed workflows." + ), + } + execution_readiness_posture = _build_execution_readiness_posture( + execution_posture=execution_posture, + ) + return execution_routing_summary, routed_handoff_items, execution_readiness_posture + + +def _handoff_queue_item_sort_key( + item: ChiefOfStaffHandoffQueueItem, +) -> tuple[int, float, str]: + return ( + item["handoff_rank"], + -item["score"], + item["handoff_item_id"], + ) + + +def _build_handoff_queue( + *, + store: ContinuityStore, + handoff_items: list[ChiefOfStaffActionHandoffItem], + recall_items: list[ContinuityRecallResultRecord], + all_follow_through_items: list[ChiefOfStaffFollowThroughItem], + handoff_review_actions: list[ChiefOfStaffHandoffReviewActionRecord], +) -> tuple[ChiefOfStaffHandoffQueueSummary, ChiefOfStaffHandoffQueueGroups]: + source_status_by_id: dict[str, str] = {} + source_created_at_by_id: dict[str, datetime] = {} + for item in recall_items: + source_status_by_id[item["id"]] = item["status"] + parsed_created_at = _parse_timestamp_optional(item["created_at"]) + if parsed_created_at is not None: + source_created_at_by_id[item["id"]] = parsed_created_at + + latest_source_created_at = ( + max(source_created_at_by_id.values()) + if source_created_at_by_id + else None + ) + + follow_through_by_id = { + item["id"]: item + for item in all_follow_through_items + } + pending_approval_ids, executed_ids = _build_governed_handoff_state_maps(store=store) + + latest_review_action_by_handoff_item_id: dict[str, ChiefOfStaffHandoffReviewActionRecord] = {} + for action in handoff_review_actions: + handoff_item_id = action["handoff_item_id"] + if handoff_item_id not in latest_review_action_by_handoff_item_id: + latest_review_action_by_handoff_item_id[handoff_item_id] = action + + grouped_items: dict[ChiefOfStaffHandoffQueueLifecycleState, list[ChiefOfStaffHandoffQueueItem]] = { + "ready": [], + "pending_approval": [], + "executed": [], + "stale": [], + "expired": [], + } + for handoff_item in handoff_items: + ( + inferred_state, + inferred_reason, + age_hours, + ) = _infer_handoff_queue_state( + handoff_item=handoff_item, + pending_approval_ids=pending_approval_ids, + executed_ids=executed_ids, + source_status_by_id=source_status_by_id, + source_created_at_by_id=source_created_at_by_id, + latest_source_created_at=latest_source_created_at, + follow_through_by_id=follow_through_by_id, + ) + + state = inferred_state + state_reason = inferred_reason + last_review_action = latest_review_action_by_handoff_item_id.get(handoff_item["handoff_item_id"]) + if last_review_action is not None: + state = last_review_action["next_lifecycle_state"] + state_reason = ( + f"Latest operator review action '{last_review_action['review_action']}' " + f"set lifecycle state to '{state}'." + ) + + queue_item: ChiefOfStaffHandoffQueueItem = { + "queue_rank": 0, + "handoff_rank": handoff_item["rank"], + "handoff_item_id": handoff_item["handoff_item_id"], + "lifecycle_state": state, + "state_reason": state_reason, + "source_kind": handoff_item["source_kind"], + "source_reference_id": handoff_item["source_reference_id"], + "title": handoff_item["title"], + "recommendation_action": handoff_item["recommendation_action"], + "priority_posture": handoff_item["priority_posture"], + "confidence_posture": handoff_item["confidence_posture"], + "score": handoff_item["score"], + "age_hours_relative_to_latest": age_hours, + "review_action_order": list(CHIEF_OF_STAFF_HANDOFF_REVIEW_ACTIONS), + "available_review_actions": _available_handoff_review_actions(state), + "last_review_action": last_review_action, + "provenance_references": handoff_item["provenance_references"], + } + grouped_items[state].append(queue_item) + + queue_rank = 1 + for lifecycle_state in CHIEF_OF_STAFF_HANDOFF_QUEUE_STATE_ORDER: + items = sorted(grouped_items[lifecycle_state], key=_handoff_queue_item_sort_key) + for item in items: + item["queue_rank"] = queue_rank + queue_rank += 1 + grouped_items[lifecycle_state] = items + + handoff_queue_summary: ChiefOfStaffHandoffQueueSummary = { + "total_count": len(handoff_items), + "ready_count": len(grouped_items["ready"]), + "pending_approval_count": len(grouped_items["pending_approval"]), + "executed_count": len(grouped_items["executed"]), + "stale_count": len(grouped_items["stale"]), + "expired_count": len(grouped_items["expired"]), + "state_order": list(CHIEF_OF_STAFF_HANDOFF_QUEUE_STATE_ORDER), + "group_order": list(CHIEF_OF_STAFF_HANDOFF_QUEUE_STATE_ORDER), + "item_order": list(CHIEF_OF_STAFF_HANDOFF_QUEUE_ITEM_ORDER), + "review_action_order": list(CHIEF_OF_STAFF_HANDOFF_REVIEW_ACTIONS), + } + handoff_queue_groups: ChiefOfStaffHandoffQueueGroups = { + "ready": { + "items": grouped_items["ready"], + "summary": { + "lifecycle_state": "ready", + "returned_count": len(grouped_items["ready"]), + "total_count": len(grouped_items["ready"]), + "order": list(CHIEF_OF_STAFF_HANDOFF_QUEUE_ITEM_ORDER), + }, + "empty_state": { + "is_empty": len(grouped_items["ready"]) == 0, + "message": _HANDOFF_QUEUE_STATE_EMPTY_MESSAGE["ready"], + }, + }, + "pending_approval": { + "items": grouped_items["pending_approval"], + "summary": { + "lifecycle_state": "pending_approval", + "returned_count": len(grouped_items["pending_approval"]), + "total_count": len(grouped_items["pending_approval"]), + "order": list(CHIEF_OF_STAFF_HANDOFF_QUEUE_ITEM_ORDER), + }, + "empty_state": { + "is_empty": len(grouped_items["pending_approval"]) == 0, + "message": _HANDOFF_QUEUE_STATE_EMPTY_MESSAGE["pending_approval"], + }, + }, + "executed": { + "items": grouped_items["executed"], + "summary": { + "lifecycle_state": "executed", + "returned_count": len(grouped_items["executed"]), + "total_count": len(grouped_items["executed"]), + "order": list(CHIEF_OF_STAFF_HANDOFF_QUEUE_ITEM_ORDER), + }, + "empty_state": { + "is_empty": len(grouped_items["executed"]) == 0, + "message": _HANDOFF_QUEUE_STATE_EMPTY_MESSAGE["executed"], + }, + }, + "stale": { + "items": grouped_items["stale"], + "summary": { + "lifecycle_state": "stale", + "returned_count": len(grouped_items["stale"]), + "total_count": len(grouped_items["stale"]), + "order": list(CHIEF_OF_STAFF_HANDOFF_QUEUE_ITEM_ORDER), + }, + "empty_state": { + "is_empty": len(grouped_items["stale"]) == 0, + "message": _HANDOFF_QUEUE_STATE_EMPTY_MESSAGE["stale"], + }, + }, + "expired": { + "items": grouped_items["expired"], + "summary": { + "lifecycle_state": "expired", + "returned_count": len(grouped_items["expired"]), + "total_count": len(grouped_items["expired"]), + "order": list(CHIEF_OF_STAFF_HANDOFF_QUEUE_ITEM_ORDER), + }, + "empty_state": { + "is_empty": len(grouped_items["expired"]) == 0, + "message": _HANDOFF_QUEUE_STATE_EMPTY_MESSAGE["expired"], + }, + }, + } + return handoff_queue_summary, handoff_queue_groups + + +def _flatten_handoff_queue_items( + *, + handoff_queue_groups: ChiefOfStaffHandoffQueueGroups, +) -> list[ChiefOfStaffHandoffQueueItem]: + items: list[ChiefOfStaffHandoffQueueItem] = [] + for lifecycle_state in CHIEF_OF_STAFF_HANDOFF_QUEUE_STATE_ORDER: + items.extend(handoff_queue_groups[lifecycle_state]["items"]) + return items + + +def _normalize_handoff_review_action( + review_action: str, +) -> ChiefOfStaffHandoffReviewAction | None: + if review_action not in CHIEF_OF_STAFF_HANDOFF_REVIEW_ACTIONS: + return None + return review_action # type: ignore[return-value] + + +def _normalize_execution_route_target( + route_target: str, +) -> ChiefOfStaffExecutionRouteTarget | None: + if route_target not in CHIEF_OF_STAFF_EXECUTION_ROUTE_TARGET_ORDER: + return None + return route_target # type: ignore[return-value] + + +def _normalize_handoff_outcome_status( + outcome_status: str, +) -> ChiefOfStaffHandoffOutcomeStatus | None: + if outcome_status not in CHIEF_OF_STAFF_HANDOFF_OUTCOME_STATUSES: + return None + return outcome_status # type: ignore[return-value] + + +def capture_chief_of_staff_recommendation_outcome( + store: ContinuityStore, + *, + user_id: UUID, + request: ChiefOfStaffRecommendationOutcomeCaptureInput, +) -> ChiefOfStaffRecommendationOutcomeCaptureResponse: + outcome = request.outcome + if outcome not in CHIEF_OF_STAFF_RECOMMENDATION_OUTCOMES: + allowed = ", ".join(CHIEF_OF_STAFF_RECOMMENDATION_OUTCOMES) + raise ChiefOfStaffValidationError(f"outcome must be one of: {allowed}") + + recommendation_action_type = request.recommendation_action_type + if recommendation_action_type not in CHIEF_OF_STAFF_RECOMMENDED_ACTION_TYPES: + allowed = ", ".join(CHIEF_OF_STAFF_RECOMMENDED_ACTION_TYPES) + raise ChiefOfStaffValidationError(f"recommendation_action_type must be one of: {allowed}") + + recommendation_title = _normalize_optional_text(request.recommendation_title) + if recommendation_title is None: + raise ChiefOfStaffValidationError("recommendation_title must not be empty") + + rationale = _normalize_optional_text(request.rationale) + rewritten_title = _normalize_optional_text(request.rewritten_title) + if outcome == "rewrite" and rewritten_title is None: + raise ChiefOfStaffValidationError("rewritten_title is required when outcome is rewrite") + if outcome != "rewrite" and rewritten_title is not None: + raise ChiefOfStaffValidationError("rewritten_title can only be provided when outcome is rewrite") + + capture_event = store.create_continuity_capture_event( + raw_content=f"Chief-of-staff recommendation outcome ({outcome}): {recommendation_title}", + explicit_signal="note", + admission_posture="TRIAGE", + admission_reason="chief_of_staff_recommendation_outcome", + ) + + target_priority_id = None if request.target_priority_id is None else str(request.target_priority_id) + thread_id = None if request.thread_id is None else str(request.thread_id) + task_id = None if request.task_id is None else str(request.task_id) + project = _normalize_optional_text(request.project) + person = _normalize_optional_text(request.person) + + body: dict[str, object] = { + "kind": _OUTCOME_BODY_KIND, + "outcome": outcome, + "recommendation_action_type": recommendation_action_type, + "recommendation_title": recommendation_title, + "target_priority_id": target_priority_id, + "rationale": rationale, + "rewritten_title": rewritten_title, + } + provenance: dict[str, object] = { + "thread_id": thread_id, + "task_id": task_id, + "project": project, + "person": person, + "source_event_ids": [str(capture_event["id"])], + "chief_of_staff_recommendation_outcome": { + "outcome": outcome, + "recommendation_action_type": recommendation_action_type, + "target_priority_id": target_priority_id, + }, + } + + stored = store.create_continuity_object( + capture_event_id=capture_event["id"], + object_type="Note", + status="active", + title=f"Recommendation outcome: {outcome} -> {recommendation_title}", + body=body, + provenance=provenance, + confidence=1.0, + ) + + scope_request = ChiefOfStaffPriorityBriefRequestInput( + thread_id=request.thread_id, + task_id=request.task_id, + project=project, + person=person, + limit=DEFAULT_CHIEF_OF_STAFF_PRIORITY_LIMIT, + ) + brief_payload = compile_chief_of_staff_priority_brief( + store, + user_id=user_id, + request=scope_request, + )["brief"] + + serialized_outcome: ChiefOfStaffRecommendationOutcomeRecord = { + "id": str(stored["id"]), + "capture_event_id": str(stored["capture_event_id"]), + "outcome": outcome, + "recommendation_action_type": recommendation_action_type, + "recommendation_title": recommendation_title, + "rewritten_title": rewritten_title, + "target_priority_id": target_priority_id, + "rationale": rationale, + "provenance_references": [ + { + "source_kind": "continuity_capture_event", + "source_id": str(capture_event["id"]), + } + ], + "created_at": stored["created_at"].isoformat(), + "updated_at": stored["updated_at"].isoformat(), + } + + return { + "outcome": serialized_outcome, + "recommendation_outcomes": brief_payload["recommendation_outcomes"], + "priority_learning_summary": brief_payload["priority_learning_summary"], + "pattern_drift_summary": brief_payload["pattern_drift_summary"], + } + + +def capture_chief_of_staff_handoff_review_action( + store: ContinuityStore, + *, + user_id: UUID, + request: ChiefOfStaffHandoffReviewActionInput, +) -> ChiefOfStaffHandoffReviewActionCaptureResponse: + handoff_item_id = _normalize_optional_text(request.handoff_item_id) + if handoff_item_id is None: + raise ChiefOfStaffValidationError("handoff_item_id must not be empty") + + review_action = _normalize_handoff_review_action(request.review_action) + if review_action is None: + allowed = ", ".join(CHIEF_OF_STAFF_HANDOFF_REVIEW_ACTIONS) + raise ChiefOfStaffValidationError(f"review_action must be one of: {allowed}") + + note = _normalize_optional_text(request.note) + project = _normalize_optional_text(request.project) + person = _normalize_optional_text(request.person) + scope_request = ChiefOfStaffPriorityBriefRequestInput( + thread_id=request.thread_id, + task_id=request.task_id, + project=project, + person=person, + limit=MAX_CHIEF_OF_STAFF_PRIORITY_LIMIT, + ) + scoped_brief = compile_chief_of_staff_priority_brief( + store, + user_id=user_id, + request=scope_request, + )["brief"] + queue_items = _flatten_handoff_queue_items( + handoff_queue_groups=scoped_brief["handoff_queue_groups"], + ) + queue_item = next( + (item for item in queue_items if item["handoff_item_id"] == handoff_item_id), + None, + ) + if queue_item is None: + raise ChiefOfStaffValidationError( + f"handoff_item_id '{handoff_item_id}' was not found in the scoped deterministic handoff queue" + ) + + previous_lifecycle_state = queue_item["lifecycle_state"] + next_lifecycle_state = _HANDOFF_REVIEW_ACTION_TO_STATE[review_action] + transition_reason = ( + f"Operator review action '{review_action}' moved lifecycle posture from " + f"'{previous_lifecycle_state}' to '{next_lifecycle_state}'." + ) + + capture_event = store.create_continuity_capture_event( + raw_content=f"Handoff review action ({review_action}): {handoff_item_id}", + explicit_signal="note", + admission_posture="TRIAGE", + admission_reason="chief_of_staff_handoff_review_action", + ) + + thread_id = None if request.thread_id is None else str(request.thread_id) + task_id = None if request.task_id is None else str(request.task_id) + body: dict[str, object] = { + "kind": _HANDOFF_REVIEW_ACTION_BODY_KIND, + "handoff_item_id": handoff_item_id, + "review_action": review_action, + "previous_lifecycle_state": previous_lifecycle_state, + "next_lifecycle_state": next_lifecycle_state, + "reason": transition_reason, + "note": note, + } + provenance: dict[str, object] = { + "thread_id": thread_id, + "task_id": task_id, + "project": project, + "person": person, + "source_event_ids": [str(capture_event["id"])], + "chief_of_staff_handoff_review_action": { + "handoff_item_id": handoff_item_id, + "review_action": review_action, + "previous_lifecycle_state": previous_lifecycle_state, + "next_lifecycle_state": next_lifecycle_state, + }, + } + + stored = store.create_continuity_object( + capture_event_id=capture_event["id"], + object_type="Note", + status="active", + title=f"Handoff review action: {review_action} ({handoff_item_id})", + body=body, + provenance=provenance, + confidence=1.0, + ) + + updated_brief = compile_chief_of_staff_priority_brief( + store, + user_id=user_id, + request=scope_request, + )["brief"] + + serialized_action: ChiefOfStaffHandoffReviewActionRecord = { + "id": str(stored["id"]), + "capture_event_id": str(stored["capture_event_id"]), + "handoff_item_id": handoff_item_id, + "review_action": review_action, + "previous_lifecycle_state": previous_lifecycle_state, + "next_lifecycle_state": next_lifecycle_state, + "reason": transition_reason, + "note": note, + "provenance_references": [ + { + "source_kind": "continuity_capture_event", + "source_id": str(capture_event["id"]), + } + ], + "created_at": stored["created_at"].isoformat(), + "updated_at": stored["updated_at"].isoformat(), + } + + review_actions = list(updated_brief["handoff_review_actions"]) + if not any(action["id"] == serialized_action["id"] for action in review_actions): + review_actions.insert(0, serialized_action) + + return { + "review_action": serialized_action, + "handoff_queue_summary": updated_brief["handoff_queue_summary"], + "handoff_queue_groups": updated_brief["handoff_queue_groups"], + "handoff_review_actions": review_actions, + } + + +def capture_chief_of_staff_execution_routing_action( + store: ContinuityStore, + *, + user_id: UUID, + request: ChiefOfStaffExecutionRoutingActionInput, +) -> ChiefOfStaffExecutionRoutingActionCaptureResponse: + handoff_item_id = _normalize_optional_text(request.handoff_item_id) + if handoff_item_id is None: + raise ChiefOfStaffValidationError("handoff_item_id must not be empty") + + route_target = _normalize_execution_route_target(request.route_target) + if route_target is None: + allowed = ", ".join(CHIEF_OF_STAFF_EXECUTION_ROUTE_TARGET_ORDER) + raise ChiefOfStaffValidationError(f"route_target must be one of: {allowed}") + + note = _normalize_optional_text(request.note) + project = _normalize_optional_text(request.project) + person = _normalize_optional_text(request.person) + scope_request = ChiefOfStaffPriorityBriefRequestInput( + thread_id=request.thread_id, + task_id=request.task_id, + project=project, + person=person, + limit=MAX_CHIEF_OF_STAFF_PRIORITY_LIMIT, + ) + scoped_brief = compile_chief_of_staff_priority_brief( + store, + user_id=user_id, + request=scope_request, + )["brief"] + routed_item = next( + ( + item + for item in scoped_brief["routed_handoff_items"] + if item["handoff_item_id"] == handoff_item_id + ), + None, + ) + if routed_item is None: + raise ChiefOfStaffValidationError( + f"handoff_item_id '{handoff_item_id}' was not found in the scoped deterministic routing list" + ) + if route_target not in routed_item["available_route_targets"]: + allowed = ", ".join(routed_item["available_route_targets"]) + raise ChiefOfStaffValidationError( + f"route_target '{route_target}' is not applicable for handoff_item_id '{handoff_item_id}'. " + f"Allowed targets: {allowed}" + ) + + previously_routed = route_target in routed_item["routed_targets"] + transition: ChiefOfStaffExecutionRoutingTransition = "reaffirmed" if previously_routed else "routed" + transition_reason = ( + f"Operator routed handoff '{handoff_item_id}' to '{route_target}' as governed draft-only execution prep; " + "explicit approval is still required before any execution." + ) + + capture_event = store.create_continuity_capture_event( + raw_content=f"Execution routing action ({route_target}): {handoff_item_id}", + explicit_signal="note", + admission_posture="TRIAGE", + admission_reason="chief_of_staff_execution_routing_action", + ) + + thread_id = None if request.thread_id is None else str(request.thread_id) + task_id = None if request.task_id is None else str(request.task_id) + body: dict[str, object] = { + "kind": _EXECUTION_ROUTING_ACTION_BODY_KIND, + "handoff_item_id": handoff_item_id, + "route_target": route_target, + "transition": transition, + "previously_routed": previously_routed, + "route_state": True, + "reason": transition_reason, + "note": note, + } + provenance: dict[str, object] = { + "thread_id": thread_id, + "task_id": task_id, + "project": project, + "person": person, + "source_event_ids": [str(capture_event["id"])], + "chief_of_staff_execution_routing_action": { + "handoff_item_id": handoff_item_id, + "route_target": route_target, + "transition": transition, + "previously_routed": previously_routed, + }, + } + + stored = store.create_continuity_object( + capture_event_id=capture_event["id"], + object_type="Note", + status="active", + title=f"Execution routing action: {route_target} ({handoff_item_id})", + body=body, + provenance=provenance, + confidence=1.0, + ) + + updated_brief = compile_chief_of_staff_priority_brief( + store, + user_id=user_id, + request=scope_request, + )["brief"] + + serialized_action: ChiefOfStaffExecutionRoutingAuditRecord = { + "id": str(stored["id"]), + "capture_event_id": str(stored["capture_event_id"]), + "handoff_item_id": handoff_item_id, + "route_target": route_target, + "transition": transition, + "previously_routed": previously_routed, + "route_state": True, + "reason": transition_reason, + "note": note, + "provenance_references": [ + { + "source_kind": "continuity_capture_event", + "source_id": str(capture_event["id"]), + } + ], + "created_at": stored["created_at"].isoformat(), + "updated_at": stored["updated_at"].isoformat(), + } + + routing_audit_trail = list(updated_brief["routing_audit_trail"]) + if not any(action["id"] == serialized_action["id"] for action in routing_audit_trail): + routing_audit_trail.insert(0, serialized_action) + + return { + "routing_action": serialized_action, + "execution_routing_summary": updated_brief["execution_routing_summary"], + "routed_handoff_items": updated_brief["routed_handoff_items"], + "routing_audit_trail": routing_audit_trail, + "execution_readiness_posture": updated_brief["execution_readiness_posture"], + } + + +def capture_chief_of_staff_handoff_outcome( + store: ContinuityStore, + *, + user_id: UUID, + request: ChiefOfStaffHandoffOutcomeCaptureInput, +) -> ChiefOfStaffHandoffOutcomeCaptureResponse: + handoff_item_id = _normalize_optional_text(request.handoff_item_id) + if handoff_item_id is None: + raise ChiefOfStaffValidationError("handoff_item_id must not be empty") + + outcome_status = _normalize_handoff_outcome_status(request.outcome_status) + if outcome_status is None: + allowed = ", ".join(CHIEF_OF_STAFF_HANDOFF_OUTCOME_STATUSES) + raise ChiefOfStaffValidationError(f"outcome_status must be one of: {allowed}") + + note = _normalize_optional_text(request.note) + project = _normalize_optional_text(request.project) + person = _normalize_optional_text(request.person) + scope_request = ChiefOfStaffPriorityBriefRequestInput( + thread_id=request.thread_id, + task_id=request.task_id, + project=project, + person=person, + limit=MAX_CHIEF_OF_STAFF_PRIORITY_LIMIT, + ) + scoped_brief = compile_chief_of_staff_priority_brief( + store, + user_id=user_id, + request=scope_request, + )["brief"] + + routed_item = next( + ( + item + for item in scoped_brief["routed_handoff_items"] + if item["handoff_item_id"] == handoff_item_id + ), + None, + ) + if routed_item is None: + raise ChiefOfStaffValidationError( + f"handoff_item_id '{handoff_item_id}' was not found in the scoped deterministic routed handoff list" + ) + if not routed_item["is_routed"]: + raise ChiefOfStaffValidationError( + f"handoff_item_id '{handoff_item_id}' has no routed targets yet; capture an explicit routing transition first" + ) + + previous_outcome = next( + ( + record + for record in scoped_brief["handoff_outcomes"] + if record["handoff_item_id"] == handoff_item_id and record["is_latest_outcome"] + ), + None, + ) + previous_outcome_status = ( + None + if previous_outcome is None + else previous_outcome["outcome_status"] + ) + + transition_reason = ( + f"Operator captured routed handoff outcome '{outcome_status}' for '{handoff_item_id}' " + "as an immutable closure-learning event." + ) + + capture_event = store.create_continuity_capture_event( + raw_content=f"Handoff outcome ({outcome_status}): {handoff_item_id}", + explicit_signal="note", + admission_posture="TRIAGE", + admission_reason="chief_of_staff_handoff_outcome", + ) + + thread_id = None if request.thread_id is None else str(request.thread_id) + task_id = None if request.task_id is None else str(request.task_id) + body: dict[str, object] = { + "kind": _HANDOFF_OUTCOME_BODY_KIND, + "handoff_item_id": handoff_item_id, + "outcome_status": outcome_status, + "previous_outcome_status": previous_outcome_status, + "reason": transition_reason, + "note": note, + } + provenance: dict[str, object] = { + "thread_id": thread_id, + "task_id": task_id, + "project": project, + "person": person, + "source_event_ids": [str(capture_event["id"])], + "chief_of_staff_handoff_outcome": { + "handoff_item_id": handoff_item_id, + "outcome_status": outcome_status, + "previous_outcome_status": previous_outcome_status, + "routed_targets": list(routed_item["routed_targets"]), + }, + } + + stored = store.create_continuity_object( + capture_event_id=capture_event["id"], + object_type="Note", + status="active", + title=f"Handoff outcome: {outcome_status} ({handoff_item_id})", + body=body, + provenance=provenance, + confidence=1.0, + ) + + updated_brief = compile_chief_of_staff_priority_brief( + store, + user_id=user_id, + request=scope_request, + )["brief"] + + serialized_outcome: ChiefOfStaffHandoffOutcomeRecord = { + "id": str(stored["id"]), + "capture_event_id": str(stored["capture_event_id"]), + "handoff_item_id": handoff_item_id, + "outcome_status": outcome_status, + "previous_outcome_status": previous_outcome_status, + "is_latest_outcome": True, + "reason": transition_reason, + "note": note, + "provenance_references": [ + { + "source_kind": "continuity_capture_event", + "source_id": str(capture_event["id"]), + } + ], + "created_at": stored["created_at"].isoformat(), + "updated_at": stored["updated_at"].isoformat(), + } + + handoff_outcomes = list(updated_brief["handoff_outcomes"]) + if not any(record["id"] == serialized_outcome["id"] for record in handoff_outcomes): + handoff_outcomes.insert(0, serialized_outcome) + + return { + "handoff_outcome": serialized_outcome, + "handoff_outcome_summary": updated_brief["handoff_outcome_summary"], + "handoff_outcomes": handoff_outcomes, + "closure_quality_summary": updated_brief["closure_quality_summary"], + "conversion_signal_summary": updated_brief["conversion_signal_summary"], + "stale_ignored_escalation_posture": updated_brief["stale_ignored_escalation_posture"], + } + + +def compile_chief_of_staff_priority_brief( + store: ContinuityStore, + *, + user_id: UUID, + request: ChiefOfStaffPriorityBriefRequestInput, +) -> ChiefOfStaffPriorityBriefResponse: + normalized_request = ChiefOfStaffPriorityBriefRequestInput( + query=_normalize_optional_text(request.query), + thread_id=request.thread_id, + task_id=request.task_id, + project=_normalize_optional_text(request.project), + person=_normalize_optional_text(request.person), + since=request.since, + until=request.until, + limit=request.limit, + ) + _validate_request(normalized_request) + + recall_payload = query_continuity_recall( + store, + user_id=user_id, + request=ContinuityRecallQueryInput( + query=normalized_request.query, + thread_id=normalized_request.thread_id, + task_id=normalized_request.task_id, + project=normalized_request.project, + person=normalized_request.person, + since=normalized_request.since, + until=normalized_request.until, + limit=MAX_CONTINUITY_RECALL_LIMIT, + ), + apply_limit=False, + ) + + open_loop_dashboard = compile_continuity_open_loop_dashboard( + store, + user_id=user_id, + request=ContinuityOpenLoopDashboardQueryInput( + query=normalized_request.query, + thread_id=normalized_request.thread_id, + task_id=normalized_request.task_id, + project=normalized_request.project, + person=normalized_request.person, + since=normalized_request.since, + until=normalized_request.until, + limit=MAX_CONTINUITY_OPEN_LOOP_LIMIT, + ), + )["dashboard"] + weekly_review = compile_continuity_weekly_review( + store, + user_id=user_id, + request=ContinuityWeeklyReviewRequestInput( + query=normalized_request.query, + thread_id=normalized_request.thread_id, + task_id=normalized_request.task_id, + project=normalized_request.project, + person=normalized_request.person, + since=normalized_request.since, + until=normalized_request.until, + limit=min(normalized_request.limit, MAX_CONTINUITY_WEEKLY_REVIEW_LIMIT), + ), + )["review"] + + resumption_brief = compile_continuity_resumption_brief( + store, + user_id=user_id, + request=ContinuityResumptionBriefRequestInput( + query=normalized_request.query, + thread_id=normalized_request.thread_id, + task_id=normalized_request.task_id, + project=normalized_request.project, + person=normalized_request.person, + since=normalized_request.since, + until=normalized_request.until, + max_recent_changes=MAX_CONTINUITY_RESUMPTION_RECENT_CHANGES_LIMIT, + max_open_loops=MAX_CONTINUITY_RESUMPTION_OPEN_LOOP_LIMIT, + ), + )["brief"] + recent_changes_items = resumption_brief.get("recent_changes", {}).get("items", []) # type: ignore[call-overload] + open_loop_items = resumption_brief.get("open_loops", {}).get("items", []) # type: ignore[call-overload] + resumption_last_decision_item = resumption_brief.get("last_decision", {}).get("item") # type: ignore[call-overload] + resumption_next_action_item = resumption_brief.get("next_action", {}).get("item") # type: ignore[call-overload] + + trust_dashboard = get_memory_trust_dashboard_summary( + store, + user_id=user_id, + )["dashboard"] + + quality_gate_status = trust_dashboard["quality_gate"]["status"] + retrieval_status = trust_dashboard["retrieval_quality"]["status"] + trust_cap = _trust_confidence_cap( + quality_gate_status=quality_gate_status, + retrieval_status=retrieval_status, + ) + + open_loop_posture_by_id = _build_open_loop_posture_map(open_loop_dashboard) + recent_change_index_by_id: dict[str, int] = { + item["id"]: index + for index, item in enumerate(recent_changes_items) + } + resumption_next_action_id = ( + None + if resumption_next_action_item is None + else resumption_next_action_item["id"] + ) + + candidate_items = [ + item + for item in recall_payload["items"] + if item["object_type"] in _ACTIONABLE_OBJECT_TYPES + ] + + created_at_values = [_parse_timestamp(item["created_at"]) for item in candidate_items] + latest_created_at = max(created_at_values) if created_at_values else None + + scored_items: list[tuple[float, datetime, ChiefOfStaffPriorityItem]] = [] + follow_through_candidates: list[ChiefOfStaffFollowThroughItem] = [] + + for item in candidate_items: + item_id = item["id"] + item_created_at = _parse_timestamp(item["created_at"]) + age_hours = ( + 0.0 + if latest_created_at is None + else _age_hours_relative_to_latest( + latest_created_at=latest_created_at, + item_created_at=item_created_at, + ) + ) + open_loop_posture = open_loop_posture_by_id.get(item_id) + recent_change_index = recent_change_index_by_id.get(item_id) + is_resumption_next_action = item_id == resumption_next_action_id + + posture = _derive_priority_posture( + item=item, + open_loop_posture=open_loop_posture, + is_resumption_next_action=is_resumption_next_action, + recent_change_index=recent_change_index, + ) + + score = _ranking_score( + item=item, + posture=posture, + age_hours=age_hours, + is_resumption_next_action=is_resumption_next_action, + recent_change_index=recent_change_index, + ) + + base_confidence_score = _confidence_score(item) + base_confidence_posture = _confidence_posture_from_score(base_confidence_score) + confidence_posture = _clamp_confidence_posture( + base_confidence_posture, + trust_cap.posture, + ) + downgraded_by_trust = confidence_posture != base_confidence_posture + + reasons = [_posture_reason(posture)] + if recent_change_index is not None: + reasons.append( + f"Appears in recent continuity changes at rank {recent_change_index + 1}." + ) + if posture in {"waiting", "blocked", "stale"} and age_hours > 0: + reasons.append( + f"Aging evidence: {age_hours:.1f}h older than the newest scoped priority candidate." + ) + if downgraded_by_trust: + reasons.append("Confidence is explicitly downgraded by current memory trust posture.") + reasons.append("Provenance references are attached from continuity recall evidence.") + + scored_items.append( + ( + score, + item_created_at, + { + "rank": 0, + "id": item_id, + "capture_event_id": item["capture_event_id"], + "object_type": item["object_type"], + "status": item["status"], + "title": item["title"], + "priority_posture": posture, + "confidence_posture": confidence_posture, + "confidence": round(float(item["confidence"]), 6), + "score": score, + "provenance": item["provenance"], + "created_at": item["created_at"], + "updated_at": item["updated_at"], + "rationale": { + "reasons": reasons, + "ranking_inputs": { + "posture": posture, + "open_loop_posture": open_loop_posture, + "recency_rank": None if recent_change_index is None else recent_change_index + 1, + "age_hours_relative_to_latest": age_hours, + "recall_relevance": round(float(item["relevance"]), 6), + "scope_match_count": item["ordering"]["scope_match_count"], + "query_term_match_count": item["ordering"]["query_term_match_count"], + "freshness_posture": item["ordering"]["freshness_posture"], + "provenance_posture": item["ordering"]["provenance_posture"], + "supersession_posture": item["ordering"]["supersession_posture"], + }, + "provenance_references": item["provenance_references"], + "trust_signals": { + "quality_gate_status": quality_gate_status, + "retrieval_status": retrieval_status, + "trust_confidence_cap": trust_cap.posture, + "downgraded_by_trust": downgraded_by_trust, + "reason": trust_cap.reason, + }, + }, + }, + ) + ) + + follow_through_posture, recommendation_action, follow_through_reason = _classify_follow_through_item( + item=item, + open_loop_posture=open_loop_posture, + age_hours=age_hours, + priority_posture=posture, + ) + if ( + follow_through_posture is not None + and recommendation_action is not None + and follow_through_reason is not None + ): + if recommendation_action not in CHIEF_OF_STAFF_FOLLOW_THROUGH_RECOMMENDATION_ACTIONS: + recommendation_action = "defer" + if follow_through_posture not in CHIEF_OF_STAFF_FOLLOW_THROUGH_POSTURE_ORDER: + follow_through_posture = "overdue" + follow_through_candidates.append( + { + "rank": 0, + "id": item_id, + "capture_event_id": item["capture_event_id"], + "object_type": item["object_type"], + "status": item["status"], + "title": item["title"], + "current_priority_posture": posture, + "follow_through_posture": follow_through_posture, + "recommendation_action": recommendation_action, + "reason": follow_through_reason, + "age_hours": age_hours, + "provenance_references": item["provenance_references"], + "created_at": item["created_at"], + "updated_at": item["updated_at"], + } + ) + + scored_items.sort( + key=lambda entry: (entry[0], entry[1], entry[2]["id"]), + reverse=True, + ) + + total_count = len(scored_items) + limit = normalized_request.limit + selected_items = scored_items[:limit] if limit > 0 else [] + + ranked_items: list[ChiefOfStaffPriorityItem] = [] + for rank, (_, _, item) in enumerate(selected_items, start=1): + ranked_item = dict(item) + ranked_item["rank"] = rank + ranked_items.append(ranked_item) # type: ignore[arg-type] + + recommended_next_action = _build_recommended_action( + ranked_items=ranked_items, + trust_cap=trust_cap.posture, + ) + + overdue_items_all = [ + item for item in follow_through_candidates if item["follow_through_posture"] == "overdue" + ] + stale_waiting_for_items_all = [ + item for item in follow_through_candidates if item["follow_through_posture"] == "stale_waiting_for" + ] + slipped_commitments_all = [ + item for item in follow_through_candidates if item["follow_through_posture"] == "slipped_commitment" + ] + + overdue_items = _rank_follow_through_items(overdue_items_all, limit=limit) + stale_waiting_for_items = _rank_follow_through_items(stale_waiting_for_items_all, limit=limit) + slipped_commitments = _rank_follow_through_items(slipped_commitments_all, limit=limit) + + all_follow_through_items = sorted( + follow_through_candidates, + key=_draft_follow_up_sort_key, + reverse=True, + ) + escalation_posture = _build_escalation_posture( + all_follow_through_items=all_follow_through_items, + ) + scope_thread_id = recall_payload["summary"]["filters"].get("thread_id") + thread_hint = None if scope_thread_id is None else str(scope_thread_id) + draft_follow_up = _build_draft_follow_up( + all_follow_through_items=all_follow_through_items, + thread_hint=thread_hint, + ) + top_ranked_priority = ranked_items[0] if ranked_items else None + preparation_brief = _build_preparation_brief( + recall_items=recall_payload["items"], + scope=recall_payload["summary"]["filters"], + last_decision=resumption_last_decision_item, + open_loops=open_loop_items, + next_action=resumption_next_action_item, + confidence_posture=trust_cap.posture, + confidence_reason=trust_cap.reason, + ) + what_changed_summary = _build_what_changed_summary( + recent_changes=recent_changes_items, + confidence_posture=trust_cap.posture, + confidence_reason=trust_cap.reason, + ) + prep_checklist = _build_prep_checklist( + last_decision=resumption_last_decision_item, + open_loops=open_loop_items, + next_action=resumption_next_action_item, + confidence_posture=trust_cap.posture, + confidence_reason=trust_cap.reason, + ) + suggested_talking_points = _build_suggested_talking_points( + last_decision=resumption_last_decision_item, + top_ranked_priority=top_ranked_priority, + open_loops=open_loop_items, + confidence_posture=trust_cap.posture, + confidence_reason=trust_cap.reason, + ) + resumption_supervision = _build_resumption_supervision( + recommended_next_action=recommended_next_action, + follow_through_items=all_follow_through_items, + trust_cap=trust_cap, + ) + weekly_review_brief = _build_weekly_review_brief( + scope=weekly_review["scope"], + weekly_rollup=weekly_review["rollup"], + follow_through_items=all_follow_through_items, + ) + all_outcome_items = _list_recommendation_outcome_records(recall_payload["items"]) + recommendation_outcomes = _build_recommendation_outcome_section( + all_outcome_items=all_outcome_items, + limit=min(limit, _OUTCOME_HISTORY_LIMIT), + ) + priority_learning_summary = _build_priority_learning_summary( + all_outcome_items=all_outcome_items, + ) + pattern_drift_summary = _build_pattern_drift_summary( + learning_summary=priority_learning_summary, + ) + ( + action_handoff_brief, + handoff_items, + task_draft, + approval_draft, + execution_posture, + ) = _build_action_handoff_artifacts( + recommended_next_action=recommended_next_action, + all_follow_through_items=all_follow_through_items, + prep_checklist=prep_checklist, + weekly_review_brief=weekly_review_brief, + trust_cap=trust_cap, + scope=recall_payload["summary"]["filters"], + ) + handoff_review_actions = _list_handoff_review_action_records(recall_payload["items"]) + handoff_queue_summary, handoff_queue_groups = _build_handoff_queue( + store=store, + handoff_items=handoff_items, + recall_items=recall_payload["items"], + all_follow_through_items=all_follow_through_items, + handoff_review_actions=handoff_review_actions, + ) + all_handoff_outcomes = _list_handoff_outcome_records(recall_payload["items"]) + ( + handoff_outcome_summary, + handoff_outcomes, + latest_handoff_outcome_status_counts, + ) = _build_handoff_outcome_artifacts( + all_handoff_outcomes=all_handoff_outcomes, + limit=min(limit, _OUTCOME_HISTORY_LIMIT), + ) + closure_quality_summary = _build_closure_quality_summary( + handoff_outcome_summary=handoff_outcome_summary, + latest_status_counts=latest_handoff_outcome_status_counts, + ) + conversion_signal_summary = _build_conversion_signal_summary( + total_handoff_count=len(handoff_items), + handoff_outcome_summary=handoff_outcome_summary, + latest_status_counts=latest_handoff_outcome_status_counts, + ) + stale_ignored_escalation_posture = _build_stale_ignored_escalation_posture( + handoff_queue_summary=handoff_queue_summary, + latest_status_counts=latest_handoff_outcome_status_counts, + ) + routing_audit_trail = _list_execution_routing_audit_records(recall_payload["items"]) + ( + execution_routing_summary, + routed_handoff_items, + execution_readiness_posture, + ) = _build_execution_routing_artifacts( + handoff_items=handoff_items, + routing_audit_trail=routing_audit_trail, + draft_follow_up=draft_follow_up, + execution_posture=execution_posture, + ) + + summary: ChiefOfStaffPrioritySummary = { + "limit": limit, + "returned_count": len(ranked_items), + "total_count": total_count, + "posture_order": list(CHIEF_OF_STAFF_PRIORITY_POSTURE_ORDER), + "order": list(CHIEF_OF_STAFF_PRIORITY_ITEM_ORDER), + "follow_through_posture_order": list(CHIEF_OF_STAFF_FOLLOW_THROUGH_POSTURE_ORDER), + "follow_through_item_order": list(CHIEF_OF_STAFF_FOLLOW_THROUGH_ITEM_ORDER), + "follow_through_total_count": len(follow_through_candidates), + "overdue_count": len(overdue_items_all), + "stale_waiting_for_count": len(stale_waiting_for_items_all), + "slipped_commitment_count": len(slipped_commitments_all), + "trust_confidence_posture": trust_cap.posture, + "trust_confidence_reason": trust_cap.reason, + "quality_gate_status": quality_gate_status, + "retrieval_status": retrieval_status, + "handoff_item_count": len(handoff_items), + "handoff_item_order": list(CHIEF_OF_STAFF_ACTION_HANDOFF_ITEM_ORDER), + "execution_posture_order": list(CHIEF_OF_STAFF_EXECUTION_POSTURE_ORDER), + "handoff_queue_total_count": handoff_queue_summary["total_count"], + "handoff_queue_ready_count": handoff_queue_summary["ready_count"], + "handoff_queue_pending_approval_count": handoff_queue_summary["pending_approval_count"], + "handoff_queue_executed_count": handoff_queue_summary["executed_count"], + "handoff_queue_stale_count": handoff_queue_summary["stale_count"], + "handoff_queue_expired_count": handoff_queue_summary["expired_count"], + "handoff_queue_state_order": list(handoff_queue_summary["state_order"]), + "handoff_queue_group_order": list(handoff_queue_summary["group_order"]), + "handoff_queue_item_order": list(handoff_queue_summary["item_order"]), + "handoff_outcome_total_count": handoff_outcome_summary["total_count"], + "handoff_outcome_latest_count": handoff_outcome_summary["latest_total_count"], + "handoff_outcome_executed_count": handoff_outcome_summary["latest_status_counts"]["executed"], + "handoff_outcome_ignored_count": handoff_outcome_summary["latest_status_counts"]["ignored"], + "closure_quality_posture": closure_quality_summary["posture"], + "stale_ignored_escalation_posture": stale_ignored_escalation_posture["posture"], + } + + brief: ChiefOfStaffPriorityBriefRecord = { + "assembly_version": CHIEF_OF_STAFF_PRIORITY_BRIEF_ASSEMBLY_VERSION_V0, + "scope": recall_payload["summary"]["filters"], + "ranked_items": ranked_items, + "overdue_items": overdue_items, + "stale_waiting_for_items": stale_waiting_for_items, + "slipped_commitments": slipped_commitments, + "escalation_posture": escalation_posture, + "draft_follow_up": draft_follow_up, + "recommended_next_action": recommended_next_action, + "preparation_brief": preparation_brief, + "what_changed_summary": what_changed_summary, + "prep_checklist": prep_checklist, + "suggested_talking_points": suggested_talking_points, + "resumption_supervision": resumption_supervision, + "weekly_review_brief": weekly_review_brief, + "recommendation_outcomes": recommendation_outcomes, + "priority_learning_summary": priority_learning_summary, + "pattern_drift_summary": pattern_drift_summary, + "action_handoff_brief": action_handoff_brief, + "handoff_items": handoff_items, + "handoff_queue_summary": handoff_queue_summary, + "handoff_queue_groups": handoff_queue_groups, + "handoff_review_actions": handoff_review_actions, + "handoff_outcome_summary": handoff_outcome_summary, + "handoff_outcomes": handoff_outcomes, + "closure_quality_summary": closure_quality_summary, + "conversion_signal_summary": conversion_signal_summary, + "stale_ignored_escalation_posture": stale_ignored_escalation_posture, + "execution_routing_summary": execution_routing_summary, + "routed_handoff_items": routed_handoff_items, + "routing_audit_trail": routing_audit_trail, + "execution_readiness_posture": execution_readiness_posture, + "task_draft": task_draft, + "approval_draft": approval_draft, + "execution_posture": execution_posture, + "summary": summary, + "sources": [ + "continuity_recall", + "continuity_open_loops", + "continuity_weekly_review", + "continuity_resumption_brief", + "chief_of_staff_recommendation_outcomes", + "chief_of_staff_action_handoff", + "chief_of_staff_handoff_queue", + "chief_of_staff_handoff_review_actions", + "chief_of_staff_handoff_outcomes", + "chief_of_staff_execution_routing", + "memory_trust_dashboard", + "memories", + "memory_review_labels", + ], + } + + return {"brief": brief} + + +def build_default_chief_of_staff_priority_request() -> ChiefOfStaffPriorityBriefRequestInput: + return ChiefOfStaffPriorityBriefRequestInput(limit=DEFAULT_CHIEF_OF_STAFF_PRIORITY_LIMIT) diff --git a/apps/api/src/alicebot_api/cli.py b/apps/api/src/alicebot_api/cli.py new file mode 100644 index 0000000..213e58e --- /dev/null +++ b/apps/api/src/alicebot_api/cli.py @@ -0,0 +1,694 @@ +from __future__ import annotations + +import argparse +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from datetime import datetime +import json +import os +import sys +from uuid import UUID + +import psycopg + +from alicebot_api.cli_formatting import ( + format_capture_output, + format_lifecycle_detail_output, + format_lifecycle_list_output, + format_open_loops_output, + format_recall_output, + format_resume_output, + format_review_apply_output, + format_review_detail_output, + format_review_queue_output, + format_status_output, +) +from alicebot_api.config import Settings, get_settings +from alicebot_api.continuity_capture import ( + ContinuityCaptureValidationError, + capture_continuity_input, +) +from alicebot_api.continuity_objects import ( + default_continuity_promotable, + default_continuity_searchable, +) +from alicebot_api.continuity_lifecycle import ( + ContinuityLifecycleNotFoundError, + ContinuityLifecycleValidationError, + get_continuity_lifecycle_state, + list_continuity_lifecycle_state, +) +from alicebot_api.continuity_open_loops import ( + ContinuityOpenLoopValidationError, + compile_continuity_open_loop_dashboard, +) +from alicebot_api.continuity_recall import ( + ContinuityRecallValidationError, + query_continuity_recall, +) +from alicebot_api.continuity_resumption import ( + ContinuityResumptionValidationError, + compile_continuity_resumption_brief, +) +from alicebot_api.continuity_review import ( + ContinuityReviewNotFoundError, + ContinuityReviewValidationError, + apply_continuity_correction, + get_continuity_review_detail, + list_continuity_review_queue, +) +from alicebot_api.contracts import ( + CONTINUITY_CAPTURE_EXPLICIT_SIGNALS, + CONTINUITY_CORRECTION_ACTIONS, + DEFAULT_CONTINUITY_LIFECYCLE_LIMIT, + DEFAULT_CONTINUITY_OPEN_LOOP_LIMIT, + DEFAULT_CONTINUITY_RECALL_LIMIT, + DEFAULT_CONTINUITY_RESUMPTION_OPEN_LOOP_LIMIT, + DEFAULT_CONTINUITY_RESUMPTION_RECENT_CHANGES_LIMIT, + DEFAULT_CONTINUITY_REVIEW_LIMIT, + MAX_CONTINUITY_OPEN_LOOP_LIMIT, + MAX_CONTINUITY_RECALL_LIMIT, + MAX_CONTINUITY_LIFECYCLE_LIMIT, + MAX_CONTINUITY_RESUMPTION_OPEN_LOOP_LIMIT, + MAX_CONTINUITY_RESUMPTION_RECENT_CHANGES_LIMIT, + MAX_CONTINUITY_REVIEW_LIMIT, + ContinuityCaptureCreateInput, + ContinuityCorrectionInput, + ContinuityLifecycleQueryInput, + ContinuityOpenLoopDashboardQueryInput, + ContinuityRecallQueryInput, + ContinuityResumptionBriefRequestInput, + ContinuityReviewQueueQueryInput, +) +from alicebot_api.db import ping_database, user_connection +from alicebot_api.retrieval_evaluation import get_retrieval_evaluation_summary +from alicebot_api.store import ContinuityStore, JsonObject + +DEFAULT_CLI_USER_ID = "00000000-0000-0000-0000-000000000001" +REVIEW_STATUS_CHOICES = ("correction_ready", "active", "stale", "superseded", "deleted", "all") + + +@dataclass(frozen=True, slots=True) +class CLIContext: + settings: Settings + database_url: str + user_id: UUID + + +def _parse_uuid(value: str) -> UUID: + try: + return UUID(value) + except ValueError as exc: + raise argparse.ArgumentTypeError(f"invalid UUID value: {value}") from exc + + +def _parse_datetime(value: str) -> datetime: + normalized = value.strip() + if normalized.endswith("Z"): + normalized = normalized[:-1] + "+00:00" + try: + return datetime.fromisoformat(normalized) + except ValueError as exc: + raise argparse.ArgumentTypeError( + f"invalid datetime value '{value}'. Use ISO-8601 format." + ) from exc + + +def _parse_optional_json_object(raw_value: str | None, *, option_name: str) -> JsonObject | None: + if raw_value is None: + return None + try: + payload = json.loads(raw_value) + except json.JSONDecodeError as exc: + raise ValueError(f"{option_name} must be valid JSON") from exc + if not isinstance(payload, dict): + raise ValueError(f"{option_name} must be a JSON object") + return payload + + +def _add_scope_filter_arguments(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--query", default=None, help="Optional query text.") + parser.add_argument("--thread-id", type=_parse_uuid, default=None, help="Optional thread UUID scope.") + parser.add_argument("--task-id", type=_parse_uuid, default=None, help="Optional task UUID scope.") + parser.add_argument("--project", default=None, help="Optional project scope.") + parser.add_argument("--person", default=None, help="Optional person scope.") + parser.add_argument("--since", type=_parse_datetime, default=None, help="Optional start time (ISO-8601).") + parser.add_argument("--until", type=_parse_datetime, default=None, help="Optional end time (ISO-8601).") + + +def _resolve_user_id(settings: Settings, user_id_flag: str | None) -> UUID: + if user_id_flag is not None: + return _parse_uuid(user_id_flag) + if settings.auth_user_id != "": + return UUID(settings.auth_user_id) + return UUID(os.getenv("ALICEBOT_AUTH_USER_ID", DEFAULT_CLI_USER_ID)) + + +def _build_context(args: argparse.Namespace) -> CLIContext: + settings = get_settings() + database_url = args.database_url if args.database_url is not None else settings.database_url + user_id = _resolve_user_id(settings, args.user_id) + return CLIContext(settings=settings, database_url=database_url, user_id=user_id) + + +@contextmanager +def _store_context(ctx: CLIContext) -> Iterator[ContinuityStore]: + with user_connection(ctx.database_url, ctx.user_id) as conn: + yield ContinuityStore(conn) + + +def _run_capture(ctx: CLIContext, args: argparse.Namespace) -> str: + raw_content = " ".join(args.raw_content).strip() + with _store_context(ctx) as store: + payload = capture_continuity_input( + store, + user_id=ctx.user_id, + request=ContinuityCaptureCreateInput( + raw_content=raw_content, + explicit_signal=args.explicit_signal, + ), + ) + return format_capture_output(payload) + + +def _run_recall(ctx: CLIContext, args: argparse.Namespace) -> str: + with _store_context(ctx) as store: + payload = query_continuity_recall( + store, + user_id=ctx.user_id, + request=ContinuityRecallQueryInput( + query=args.query, + thread_id=args.thread_id, + task_id=args.task_id, + project=args.project, + person=args.person, + since=args.since, + until=args.until, + limit=args.limit, + ), + ) + return format_recall_output(payload) + + +def _run_lifecycle_list(ctx: CLIContext, args: argparse.Namespace) -> str: + with _store_context(ctx) as store: + payload = list_continuity_lifecycle_state( + store, + user_id=ctx.user_id, + request=ContinuityLifecycleQueryInput(limit=args.limit), + ) + return format_lifecycle_list_output(payload) + + +def _run_lifecycle_show(ctx: CLIContext, args: argparse.Namespace) -> str: + with _store_context(ctx) as store: + payload = get_continuity_lifecycle_state( + store, + user_id=ctx.user_id, + continuity_object_id=args.continuity_object_id, + ) + return format_lifecycle_detail_output(payload) + + +def _run_resume(ctx: CLIContext, args: argparse.Namespace) -> str: + with _store_context(ctx) as store: + payload = compile_continuity_resumption_brief( + store, + user_id=ctx.user_id, + request=ContinuityResumptionBriefRequestInput( + query=args.query, + thread_id=args.thread_id, + task_id=args.task_id, + project=args.project, + person=args.person, + since=args.since, + until=args.until, + max_recent_changes=args.max_recent_changes, + max_open_loops=args.max_open_loops, + include_non_promotable_facts=args.include_non_promotable_facts, + ), + ) + return format_resume_output(payload) + + +def _run_open_loops(ctx: CLIContext, args: argparse.Namespace) -> str: + with _store_context(ctx) as store: + payload = compile_continuity_open_loop_dashboard( + store, + user_id=ctx.user_id, + request=ContinuityOpenLoopDashboardQueryInput( + query=args.query, + thread_id=args.thread_id, + task_id=args.task_id, + project=args.project, + person=args.person, + since=args.since, + until=args.until, + limit=args.limit, + ), + ) + return format_open_loops_output(payload) + + +def _run_review_queue(ctx: CLIContext, args: argparse.Namespace) -> str: + with _store_context(ctx) as store: + payload = list_continuity_review_queue( + store, + user_id=ctx.user_id, + request=ContinuityReviewQueueQueryInput( + status=args.status, + limit=args.limit, + ), + ) + return format_review_queue_output(payload) + + +def _run_review_show(ctx: CLIContext, args: argparse.Namespace) -> str: + with _store_context(ctx) as store: + payload = get_continuity_review_detail( + store, + user_id=ctx.user_id, + continuity_object_id=args.continuity_object_id, + ) + return format_review_detail_output(payload) + + +def _run_review_apply(ctx: CLIContext, args: argparse.Namespace) -> str: + body = _parse_optional_json_object(args.body_json, option_name="--body-json") + provenance = _parse_optional_json_object(args.provenance_json, option_name="--provenance-json") + replacement_body = _parse_optional_json_object( + args.replacement_body_json, + option_name="--replacement-body-json", + ) + replacement_provenance = _parse_optional_json_object( + args.replacement_provenance_json, + option_name="--replacement-provenance-json", + ) + + with _store_context(ctx) as store: + payload = apply_continuity_correction( + store, + user_id=ctx.user_id, + continuity_object_id=args.continuity_object_id, + request=ContinuityCorrectionInput( + action=args.action, + reason=args.reason, + title=args.title, + body=body, + provenance=provenance, + confidence=args.confidence, + replacement_title=args.replacement_title, + replacement_body=replacement_body, + replacement_provenance=replacement_provenance, + replacement_confidence=args.replacement_confidence, + ), + ) + return format_review_apply_output(payload) + + +def _run_status(ctx: CLIContext, _args: argparse.Namespace) -> str: + database_reachable = ping_database( + ctx.database_url, + timeout_seconds=ctx.settings.healthcheck_timeout_seconds, + ) + + status_payload: dict[str, object] = { + "user_id": str(ctx.user_id), + "database_status": "reachable" if database_reachable else "unreachable", + "continuity_capture_events": 0, + "continuity_objects_total": 0, + "continuity_objects_active": 0, + "continuity_objects_stale": 0, + "continuity_objects_superseded": 0, + "continuity_objects_deleted": 0, + "continuity_objects_searchable": 0, + "continuity_objects_non_searchable": 0, + "continuity_objects_promotable": 0, + "continuity_objects_non_promotable": 0, + "review_correction_ready": 0, + "review_active": 0, + "review_stale": 0, + "review_superseded": 0, + "review_deleted": 0, + "open_loops_total": 0, + "open_loops_waiting_for": 0, + "open_loops_blocker": 0, + "open_loops_stale": 0, + "open_loops_next_action": 0, + "retrieval_eval_status": "unknown", + "retrieval_precision_at_k_mean": "0.000", + "retrieval_precision_at_1_mean": "0.000", + } + if not database_reachable: + return format_status_output(status_payload) + + with _store_context(ctx) as store: + review_counts = { + "active": store.count_continuity_review_queue(statuses=["active"]), + "stale": store.count_continuity_review_queue(statuses=["stale"]), + "superseded": store.count_continuity_review_queue(statuses=["superseded"]), + "deleted": store.count_continuity_review_queue(statuses=["deleted"]), + } + + recall_candidates = store.list_continuity_recall_candidates() + object_status_counts = { + "active": 0, + "stale": 0, + "superseded": 0, + "deleted": 0, + } + for candidate in recall_candidates: + status = str(candidate["status"]) + if status in object_status_counts: + object_status_counts[status] += 1 + + open_loops = compile_continuity_open_loop_dashboard( + store, + user_id=ctx.user_id, + request=ContinuityOpenLoopDashboardQueryInput(limit=0), + ) + open_loop_dashboard = open_loops["dashboard"] + + retrieval_summary = get_retrieval_evaluation_summary( + store, + user_id=ctx.user_id, + )["summary"] + + status_payload.update( + { + "continuity_capture_events": store.count_continuity_capture_events(), + "continuity_objects_total": len(recall_candidates), + "continuity_objects_active": object_status_counts["active"], + "continuity_objects_stale": object_status_counts["stale"], + "continuity_objects_superseded": object_status_counts["superseded"], + "continuity_objects_deleted": object_status_counts["deleted"], + "continuity_objects_searchable": sum( + 1 + for candidate in recall_candidates + if bool( + candidate.get( + "is_searchable", + default_continuity_searchable(str(candidate["object_type"])), + ) + ) + ), + "continuity_objects_non_searchable": sum( + 1 + for candidate in recall_candidates + if not bool( + candidate.get( + "is_searchable", + default_continuity_searchable(str(candidate["object_type"])), + ) + ) + ), + "continuity_objects_promotable": sum( + 1 + for candidate in recall_candidates + if bool( + candidate.get( + "is_promotable", + default_continuity_promotable(str(candidate["object_type"])), + ) + ) + ), + "continuity_objects_non_promotable": sum( + 1 + for candidate in recall_candidates + if not bool( + candidate.get( + "is_promotable", + default_continuity_promotable(str(candidate["object_type"])), + ) + ) + ), + "review_correction_ready": review_counts["active"] + review_counts["stale"], + "review_active": review_counts["active"], + "review_stale": review_counts["stale"], + "review_superseded": review_counts["superseded"], + "review_deleted": review_counts["deleted"], + "open_loops_total": open_loop_dashboard["summary"]["total_count"], + "open_loops_waiting_for": open_loop_dashboard["waiting_for"]["summary"]["total_count"], + "open_loops_blocker": open_loop_dashboard["blocker"]["summary"]["total_count"], + "open_loops_stale": open_loop_dashboard["stale"]["summary"]["total_count"], + "open_loops_next_action": open_loop_dashboard["next_action"]["summary"]["total_count"], + "retrieval_eval_status": retrieval_summary["status"], + "retrieval_precision_at_k_mean": f"{retrieval_summary['precision_at_k_mean']:.3f}", + "retrieval_precision_at_1_mean": f"{retrieval_summary['precision_at_1_mean']:.3f}", + } + ) + + return format_status_output(status_payload) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="alicebot", + description="Deterministic local CLI for Alice continuity workflows.", + ) + parser.add_argument( + "--database-url", + default=None, + help="Override database URL. Defaults to settings/env DATABASE_URL.", + ) + parser.add_argument( + "--user-id", + default=None, + help=( + "Override acting user UUID. Defaults to ALICEBOT_AUTH_USER_ID when set, " + f"otherwise {DEFAULT_CLI_USER_ID}." + ), + ) + + subparsers = parser.add_subparsers(dest="command", required=True) + + capture_parser = subparsers.add_parser("capture", help="Capture continuity input.") + capture_parser.add_argument("raw_content", nargs="+", help="Raw continuity text to capture.") + capture_parser.add_argument( + "--explicit-signal", + choices=CONTINUITY_CAPTURE_EXPLICIT_SIGNALS, + default=None, + help="Optional explicit signal for deterministic derivation.", + ) + capture_parser.set_defaults(handler=_run_capture) + + recall_parser = subparsers.add_parser("recall", help="Recall continuity objects.") + _add_scope_filter_arguments(recall_parser) + recall_parser.add_argument( + "--limit", + type=int, + default=DEFAULT_CONTINUITY_RECALL_LIMIT, + help=f"Max results (1-{MAX_CONTINUITY_RECALL_LIMIT}).", + ) + recall_parser.set_defaults(handler=_run_recall) + + lifecycle_parser = subparsers.add_parser("lifecycle", help="Inspect continuity lifecycle state.") + lifecycle_subparsers = lifecycle_parser.add_subparsers(dest="lifecycle_command", required=True) + + lifecycle_list_parser = lifecycle_subparsers.add_parser("list", help="List lifecycle states.") + lifecycle_list_parser.add_argument( + "--limit", + type=int, + default=DEFAULT_CONTINUITY_LIFECYCLE_LIMIT, + help=f"Max lifecycle results (1-{MAX_CONTINUITY_LIFECYCLE_LIMIT}).", + ) + lifecycle_list_parser.set_defaults(handler=_run_lifecycle_list) + + lifecycle_show_parser = lifecycle_subparsers.add_parser("show", help="Show one lifecycle state.") + lifecycle_show_parser.add_argument( + "continuity_object_id", + type=_parse_uuid, + help="Continuity object UUID.", + ) + lifecycle_show_parser.set_defaults(handler=_run_lifecycle_show) + + resume_parser = subparsers.add_parser("resume", help="Compile continuity resumption brief.") + _add_scope_filter_arguments(resume_parser) + resume_parser.add_argument( + "--max-recent-changes", + type=int, + default=DEFAULT_CONTINUITY_RESUMPTION_RECENT_CHANGES_LIMIT, + help=f"Recent change limit (0-{MAX_CONTINUITY_RESUMPTION_RECENT_CHANGES_LIMIT}).", + ) + resume_parser.add_argument( + "--max-open-loops", + type=int, + default=DEFAULT_CONTINUITY_RESUMPTION_OPEN_LOOP_LIMIT, + help=f"Open loop limit (0-{MAX_CONTINUITY_RESUMPTION_OPEN_LOOP_LIMIT}).", + ) + resume_parser.add_argument( + "--include-non-promotable-facts", + action="store_true", + help="Include searchable but non-promotable facts in recent changes.", + ) + resume_parser.set_defaults(handler=_run_resume) + + open_loops_parser = subparsers.add_parser( + "open-loops", + help="List open-loop dashboard grouped by posture.", + ) + _add_scope_filter_arguments(open_loops_parser) + open_loops_parser.add_argument( + "--limit", + type=int, + default=DEFAULT_CONTINUITY_OPEN_LOOP_LIMIT, + help=f"Per-posture item limit (0-{MAX_CONTINUITY_OPEN_LOOP_LIMIT}).", + ) + open_loops_parser.set_defaults(handler=_run_open_loops) + + review_parser = subparsers.add_parser("review", help="Review queue and correction commands.") + review_subparsers = review_parser.add_subparsers(dest="review_command", required=True) + + review_queue_parser = review_subparsers.add_parser("queue", help="List review queue.") + review_queue_parser.add_argument( + "--status", + choices=REVIEW_STATUS_CHOICES, + default="correction_ready", + help="Queue status filter.", + ) + review_queue_parser.add_argument( + "--limit", + type=int, + default=DEFAULT_CONTINUITY_REVIEW_LIMIT, + help=f"Max queue results (1-{MAX_CONTINUITY_REVIEW_LIMIT}).", + ) + review_queue_parser.set_defaults(handler=_run_review_queue) + + review_show_parser = review_subparsers.add_parser("show", help="Show detail for one review object.") + review_show_parser.add_argument("continuity_object_id", type=_parse_uuid, help="Continuity object UUID.") + review_show_parser.set_defaults(handler=_run_review_show) + + review_apply_parser = review_subparsers.add_parser("apply", help="Apply a continuity correction.") + review_apply_parser.add_argument("continuity_object_id", type=_parse_uuid, help="Continuity object UUID.") + review_apply_parser.add_argument( + "--action", + required=True, + choices=CONTINUITY_CORRECTION_ACTIONS, + help="Correction action.", + ) + review_apply_parser.add_argument("--reason", default=None, help="Optional correction reason.") + review_apply_parser.add_argument("--title", default=None, help="Replacement title for edit.") + review_apply_parser.add_argument( + "--body-json", + default=None, + help="JSON object payload for body replacement on edit.", + ) + review_apply_parser.add_argument( + "--provenance-json", + default=None, + help="JSON object payload for provenance replacement on edit.", + ) + review_apply_parser.add_argument( + "--confidence", + type=float, + default=None, + help="Updated confidence for edit/supersede.", + ) + review_apply_parser.add_argument( + "--replacement-title", + default=None, + help="Replacement title for supersede.", + ) + review_apply_parser.add_argument( + "--replacement-body-json", + default=None, + help="JSON object payload for supersede replacement body.", + ) + review_apply_parser.add_argument( + "--replacement-provenance-json", + default=None, + help="JSON object payload for supersede replacement provenance.", + ) + review_apply_parser.add_argument( + "--replacement-confidence", + type=float, + default=None, + help="Replacement confidence for supersede.", + ) + review_apply_parser.set_defaults(handler=_run_review_apply) + + status_parser = subparsers.add_parser("status", help="Show local continuity runtime status.") + status_parser.set_defaults(handler=_run_status) + + return parser + + +def _validate_limit(value: int, *, option_name: str, minimum: int, maximum: int) -> None: + if value < minimum or value > maximum: + raise ValueError(f"{option_name} must be between {minimum} and {maximum}") + + +def _validate_arguments(args: argparse.Namespace) -> None: + if args.command == "recall": + _validate_limit( + args.limit, + option_name="--limit", + minimum=1, + maximum=MAX_CONTINUITY_RECALL_LIMIT, + ) + elif args.command == "lifecycle" and args.lifecycle_command == "list": + _validate_limit( + args.limit, + option_name="--limit", + minimum=1, + maximum=MAX_CONTINUITY_LIFECYCLE_LIMIT, + ) + elif args.command == "resume": + _validate_limit( + args.max_recent_changes, + option_name="--max-recent-changes", + minimum=0, + maximum=MAX_CONTINUITY_RESUMPTION_RECENT_CHANGES_LIMIT, + ) + _validate_limit( + args.max_open_loops, + option_name="--max-open-loops", + minimum=0, + maximum=MAX_CONTINUITY_RESUMPTION_OPEN_LOOP_LIMIT, + ) + elif args.command == "open-loops": + _validate_limit( + args.limit, + option_name="--limit", + minimum=0, + maximum=MAX_CONTINUITY_OPEN_LOOP_LIMIT, + ) + elif args.command == "review" and args.review_command == "queue": + _validate_limit( + args.limit, + option_name="--limit", + minimum=1, + maximum=MAX_CONTINUITY_REVIEW_LIMIT, + ) + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + try: + _validate_arguments(args) + ctx = _build_context(args) + handler = args.handler + output = handler(ctx, args) + except ( + ValueError, + psycopg.Error, + ContinuityCaptureValidationError, + ContinuityLifecycleValidationError, + ContinuityLifecycleNotFoundError, + ContinuityRecallValidationError, + ContinuityResumptionValidationError, + ContinuityOpenLoopValidationError, + ContinuityReviewValidationError, + ContinuityReviewNotFoundError, + ) as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + print(output) + return 0 + + +__all__ = ["build_parser", "main"] diff --git a/apps/api/src/alicebot_api/cli_formatting.py b/apps/api/src/alicebot_api/cli_formatting.py new file mode 100644 index 0000000..8c5e8ef --- /dev/null +++ b/apps/api/src/alicebot_api/cli_formatting.py @@ -0,0 +1,475 @@ +from __future__ import annotations + +import json +from typing import Mapping, Sequence + +from alicebot_api.contracts import ( + ContinuityCaptureCreateResponse, + ContinuityCorrectionApplyResponse, + ContinuityLifecycleDetailResponse, + ContinuityLifecycleListResponse, + ContinuityOpenLoopDashboardResponse, + ContinuityRecallResultRecord, + ContinuityRecallResponse, + ContinuityResumptionBriefResponse, + ContinuityReviewDetailResponse, + ContinuityReviewQueueResponse, +) + + +_SCOPE_KEY_ORDER = ("thread_id", "task_id", "project", "person", "since", "until") +_RECALL_ITEM_PREFIX = " " + + +def _format_float(value: float) -> str: + return f"{value:.3f}" + + +def _format_scope(scope: Mapping[str, object]) -> str: + rendered: list[str] = [] + for key in _SCOPE_KEY_ORDER: + if key not in scope: + continue + value = scope[key] + if value is None: + continue + rendered.append(f"{key}={value}") + if not rendered: + return "(none)" + return ", ".join(rendered) + + +def _format_json(value: object) -> str: + return json.dumps(value, sort_keys=True, separators=(",", ":")) + + +def _format_provenance_refs(item: ContinuityRecallResultRecord) -> str: + refs = item["provenance_references"] + if len(refs) == 0: + return "(none)" + return "; ".join(f"{ref['source_kind']}:{ref['source_id']}" for ref in refs) + + +def _format_provenance_source(item: ContinuityRecallResultRecord) -> str: + provenance = item.get("provenance", {}) + if not isinstance(provenance, dict): + return "(unknown)" + + label = provenance.get("source_label") + source_kind = provenance.get("source_kind") + + label_text = label.strip() if isinstance(label, str) else None + source_kind_text = source_kind.strip() if isinstance(source_kind, str) else None + + if label_text and source_kind_text: + return f"{label_text} ({source_kind_text})" + if label_text: + return label_text + if source_kind_text: + return source_kind_text + return "(unknown)" + + +def _render_recall_item( + item: ContinuityRecallResultRecord, + *, + index: int | None = None, + prefix: str = _RECALL_ITEM_PREFIX, +) -> list[str]: + marker = "-" if index is None else f"{index}." + lines = [ + f"{prefix}{marker} [{item['object_type']}|{item['status']}] {item['title']}", + f"{prefix} id={item['id']} capture_event_id={item['capture_event_id']}", + ( + f"{prefix} lifecycle=preserved:{item['lifecycle']['is_preserved']} " + f"searchable:{item['lifecycle']['is_searchable']} " + f"promotable:{item['lifecycle']['is_promotable']}" + ), + ( + f"{prefix} confidence={_format_float(item['confidence'])} " + f"relevance={_format_float(item['relevance'])} " + f"confirmation={item['confirmation_status']}" + ), + ( + f"{prefix} freshness={item['ordering']['freshness_posture']} " + f"provenance={item['ordering']['provenance_posture']} " + f"supersession={item['ordering']['supersession_posture']}" + ), + f"{prefix} source={_format_provenance_source(item)}", + f"{prefix} provenance_refs={_format_provenance_refs(item)}", + ] + return lines + + +def _render_recall_list_section( + *, + title: str, + items: Sequence[ContinuityRecallResultRecord], + limit: int, + total_count: int, + order: Sequence[str], + empty_message: str, +) -> list[str]: + lines = [ + f"{title} (returned={len(items)} total={total_count} limit={limit})", + f"order: {', '.join(order)}", + ] + if len(items) == 0: + lines.append(f"empty: {empty_message}") + return lines + + for index, item in enumerate(items, start=1): + lines.extend(_render_recall_item(item, index=index)) + return lines + + +def format_capture_output(payload: ContinuityCaptureCreateResponse) -> str: + capture = payload["capture"]["capture_event"] + derived = payload["capture"]["derived_object"] + + lines = [ + "capture result", + f"capture_event_id: {capture['id']}", + f"created_at: {capture['created_at']}", + f"admission_posture: {capture['admission_posture']}", + f"admission_reason: {capture['admission_reason']}", + f"explicit_signal: {capture['explicit_signal']}", + ] + + if derived is None: + lines.append("derived_object: none") + else: + lines.extend( + [ + f"derived_object_id: {derived['id']}", + f"derived_object_type: {derived['object_type']}", + f"derived_object_status: {derived['status']}", + ( + "derived_lifecycle: " + f"preserved={derived['lifecycle']['is_preserved']} " + f"searchable={derived['lifecycle']['is_searchable']} " + f"promotable={derived['lifecycle']['is_promotable']}" + ), + f"derived_confidence: {_format_float(derived['confidence'])}", + f"derived_title: {derived['title']}", + ] + ) + + return "\n".join(lines) + + +def format_recall_output(payload: ContinuityRecallResponse) -> str: + summary = payload["summary"] + lines = [ + "recall summary", + f"query: {summary['query']}", + f"filters: {_format_scope(summary['filters'])}", + ( + f"returned: {summary['returned_count']}/{summary['total_count']} " + f"(limit={summary['limit']})" + ), + f"order: {', '.join(summary['order'])}", + ] + + items = payload["items"] + if len(items) == 0: + lines.append("empty: no continuity results in requested scope.") + return "\n".join(lines) + + lines.append("items:") + for index, item in enumerate(items, start=1): + lines.extend(_render_recall_item(item, index=index)) + return "\n".join(lines) + + +def format_resume_output(payload: ContinuityResumptionBriefResponse) -> str: + brief = payload["brief"] + lines = [ + "resumption brief", + f"assembly_version: {brief['assembly_version']}", + f"scope: {_format_scope(brief['scope'])}", + f"sources: {', '.join(brief['sources'])}", + ] + + lines.append("last_decision:") + last_decision = brief["last_decision"] + if last_decision["item"] is None: + lines.append(f" empty: {last_decision['empty_state']['message']}") + else: + lines.extend(_render_recall_item(last_decision["item"])) + + lines.extend( + _render_recall_list_section( + title="open_loops", + items=brief["open_loops"]["items"], + limit=brief["open_loops"]["summary"]["limit"], + total_count=brief["open_loops"]["summary"]["total_count"], + order=brief["open_loops"]["summary"]["order"], + empty_message=brief["open_loops"]["empty_state"]["message"], + ) + ) + lines.extend( + _render_recall_list_section( + title="recent_changes", + items=brief["recent_changes"]["items"], + limit=brief["recent_changes"]["summary"]["limit"], + total_count=brief["recent_changes"]["summary"]["total_count"], + order=brief["recent_changes"]["summary"]["order"], + empty_message=brief["recent_changes"]["empty_state"]["message"], + ) + ) + + lines.append("next_action:") + next_action = brief["next_action"] + if next_action["item"] is None: + lines.append(f" empty: {next_action['empty_state']['message']}") + else: + lines.extend(_render_recall_item(next_action["item"])) + + return "\n".join(lines) + + +def format_open_loops_output(payload: ContinuityOpenLoopDashboardResponse) -> str: + dashboard = payload["dashboard"] + summary = dashboard["summary"] + lines = [ + "open loops dashboard", + f"scope: {_format_scope(dashboard['scope'])}", + f"sources: {', '.join(dashboard['sources'])}", + ( + f"summary: total={summary['total_count']} " + f"limit={summary['limit']} " + f"posture_order={','.join(summary['posture_order'])}" + ), + ] + + for posture in ("waiting_for", "blocker", "stale", "next_action"): + section = dashboard[posture] + lines.extend( + _render_recall_list_section( + title=posture, + items=section["items"], + limit=section["summary"]["limit"], + total_count=section["summary"]["total_count"], + order=section["summary"]["order"], + empty_message=section["empty_state"]["message"], + ) + ) + + return "\n".join(lines) + + +def format_review_queue_output(payload: ContinuityReviewQueueResponse) -> str: + summary = payload["summary"] + lines = [ + "review queue", + ( + f"status={summary['status']} " + f"returned={summary['returned_count']}/{summary['total_count']} " + f"limit={summary['limit']}" + ), + f"order: {', '.join(summary['order'])}", + ] + + items = payload["items"] + if len(items) == 0: + lines.append("empty: no review items in requested status.") + return "\n".join(lines) + + for index, item in enumerate(items, start=1): + lines.extend( + [ + f"{index}. [{item['object_type']}|{item['status']}] {item['title']}", + f" id={item['id']} capture_event_id={item['capture_event_id']}", + ( + " lifecycle=" + f"preserved:{item['lifecycle']['is_preserved']} " + f"searchable:{item['lifecycle']['is_searchable']} " + f"promotable:{item['lifecycle']['is_promotable']}" + ), + f" confidence={_format_float(item['confidence'])} last_confirmed_at={item['last_confirmed_at']}", + f" provenance={_format_json(item['provenance'])}", + ] + ) + + return "\n".join(lines) + + +def format_review_detail_output(payload: ContinuityReviewDetailResponse) -> str: + review = payload["review"] + continuity_object = review["continuity_object"] + supersession = review["supersession_chain"] + + lines = [ + "review detail", + f"continuity_object_id: {continuity_object['id']}", + f"type: {continuity_object['object_type']}", + f"status: {continuity_object['status']}", + ( + "lifecycle: " + f"preserved={continuity_object['lifecycle']['is_preserved']} " + f"searchable={continuity_object['lifecycle']['is_searchable']} " + f"promotable={continuity_object['lifecycle']['is_promotable']}" + ), + f"title: {continuity_object['title']}", + f"confidence: {_format_float(continuity_object['confidence'])}", + f"last_confirmed_at: {continuity_object['last_confirmed_at']}", + f"body: {_format_json(continuity_object['body'])}", + f"provenance: {_format_json(continuity_object['provenance'])}", + ( + "supersession_chain: " + f"supersedes={None if supersession['supersedes'] is None else supersession['supersedes']['id']} " + f"superseded_by={None if supersession['superseded_by'] is None else supersession['superseded_by']['id']}" + ), + f"correction_event_count: {len(review['correction_events'])}", + ] + + for index, event in enumerate(review["correction_events"], start=1): + lines.append( + f"event {index}: id={event['id']} action={event['action']} " + f"reason={event['reason']} created_at={event['created_at']}" + ) + + return "\n".join(lines) + + +def format_review_apply_output(payload: ContinuityCorrectionApplyResponse) -> str: + continuity_object = payload["continuity_object"] + correction_event = payload["correction_event"] + replacement_object = payload["replacement_object"] + + lines = [ + "review apply result", + f"continuity_object_id: {continuity_object['id']}", + f"continuity_object_status: {continuity_object['status']}", + ( + "continuity_object_lifecycle: " + f"preserved={continuity_object['lifecycle']['is_preserved']} " + f"searchable={continuity_object['lifecycle']['is_searchable']} " + f"promotable={continuity_object['lifecycle']['is_promotable']}" + ), + f"continuity_object_title: {continuity_object['title']}", + f"correction_event_id: {correction_event['id']}", + f"correction_action: {correction_event['action']}", + f"correction_reason: {correction_event['reason']}", + f"correction_created_at: {correction_event['created_at']}", + ] + + if replacement_object is None: + lines.append("replacement_object_id: none") + else: + lines.extend( + [ + f"replacement_object_id: {replacement_object['id']}", + f"replacement_status: {replacement_object['status']}", + f"replacement_title: {replacement_object['title']}", + ] + ) + + return "\n".join(lines) + + +def format_status_output(status: Mapping[str, object]) -> str: + lines = [ + "alice-core status", + f"user_id: {status['user_id']}", + f"database: {status['database_status']}", + f"continuity_capture_events: {status['continuity_capture_events']}", + f"continuity_objects_total: {status['continuity_objects_total']}", + ( + "continuity_object_statuses: " + f"active={status['continuity_objects_active']} " + f"stale={status['continuity_objects_stale']} " + f"superseded={status['continuity_objects_superseded']} " + f"deleted={status['continuity_objects_deleted']}" + ), + ( + "continuity_object_lifecycle: " + f"searchable={status['continuity_objects_searchable']} " + f"non_searchable={status['continuity_objects_non_searchable']} " + f"promotable={status['continuity_objects_promotable']} " + f"non_promotable={status['continuity_objects_non_promotable']}" + ), + ( + "review_queue: " + f"correction_ready={status['review_correction_ready']} " + f"active={status['review_active']} " + f"stale={status['review_stale']} " + f"superseded={status['review_superseded']} " + f"deleted={status['review_deleted']}" + ), + ( + "open_loops: " + f"total={status['open_loops_total']} " + f"waiting_for={status['open_loops_waiting_for']} " + f"blocker={status['open_loops_blocker']} " + f"stale={status['open_loops_stale']} " + f"next_action={status['open_loops_next_action']}" + ), + ( + "retrieval_eval: " + f"status={status['retrieval_eval_status']} " + f"precision_at_k_mean={status['retrieval_precision_at_k_mean']} " + f"precision_at_1_mean={status['retrieval_precision_at_1_mean']}" + ), + ] + return "\n".join(lines) + + +def format_lifecycle_list_output(payload: ContinuityLifecycleListResponse) -> str: + summary = payload["summary"] + lines = [ + "continuity lifecycle", + ( + f"returned: {summary['returned_count']}/{summary['total_count']} " + f"(limit={summary['limit']})" + ), + ( + "counts: " + f"preserved={summary['counts']['preserved_count']} " + f"searchable={summary['counts']['searchable_count']} " + f"promotable={summary['counts']['promotable_count']} " + f"non_searchable={summary['counts']['not_searchable_count']} " + f"non_promotable={summary['counts']['not_promotable_count']}" + ), + f"order: {', '.join(summary['order'])}", + ] + if len(payload["items"]) == 0: + lines.append("empty: no continuity lifecycle records.") + return "\n".join(lines) + + for index, item in enumerate(payload["items"], start=1): + lines.extend( + [ + f"{index}. [{item['object_type']}|{item['status']}] {item['title']}", + f" id={item['id']} capture_event_id={item['capture_event_id']}", + ( + " lifecycle=" + f"preserved:{item['lifecycle']['is_preserved']} " + f"searchable:{item['lifecycle']['is_searchable']} " + f"promotable:{item['lifecycle']['is_promotable']}" + ), + ] + ) + return "\n".join(lines) + + +def format_lifecycle_detail_output(payload: ContinuityLifecycleDetailResponse) -> str: + item = payload["continuity_object"] + return "\n".join( + [ + "continuity lifecycle detail", + f"continuity_object_id: {item['id']}", + f"type: {item['object_type']}", + f"status: {item['status']}", + ( + "lifecycle: " + f"preserved={item['lifecycle']['is_preserved']} " + f"searchable={item['lifecycle']['is_searchable']} " + f"promotable={item['lifecycle']['is_promotable']}" + ), + f"title: {item['title']}", + f"body: {_format_json(item['body'])}", + f"provenance: {_format_json(item['provenance'])}", + ] + ) diff --git a/apps/api/src/alicebot_api/compiler.py b/apps/api/src/alicebot_api/compiler.py new file mode 100644 index 0000000..7bbcbe0 --- /dev/null +++ b/apps/api/src/alicebot_api/compiler.py @@ -0,0 +1,1858 @@ +from __future__ import annotations + +from dataclasses import dataclass +from uuid import UUID + +from alicebot_api.contracts import ( + COMPILER_VERSION_V0, + DEFAULT_RESUMPTION_BRIEF_EVENT_LIMIT, + DEFAULT_RESUMPTION_BRIEF_MEMORY_LIMIT, + DEFAULT_RESUMPTION_BRIEF_OPEN_LOOP_LIMIT, + ArtifactSelectionSource, + CompileContextArtifactScopedSemanticArtifactRetrievalInput, + CompilerDecision, + CompileContextArtifactRetrievalInput, + CompileContextArtifactScopedArtifactRetrievalInput, + CompileContextSemanticRetrievalInput, + CompileContextSemanticArtifactRetrievalInput, + CompileContextTaskScopedSemanticArtifactRetrievalInput, + CompileContextTaskScopedArtifactRetrievalInput, + CompilerRunResult, + CompiledContextPack, + ContextPackArtifactChunk, + ContextPackArtifactChunkSummary, + ContextCompilerLimits, + ContextPackHybridMemorySummary, + ContextPackMemory, + ContextPackMemorySummary, + ContextPackOpenLoop, + ContextPackOpenLoopSummary, + HybridMemoryDecisionTracePayload, + HybridArtifactRetrievalDecisionTracePayload, + MemorySelectionSource, + OPEN_LOOP_REVIEW_ORDER, + RESUMPTION_BRIEF_ASSEMBLY_VERSION_V0, + RESUMPTION_BRIEF_CONVERSATION_EVENT_KINDS, + RESUMPTION_BRIEF_CONVERSATION_ORDER, + RESUMPTION_BRIEF_MEMORY_ORDER, + ResumptionBriefConversationSection, + ResumptionBriefMemoryHighlightSection, + ResumptionBriefRecord, + ResumptionBriefSectionSummary, + ResumptionBriefWorkflowPosture, + ResumptionBriefWorkflowSummary, + SEMANTIC_MEMORY_RETRIEVAL_ORDER, + TASK_LIST_ORDER, + TASK_STEP_LIST_ORDER, + TASK_ARTIFACT_CHUNK_RETRIEVAL_ORDER, + TASK_ARTIFACT_CHUNK_SEMANTIC_RETRIEVAL_ORDER, + SemanticMemoryRetrievalRequestInput, + TRACE_KIND_CONTEXT_COMPILE, + TraceEventRecord, + isoformat_or_none, +) +from alicebot_api.artifacts import ( + TASK_ARTIFACT_CHUNK_RETRIEVAL_MATCHING_RULE, + TaskArtifactNotFoundError, + build_task_artifact_chunk_retrieval_scope, + infer_task_artifact_media_type, + resolve_artifact_chunk_retrieval_query_terms, + retrieve_matching_task_artifact_chunks, +) +from alicebot_api.semantic_retrieval import ( + serialize_semantic_artifact_chunk_result_item, + validate_semantic_artifact_chunk_retrieval_request, + validate_semantic_memory_retrieval_request, +) +from alicebot_api.store import ( + ContinuityStore, + EntityEdgeRow, + EntityRow, + EventRow, + MemoryRow, + OpenLoopRow, + SemanticMemoryRetrievalRow, + SessionRow, + TaskRow, + TaskStepRow, + ThreadRow, + UserRow, +) +from alicebot_api.tasks import TaskNotFoundError, serialize_task_row, serialize_task_step_row + +SUMMARY_TRACE_EVENT_KIND = "context.summary" +_UNBOUNDED_SEMANTIC_RETRIEVAL_LIMIT = 2_147_483_647 +_UNBOUNDED_SEMANTIC_ARTIFACT_RETRIEVAL_LIMIT = 2_147_483_647 +HYBRID_MEMORY_SOURCE_PRECEDENCE: list[MemorySelectionSource] = ["symbolic", "semantic"] +HYBRID_SYMBOLIC_ORDER = ["updated_at_asc", "created_at_asc", "id_asc"] +HYBRID_ARTIFACT_SOURCE_PRECEDENCE: list[ArtifactSelectionSource] = ["lexical", "semantic"] +HYBRID_ARTIFACT_MERGED_ORDER = [ + "source_precedence_asc", + "lexical_rank_asc", + "semantic_rank_asc", + "relative_path_asc", + "sequence_no_asc", + "id_asc", +] +DEFAULT_AGENT_PROFILE_ID = "assistant_default" + + +@dataclass(frozen=True, slots=True) +class CompiledTraceRun: + trace_id: str + context_pack: CompiledContextPack + trace_event_count: int + + +@dataclass(frozen=True, slots=True) +class CompiledMemorySection: + items: list[ContextPackMemory] + summary: ContextPackMemorySummary + decisions: list[CompilerDecision] + + +@dataclass(frozen=True, slots=True) +class CompiledOpenLoopSection: + items: list[ContextPackOpenLoop] + summary: ContextPackOpenLoopSummary + decisions: list[CompilerDecision] + + +@dataclass(frozen=True, slots=True) +class CompiledArtifactChunkSection: + items: list[ContextPackArtifactChunk] + summary: ContextPackArtifactChunkSummary + decisions: list[CompilerDecision] + + +@dataclass(slots=True) +class HybridMemoryCandidate: + memory: MemoryRow + sources: list[MemorySelectionSource] + semantic_score: float | None = None + + +@dataclass(slots=True) +class HybridArtifactChunkCandidate: + item: ContextPackArtifactChunk + sources: list[ArtifactSelectionSource] + lexical_rank: int | None = None + semantic_rank: int | None = None + + +def _session_sort_key( + session: SessionRow, + latest_session_sequence: dict[UUID, int], +) -> tuple[int, str, str, str]: + latest_sequence = latest_session_sequence.get(session["id"], -1) + started_at = isoformat_or_none(session["started_at"]) or "" + created_at = session["created_at"].isoformat() + return (latest_sequence, started_at, created_at, str(session["id"])) + + +def _serialize_user(user: UserRow) -> dict[str, str | None]: + return { + "id": str(user["id"]), + "email": user["email"], + "display_name": user["display_name"], + "created_at": user["created_at"].isoformat(), + } + + +def _serialize_thread(thread: ThreadRow) -> dict[str, str]: + return { + "id": str(thread["id"]), + "title": thread["title"], + "created_at": thread["created_at"].isoformat(), + "updated_at": thread["updated_at"].isoformat(), + } + + +def _resolve_thread_agent_profile_id(thread: ThreadRow) -> str: + return str(thread.get("agent_profile_id", DEFAULT_AGENT_PROFILE_ID)) + + +def _list_context_memories_for_profile( + store: ContinuityStore, + *, + agent_profile_id: str, +) -> list[MemoryRow]: + list_for_profile = getattr(store, "list_context_memories_for_profile", None) + if callable(list_for_profile): + return list_for_profile(agent_profile_id=agent_profile_id) + return [ + memory + for memory in store.list_context_memories() + if str(memory.get("agent_profile_id", DEFAULT_AGENT_PROFILE_ID)) == agent_profile_id + ] + + +def _retrieve_semantic_memory_matches_for_profile( + store: ContinuityStore, + *, + embedding_config_id: UUID, + query_vector: list[float], + limit: int, + agent_profile_id: str, +) -> list[SemanticMemoryRetrievalRow]: + retrieve_for_profile = getattr(store, "retrieve_semantic_memory_matches_for_profile", None) + if callable(retrieve_for_profile): + return retrieve_for_profile( + embedding_config_id=embedding_config_id, + query_vector=query_vector, + limit=limit, + agent_profile_id=agent_profile_id, + ) + return [ + memory + for memory in store.retrieve_semantic_memory_matches( + embedding_config_id=embedding_config_id, + query_vector=query_vector, + limit=limit, + ) + if str(memory.get("agent_profile_id", DEFAULT_AGENT_PROFILE_ID)) == agent_profile_id + ] + + +def _serialize_session(session: SessionRow) -> dict[str, str | None]: + return { + "id": str(session["id"]), + "status": session["status"], + "started_at": isoformat_or_none(session["started_at"]), + "ended_at": isoformat_or_none(session["ended_at"]), + "created_at": session["created_at"].isoformat(), + } + + +def _serialize_event(event: EventRow) -> dict[str, object]: + return { + "id": str(event["id"]), + "session_id": None if event["session_id"] is None else str(event["session_id"]), + "sequence_no": event["sequence_no"], + "kind": event["kind"], + "payload": event["payload"], + "created_at": event["created_at"].isoformat(), + } + + +def _memory_sort_key(memory: MemoryRow) -> tuple[str, str, str]: + return ( + memory["updated_at"].isoformat(), + memory["created_at"].isoformat(), + str(memory["id"]), + ) + + +def _serialize_memory(memory: MemoryRow) -> dict[str, object]: + payload: dict[str, object] = { + "id": str(memory["id"]), + "memory_key": memory["memory_key"], + "value": memory["value"], + "status": memory["status"], + "source_event_ids": memory["source_event_ids"], + "created_at": memory["created_at"].isoformat(), + "updated_at": memory["updated_at"].isoformat(), + "source_provenance": { + "sources": ["symbolic"], + "semantic_score": None, + }, + } + payload.update(_serialize_typed_memory_metadata(memory)) + return payload + + +def _open_loop_sort_key(open_loop: OpenLoopRow) -> tuple[str, str, str]: + return ( + open_loop["opened_at"].isoformat(), + open_loop["created_at"].isoformat(), + str(open_loop["id"]), + ) + + +def _serialize_open_loop(open_loop: OpenLoopRow) -> ContextPackOpenLoop: + return { + "id": str(open_loop["id"]), + "memory_id": None if open_loop["memory_id"] is None else str(open_loop["memory_id"]), + "title": open_loop["title"], + "status": open_loop["status"], # type: ignore[typeddict-item] + "opened_at": open_loop["opened_at"].isoformat(), + "due_at": isoformat_or_none(open_loop["due_at"]), + "resolved_at": isoformat_or_none(open_loop["resolved_at"]), + "resolution_note": open_loop["resolution_note"], + "created_at": open_loop["created_at"].isoformat(), + "updated_at": open_loop["updated_at"].isoformat(), + } + + +def _entity_sort_key(entity: EntityRow) -> tuple[str, str]: + return (entity["created_at"].isoformat(), str(entity["id"])) + + +def _serialize_entity(entity: EntityRow) -> dict[str, object]: + return { + "id": str(entity["id"]), + "entity_type": entity["entity_type"], + "name": entity["name"], + "source_memory_ids": entity["source_memory_ids"], + "created_at": entity["created_at"].isoformat(), + } + + +def _entity_edge_sort_key(edge: EntityEdgeRow) -> tuple[str, str]: + return (edge["created_at"].isoformat(), str(edge["id"])) + + +def _serialize_entity_edge(edge: EntityEdgeRow) -> dict[str, object]: + return { + "id": str(edge["id"]), + "from_entity_id": str(edge["from_entity_id"]), + "to_entity_id": str(edge["to_entity_id"]), + "relationship_type": edge["relationship_type"], + "valid_from": isoformat_or_none(edge["valid_from"]), + "valid_to": isoformat_or_none(edge["valid_to"]), + "source_memory_ids": edge["source_memory_ids"], + "created_at": edge["created_at"].isoformat(), + } + + +def _semantic_memory_sort_key(memory: SemanticMemoryRetrievalRow) -> tuple[float, str, str]: + return (-float(memory["score"]), memory["created_at"].isoformat(), str(memory["id"])) + + +def _semantic_deleted_memory_sort_key(memory: MemoryRow) -> tuple[str, str, str]: + return ( + memory["updated_at"].isoformat(), + memory["created_at"].isoformat(), + str(memory["id"]), + ) + + +def _serialize_typed_memory_metadata(memory: MemoryRow) -> dict[str, object]: + payload: dict[str, object] = {} + + if "memory_type" in memory: + payload["memory_type"] = memory["memory_type"] + if "confidence" in memory: + payload["confidence"] = memory["confidence"] + if "salience" in memory: + payload["salience"] = memory["salience"] + if "confirmation_status" in memory: + payload["confirmation_status"] = memory["confirmation_status"] + if "trust_class" in memory: + payload["trust_class"] = memory["trust_class"] + if "promotion_eligibility" in memory: + payload["promotion_eligibility"] = memory["promotion_eligibility"] + if "evidence_count" in memory: + payload["evidence_count"] = memory["evidence_count"] + if "independent_source_count" in memory: + payload["independent_source_count"] = memory["independent_source_count"] + if "extracted_by_model" in memory: + payload["extracted_by_model"] = memory["extracted_by_model"] + if "trust_reason" in memory: + payload["trust_reason"] = memory["trust_reason"] + if "valid_from" in memory: + payload["valid_from"] = isoformat_or_none(memory["valid_from"]) + if "valid_to" in memory: + payload["valid_to"] = isoformat_or_none(memory["valid_to"]) + if "last_confirmed_at" in memory: + payload["last_confirmed_at"] = isoformat_or_none(memory["last_confirmed_at"]) + + return payload + + +def _empty_hybrid_memory_summary() -> ContextPackHybridMemorySummary: + return { + "requested": False, + "embedding_config_id": None, + "query_vector_dimensions": 0, + "semantic_limit": 0, + "symbolic_selected_count": 0, + "semantic_selected_count": 0, + "merged_candidate_count": 0, + "deduplicated_count": 0, + "included_symbolic_only_count": 0, + "included_semantic_only_count": 0, + "included_dual_source_count": 0, + "similarity_metric": None, + "source_precedence": list(HYBRID_MEMORY_SOURCE_PRECEDENCE), + "symbolic_order": list(HYBRID_SYMBOLIC_ORDER), + "semantic_order": list(SEMANTIC_MEMORY_RETRIEVAL_ORDER), + } + + +def _empty_artifact_chunk_summary() -> ContextPackArtifactChunkSummary: + return { + "requested": False, + "lexical_requested": False, + "semantic_requested": False, + "scope": None, + "query": None, + "query_terms": [], + "embedding_config_id": None, + "query_vector_dimensions": 0, + "limit": 0, + "lexical_limit": 0, + "semantic_limit": 0, + "searched_artifact_count": 0, + "lexical_candidate_count": 0, + "semantic_candidate_count": 0, + "merged_candidate_count": 0, + "deduplicated_count": 0, + "included_count": 0, + "included_lexical_only_count": 0, + "included_semantic_only_count": 0, + "included_dual_source_count": 0, + "excluded_uningested_artifact_count": 0, + "excluded_limit_count": 0, + "matching_rule": None, + "similarity_metric": None, + "source_precedence": list(HYBRID_ARTIFACT_SOURCE_PRECEDENCE), + "lexical_order": list(TASK_ARTIFACT_CHUNK_RETRIEVAL_ORDER), + "semantic_order": list(TASK_ARTIFACT_CHUNK_SEMANTIC_RETRIEVAL_ORDER), + "merged_order": list(HYBRID_ARTIFACT_MERGED_ORDER), + } + + +def _hybrid_artifact_retrieval_decision_metadata( + *, + scope_kind: str, + task_id: UUID, + task_artifact_id: UUID, + relative_path: str, + media_type: str | None, + ingestion_status: str, + limit: int, + selected_sources: list[ArtifactSelectionSource], + embedding_config_id: UUID | None = None, + query_vector_dimensions: int = 0, + match: dict[str, object] | None = None, + score: float | None = None, + sequence_no: int | None = None, + char_start: int | None = None, + char_end_exclusive: int | None = None, +) -> HybridArtifactRetrievalDecisionTracePayload: + payload: HybridArtifactRetrievalDecisionTracePayload = { + "scope_kind": scope_kind, # type: ignore[typeddict-item] + "task_id": str(task_id), + "task_artifact_id": str(task_artifact_id), + "relative_path": relative_path, + "media_type": media_type, + "ingestion_status": ingestion_status, # type: ignore[typeddict-item] + "selected_sources": list(selected_sources), + "embedding_config_id": None if embedding_config_id is None else str(embedding_config_id), + "query_vector_dimensions": query_vector_dimensions, + "limit": limit, + } + if match is not None: + payload["matched_query_terms"] = list(match["matched_query_terms"]) # type: ignore[index] + payload["matched_query_term_count"] = int(match["matched_query_term_count"]) # type: ignore[index] + payload["first_match_char_start"] = int(match["first_match_char_start"]) # type: ignore[index] + if score is not None: + payload["score"] = score + payload["similarity_metric"] = "cosine_similarity" + if sequence_no is not None: + payload["sequence_no"] = sequence_no + if char_start is not None: + payload["char_start"] = char_start + if char_end_exclusive is not None: + payload["char_end_exclusive"] = char_end_exclusive + return payload + + +def _hybrid_memory_decision_metadata( + *, + embedding_config_id: UUID | None, + memory_key: str, + status: str, + source_event_ids: list[str], + selected_sources: list[MemorySelectionSource], + semantic_score: float | None, +) -> HybridMemoryDecisionTracePayload: + return { + "embedding_config_id": None if embedding_config_id is None else str(embedding_config_id), + "memory_key": memory_key, + "status": status, + "source_event_ids": source_event_ids, + "selected_sources": list(selected_sources), + "semantic_score": semantic_score, + } + + +def _serialize_hybrid_memory(candidate: HybridMemoryCandidate) -> ContextPackMemory: + memory = candidate.memory + payload: ContextPackMemory = { + "id": str(memory["id"]), + "memory_key": memory["memory_key"], + "value": memory["value"], + "status": memory["status"], + "source_event_ids": memory["source_event_ids"], + "created_at": memory["created_at"].isoformat(), + "updated_at": memory["updated_at"].isoformat(), + "source_provenance": { + "sources": list(candidate.sources), + "semantic_score": candidate.semantic_score, + }, + } + payload.update(_serialize_typed_memory_metadata(memory)) + return payload + + +def _serialize_hybrid_artifact_chunk(candidate: HybridArtifactChunkCandidate) -> ContextPackArtifactChunk: + item = candidate.item + return { + "id": item["id"], + "task_id": item["task_id"], + "task_artifact_id": item["task_artifact_id"], + "relative_path": item["relative_path"], + "media_type": item["media_type"], + "sequence_no": item["sequence_no"], + "char_start": item["char_start"], + "char_end_exclusive": item["char_end_exclusive"], + "text": item["text"], + "source_provenance": { + "sources": list(candidate.sources), + "lexical_match": item["source_provenance"]["lexical_match"], + "semantic_score": item["source_provenance"]["semantic_score"], + }, + } + + +def _resolve_artifact_scope( + store: ContinuityStore, + *, + artifact_retrieval: CompileContextArtifactRetrievalInput | None, + semantic_artifact_retrieval: CompileContextSemanticArtifactRetrievalInput | None, +) -> tuple[list[dict[str, object]], dict[str, str] | None, str | None]: + lexical_scope: tuple[list[dict[str, object]], dict[str, str], str] | None = None + semantic_scope: tuple[list[dict[str, object]], dict[str, str], str] | None = None + + if isinstance(artifact_retrieval, CompileContextTaskScopedArtifactRetrievalInput): + task = store.get_task_optional(artifact_retrieval.task_id) + if task is None: + raise TaskNotFoundError(f"task {artifact_retrieval.task_id} was not found") + lexical_scope = ( + store.list_task_artifacts_for_task(artifact_retrieval.task_id), + build_task_artifact_chunk_retrieval_scope( + kind="task", + task_id=artifact_retrieval.task_id, + ), + "task", + ) + elif isinstance(artifact_retrieval, CompileContextArtifactScopedArtifactRetrievalInput): + artifact_row = store.get_task_artifact_optional(artifact_retrieval.task_artifact_id) + if artifact_row is None: + raise TaskArtifactNotFoundError( + f"task artifact {artifact_retrieval.task_artifact_id} was not found" + ) + lexical_scope = ( + [artifact_row], + build_task_artifact_chunk_retrieval_scope( + kind="artifact", + task_id=artifact_row["task_id"], + task_artifact_id=artifact_row["id"], + ), + "artifact", + ) + + if isinstance( + semantic_artifact_retrieval, + CompileContextTaskScopedSemanticArtifactRetrievalInput, + ): + task = store.get_task_optional(semantic_artifact_retrieval.task_id) + if task is None: + raise TaskNotFoundError(f"task {semantic_artifact_retrieval.task_id} was not found") + semantic_scope = ( + store.list_task_artifacts_for_task(semantic_artifact_retrieval.task_id), + build_task_artifact_chunk_retrieval_scope( + kind="task", + task_id=semantic_artifact_retrieval.task_id, + ), + "task", + ) + elif isinstance( + semantic_artifact_retrieval, + CompileContextArtifactScopedSemanticArtifactRetrievalInput, + ): + artifact_row = store.get_task_artifact_optional( + semantic_artifact_retrieval.task_artifact_id + ) + if artifact_row is None: + raise TaskArtifactNotFoundError( + f"task artifact {semantic_artifact_retrieval.task_artifact_id} was not found" + ) + semantic_scope = ( + [artifact_row], + build_task_artifact_chunk_retrieval_scope( + kind="artifact", + task_id=artifact_row["task_id"], + task_artifact_id=artifact_row["id"], + ), + "artifact", + ) + + if lexical_scope is not None and semantic_scope is not None and lexical_scope[1] != semantic_scope[1]: + raise TaskArtifactChunkRetrievalValidationError( + "artifact_retrieval and semantic_artifact_retrieval must target the same scope" + ) + + resolved_scope = lexical_scope or semantic_scope + if resolved_scope is None: + return [], None, None + return resolved_scope + + +def _build_symbolic_memory_section( + *, + memories: list[MemoryRow], + limits: ContextCompilerLimits, +) -> CompiledMemorySection: + ordered_memories = sorted(memories, key=_memory_sort_key) + active_memories = [memory for memory in ordered_memories if memory["status"] == "active"] + deleted_memories = [memory for memory in ordered_memories if memory["status"] != "active"] + symbolic_candidates = active_memories[-limits.max_memories :] if limits.max_memories > 0 else [] + memory_candidates = [ + HybridMemoryCandidate(memory=memory, sources=["symbolic"]) + for memory in symbolic_candidates + ] + decisions: list[CompilerDecision] = [] + + for position, candidate in enumerate(memory_candidates, start=1): + decisions.append( + CompilerDecision( + "included", + "memory", + candidate.memory["id"], + "within_hybrid_memory_limit", + position, + metadata=_hybrid_memory_decision_metadata( + embedding_config_id=None, + memory_key=candidate.memory["memory_key"], + status=candidate.memory["status"], + source_event_ids=candidate.memory["source_event_ids"], + selected_sources=candidate.sources, + semantic_score=None, + ), + ) + ) + + for position, memory in enumerate(deleted_memories, start=1): + decisions.append( + CompilerDecision( + "excluded", + "memory", + memory["id"], + "hybrid_memory_deleted", + position, + metadata=_hybrid_memory_decision_metadata( + embedding_config_id=None, + memory_key=memory["memory_key"], + status=memory["status"], + source_event_ids=memory["source_event_ids"], + selected_sources=["symbolic"], + semantic_score=None, + ), + ) + ) + + included_items = [_serialize_hybrid_memory(candidate) for candidate in memory_candidates] + return CompiledMemorySection( + items=included_items, + summary={ + "candidate_count": len(memory_candidates) + len(deleted_memories), + "included_count": len(included_items), + "excluded_deleted_count": len(deleted_memories), + "excluded_limit_count": 0, + "hybrid_retrieval": { + **_empty_hybrid_memory_summary(), + "symbolic_selected_count": len(memory_candidates), + "merged_candidate_count": len(memory_candidates), + "included_symbolic_only_count": len(included_items), + }, + }, + decisions=decisions, + ) + + +def _compile_memory_section( + store: ContinuityStore, + *, + memories: list[MemoryRow], + agent_profile_id: str, + limits: ContextCompilerLimits, + semantic_retrieval: CompileContextSemanticRetrievalInput | None, +) -> CompiledMemorySection: + if semantic_retrieval is None: + return _build_symbolic_memory_section(memories=memories, limits=limits) + + ordered_memories = sorted(memories, key=_memory_sort_key) + active_memories = [memory for memory in ordered_memories if memory["status"] == "active"] + deleted_memories = [memory for memory in ordered_memories if memory["status"] != "active"] + symbolic_candidates = active_memories[-limits.max_memories :] if limits.max_memories > 0 else [] + active_memories_by_id = {memory["id"]: memory for memory in active_memories} + + request = SemanticMemoryRetrievalRequestInput( + embedding_config_id=semantic_retrieval.embedding_config_id, + query_vector=semantic_retrieval.query_vector, + limit=semantic_retrieval.limit, + ) + _config, query_vector = validate_semantic_memory_retrieval_request(store, request=request) + ordered_semantic_candidates = sorted( + _retrieve_semantic_memory_matches_for_profile( + store, + embedding_config_id=semantic_retrieval.embedding_config_id, + query_vector=query_vector, + limit=_UNBOUNDED_SEMANTIC_RETRIEVAL_LIMIT, + agent_profile_id=agent_profile_id, + ), + key=_semantic_memory_sort_key, + ) + selected_semantic_candidates = ordered_semantic_candidates[: semantic_retrieval.limit] + + merged_candidates: list[HybridMemoryCandidate] = [ + HybridMemoryCandidate(memory=memory, sources=["symbolic"]) + for memory in symbolic_candidates + ] + merged_candidate_ids = {candidate.memory["id"] for candidate in merged_candidates} + deduplication_decisions: list[CompilerDecision] = [] + deduplicated_count = 0 + + for position, semantic_candidate in enumerate(selected_semantic_candidates, start=1): + memory = active_memories_by_id.get(semantic_candidate["id"], semantic_candidate) + if semantic_candidate["id"] in merged_candidate_ids: + deduplicated_count += 1 + for candidate in merged_candidates: + if candidate.memory["id"] != semantic_candidate["id"]: + continue + if "semantic" not in candidate.sources: + candidate.sources.append("semantic") + candidate.semantic_score = float(semantic_candidate["score"]) + deduplication_decisions.append( + CompilerDecision( + "included", + "memory", + semantic_candidate["id"], + "hybrid_memory_deduplicated", + position, + metadata=_hybrid_memory_decision_metadata( + embedding_config_id=semantic_retrieval.embedding_config_id, + memory_key=candidate.memory["memory_key"], + status=candidate.memory["status"], + source_event_ids=candidate.memory["source_event_ids"], + selected_sources=candidate.sources, + semantic_score=candidate.semantic_score, + ), + ) + ) + break + continue + + merged_candidate_ids.add(semantic_candidate["id"]) + merged_candidates.append( + HybridMemoryCandidate( + memory=memory, + sources=["semantic"], + semantic_score=float(semantic_candidate["score"]), + ) + ) + + deleted_candidates = [ + HybridMemoryCandidate( + memory=memory, + sources=["symbolic"], + ) + for memory in sorted(deleted_memories, key=_semantic_deleted_memory_sort_key) + ] + + decisions = list(deduplication_decisions) + included_candidates = merged_candidates[: limits.max_memories] if limits.max_memories > 0 else [] + excluded_candidates = merged_candidates[limits.max_memories :] if limits.max_memories > 0 else merged_candidates + included_symbolic_only_count = 0 + included_semantic_only_count = 0 + included_dual_source_count = 0 + + for position, candidate in enumerate(merged_candidates, start=1): + if position <= limits.max_memories and limits.max_memories > 0: + if candidate.sources == ["symbolic"]: + included_symbolic_only_count += 1 + elif candidate.sources == ["semantic"]: + included_semantic_only_count += 1 + else: + included_dual_source_count += 1 + decisions.append( + CompilerDecision( + "included", + "memory", + candidate.memory["id"], + "within_hybrid_memory_limit", + position, + metadata=_hybrid_memory_decision_metadata( + embedding_config_id=semantic_retrieval.embedding_config_id, + memory_key=candidate.memory["memory_key"], + status=candidate.memory["status"], + source_event_ids=candidate.memory["source_event_ids"], + selected_sources=candidate.sources, + semantic_score=candidate.semantic_score, + ), + ) + ) + continue + + decisions.append( + CompilerDecision( + "excluded", + "memory", + candidate.memory["id"], + "hybrid_memory_limit_exceeded", + position, + metadata=_hybrid_memory_decision_metadata( + embedding_config_id=semantic_retrieval.embedding_config_id, + memory_key=candidate.memory["memory_key"], + status=candidate.memory["status"], + source_event_ids=candidate.memory["source_event_ids"], + selected_sources=candidate.sources, + semantic_score=candidate.semantic_score, + ), + ) + ) + + for position, candidate in enumerate(deleted_candidates, start=1): + decisions.append( + CompilerDecision( + "excluded", + "memory", + candidate.memory["id"], + "hybrid_memory_deleted", + position, + metadata=_hybrid_memory_decision_metadata( + embedding_config_id=semantic_retrieval.embedding_config_id, + memory_key=candidate.memory["memory_key"], + status=candidate.memory["status"], + source_event_ids=candidate.memory["source_event_ids"], + selected_sources=candidate.sources, + semantic_score=None, + ), + ) + ) + + return CompiledMemorySection( + items=[_serialize_hybrid_memory(candidate) for candidate in included_candidates], + summary={ + "candidate_count": len(merged_candidates) + len(deleted_candidates), + "included_count": len(included_candidates), + "excluded_deleted_count": len(deleted_candidates), + "excluded_limit_count": len(excluded_candidates), + "hybrid_retrieval": { + "requested": True, + "embedding_config_id": str(semantic_retrieval.embedding_config_id), + "query_vector_dimensions": len(query_vector), + "semantic_limit": semantic_retrieval.limit, + "symbolic_selected_count": len(symbolic_candidates), + "semantic_selected_count": len(selected_semantic_candidates), + "merged_candidate_count": len(merged_candidates), + "deduplicated_count": deduplicated_count, + "included_symbolic_only_count": included_symbolic_only_count, + "included_semantic_only_count": included_semantic_only_count, + "included_dual_source_count": included_dual_source_count, + "similarity_metric": "cosine_similarity", + "source_precedence": list(HYBRID_MEMORY_SOURCE_PRECEDENCE), + "symbolic_order": list(HYBRID_SYMBOLIC_ORDER), + "semantic_order": list(SEMANTIC_MEMORY_RETRIEVAL_ORDER), + }, + }, + decisions=decisions, + ) + + +def _compile_open_loop_section( + *, + open_loops: list[OpenLoopRow], + limits: ContextCompilerLimits, +) -> CompiledOpenLoopSection: + ordered_open_loops = sorted(open_loops, key=_open_loop_sort_key, reverse=True) + included_open_loops = ( + ordered_open_loops[: limits.max_memories] if limits.max_memories > 0 else [] + ) + excluded_open_loops = ( + ordered_open_loops[limits.max_memories :] if limits.max_memories > 0 else ordered_open_loops + ) + + decisions: list[CompilerDecision] = [] + for position, open_loop in enumerate(included_open_loops, start=1): + decisions.append( + CompilerDecision( + "included", + "open_loop", + open_loop["id"], + "within_open_loop_limit", + position, + metadata={ + "title": open_loop["title"], + "status": open_loop["status"], + "memory_id": ( + None if open_loop["memory_id"] is None else str(open_loop["memory_id"]) + ), + "due_at": isoformat_or_none(open_loop["due_at"]), + }, + ) + ) + + for position, open_loop in enumerate(excluded_open_loops, start=1): + decisions.append( + CompilerDecision( + "excluded", + "open_loop", + open_loop["id"], + "open_loop_limit_exceeded", + position, + metadata={ + "title": open_loop["title"], + "status": open_loop["status"], + "memory_id": ( + None if open_loop["memory_id"] is None else str(open_loop["memory_id"]) + ), + "due_at": isoformat_or_none(open_loop["due_at"]), + }, + ) + ) + + return CompiledOpenLoopSection( + items=[_serialize_open_loop(open_loop) for open_loop in included_open_loops], + summary={ + "candidate_count": len(ordered_open_loops), + "included_count": len(included_open_loops), + "excluded_limit_count": len(excluded_open_loops), + "order": list(OPEN_LOOP_REVIEW_ORDER), + }, + decisions=decisions, + ) + + +def _compile_artifact_chunk_section( + store: ContinuityStore, + *, + artifact_retrieval: CompileContextArtifactRetrievalInput | None, + semantic_artifact_retrieval: CompileContextSemanticArtifactRetrievalInput | None, +) -> CompiledArtifactChunkSection: + if artifact_retrieval is None and semantic_artifact_retrieval is None: + return CompiledArtifactChunkSection( + items=[], + summary=_empty_artifact_chunk_summary(), + decisions=[], + ) + + artifact_rows, scope, scope_kind = _resolve_artifact_scope( + store, + artifact_retrieval=artifact_retrieval, + semantic_artifact_retrieval=semantic_artifact_retrieval, + ) + assert scope is not None + assert scope_kind is not None + + query = None if artifact_retrieval is None else artifact_retrieval.query + query_terms: list[str] = [] + lexical_items: list[ContextPackArtifactChunk] = [] + searched_artifact_count = sum( + 1 for artifact_row in artifact_rows if artifact_row["ingestion_status"] == "ingested" + ) + if artifact_retrieval is not None: + query_terms = resolve_artifact_chunk_retrieval_query_terms(artifact_retrieval.query) + lexical_matches, searched_artifact_count = retrieve_matching_task_artifact_chunks( + store, + artifact_rows=artifact_rows, + query_terms=query_terms, + ) + lexical_items = [ + { + "id": item["id"], + "task_id": item["task_id"], + "task_artifact_id": item["task_artifact_id"], + "relative_path": item["relative_path"], + "media_type": item["media_type"], + "sequence_no": item["sequence_no"], + "char_start": item["char_start"], + "char_end_exclusive": item["char_end_exclusive"], + "text": item["text"], + "source_provenance": { + "sources": ["lexical"], + "lexical_match": item["match"], + "semantic_score": None, + }, + } + for item in lexical_matches + ] + + semantic_items: list[ContextPackArtifactChunk] = [] + query_vector_dimensions = 0 + if isinstance( + semantic_artifact_retrieval, + CompileContextTaskScopedSemanticArtifactRetrievalInput, + ): + _config, query_vector = validate_semantic_artifact_chunk_retrieval_request( + store, + embedding_config_id=semantic_artifact_retrieval.embedding_config_id, + query_vector=semantic_artifact_retrieval.query_vector, + ) + query_vector_dimensions = len(query_vector) + semantic_items = [ + { + "id": item["id"], + "task_id": item["task_id"], + "task_artifact_id": item["task_artifact_id"], + "relative_path": item["relative_path"], + "media_type": item["media_type"], + "sequence_no": item["sequence_no"], + "char_start": item["char_start"], + "char_end_exclusive": item["char_end_exclusive"], + "text": item["text"], + "source_provenance": { + "sources": ["semantic"], + "lexical_match": None, + "semantic_score": item["score"], + }, + } + for item in [ + serialize_semantic_artifact_chunk_result_item(row) + for row in store.retrieve_task_scoped_semantic_artifact_chunk_matches( + task_id=semantic_artifact_retrieval.task_id, + embedding_config_id=semantic_artifact_retrieval.embedding_config_id, + query_vector=query_vector, + limit=_UNBOUNDED_SEMANTIC_ARTIFACT_RETRIEVAL_LIMIT, + ) + ] + ] + elif isinstance( + semantic_artifact_retrieval, + CompileContextArtifactScopedSemanticArtifactRetrievalInput, + ): + _config, query_vector = validate_semantic_artifact_chunk_retrieval_request( + store, + embedding_config_id=semantic_artifact_retrieval.embedding_config_id, + query_vector=semantic_artifact_retrieval.query_vector, + ) + query_vector_dimensions = len(query_vector) + semantic_items = [ + { + "id": item["id"], + "task_id": item["task_id"], + "task_artifact_id": item["task_artifact_id"], + "relative_path": item["relative_path"], + "media_type": item["media_type"], + "sequence_no": item["sequence_no"], + "char_start": item["char_start"], + "char_end_exclusive": item["char_end_exclusive"], + "text": item["text"], + "source_provenance": { + "sources": ["semantic"], + "lexical_match": None, + "semantic_score": item["score"], + }, + } + for item in [ + serialize_semantic_artifact_chunk_result_item(row) + for row in store.retrieve_artifact_scoped_semantic_artifact_chunk_matches( + task_artifact_id=semantic_artifact_retrieval.task_artifact_id, + embedding_config_id=semantic_artifact_retrieval.embedding_config_id, + query_vector=query_vector, + limit=_UNBOUNDED_SEMANTIC_ARTIFACT_RETRIEVAL_LIMIT, + ) + ] + ] + + merged_candidates: list[HybridArtifactChunkCandidate] = [] + merged_candidates_by_id: dict[str, HybridArtifactChunkCandidate] = {} + deduplicated_count = 0 + excluded_uningested_artifact_count = 0 + decisions: list[CompilerDecision] = [] + final_limit = ( + artifact_retrieval.limit + if artifact_retrieval is not None + else semantic_artifact_retrieval.limit + if semantic_artifact_retrieval is not None + else 0 + ) + + for position, artifact_row in enumerate(artifact_rows, start=1): + if artifact_row["ingestion_status"] == "ingested": + continue + excluded_uningested_artifact_count += 1 + decisions.append( + CompilerDecision( + "excluded", + "task_artifact", + artifact_row["id"], + "hybrid_artifact_not_ingested", + position, + metadata=_hybrid_artifact_retrieval_decision_metadata( + scope_kind=scope_kind, + task_id=artifact_row["task_id"], + task_artifact_id=artifact_row["id"], + relative_path=artifact_row["relative_path"], + media_type=infer_task_artifact_media_type(artifact_row), + ingestion_status=artifact_row["ingestion_status"], + limit=final_limit, + selected_sources=[], + embedding_config_id=( + None + if semantic_artifact_retrieval is None + else semantic_artifact_retrieval.embedding_config_id + ), + query_vector_dimensions=query_vector_dimensions, + ), + ) + ) + + for lexical_rank, item in enumerate(lexical_items, start=1): + candidate = HybridArtifactChunkCandidate( + item=item, + sources=["lexical"], + lexical_rank=lexical_rank, + ) + merged_candidates.append(candidate) + merged_candidates_by_id[item["id"]] = candidate + + for semantic_rank, item in enumerate(semantic_items, start=1): + existing_candidate = merged_candidates_by_id.get(item["id"]) + if existing_candidate is None: + candidate = HybridArtifactChunkCandidate( + item=item, + sources=["semantic"], + semantic_rank=semantic_rank, + ) + merged_candidates.append(candidate) + merged_candidates_by_id[item["id"]] = candidate + continue + + deduplicated_count += 1 + if "semantic" not in existing_candidate.sources: + existing_candidate.sources.append("semantic") + existing_candidate.semantic_rank = semantic_rank + existing_candidate.item["source_provenance"]["semantic_score"] = item["source_provenance"][ + "semantic_score" + ] + decisions.append( + CompilerDecision( + "included", + "artifact_chunk", + UUID(existing_candidate.item["id"]), + "hybrid_artifact_chunk_deduplicated", + semantic_rank, + metadata=_hybrid_artifact_retrieval_decision_metadata( + scope_kind=scope_kind, + task_id=UUID(existing_candidate.item["task_id"]), + task_artifact_id=UUID(existing_candidate.item["task_artifact_id"]), + relative_path=existing_candidate.item["relative_path"], + media_type=existing_candidate.item["media_type"], + ingestion_status="ingested", + limit=final_limit, + selected_sources=existing_candidate.sources, + embedding_config_id=semantic_artifact_retrieval.embedding_config_id, + query_vector_dimensions=query_vector_dimensions, + match=existing_candidate.item["source_provenance"]["lexical_match"], + score=existing_candidate.item["source_provenance"]["semantic_score"], + sequence_no=existing_candidate.item["sequence_no"], + char_start=existing_candidate.item["char_start"], + char_end_exclusive=existing_candidate.item["char_end_exclusive"], + ), + ) + ) + + merged_candidates.sort( + key=lambda candidate: ( + min( + HYBRID_ARTIFACT_SOURCE_PRECEDENCE.index(source) + for source in candidate.sources + ), + candidate.lexical_rank if candidate.lexical_rank is not None else 2_147_483_647, + candidate.semantic_rank if candidate.semantic_rank is not None else 2_147_483_647, + candidate.item["relative_path"], + candidate.item["sequence_no"], + candidate.item["id"], + ) + ) + + included_candidates = merged_candidates[:final_limit] if final_limit > 0 else [] + excluded_candidates = merged_candidates[final_limit:] if final_limit > 0 else merged_candidates + included_lexical_only_count = 0 + included_semantic_only_count = 0 + included_dual_source_count = 0 + + for position, candidate in enumerate(merged_candidates, start=1): + if position <= final_limit and final_limit > 0: + if candidate.sources == ["lexical"]: + included_lexical_only_count += 1 + elif candidate.sources == ["semantic"]: + included_semantic_only_count += 1 + else: + included_dual_source_count += 1 + decisions.append( + CompilerDecision( + "included", + "artifact_chunk", + UUID(candidate.item["id"]), + "within_hybrid_artifact_chunk_limit", + position, + metadata=_hybrid_artifact_retrieval_decision_metadata( + scope_kind=scope_kind, + task_id=UUID(candidate.item["task_id"]), + task_artifact_id=UUID(candidate.item["task_artifact_id"]), + relative_path=candidate.item["relative_path"], + media_type=candidate.item["media_type"], + ingestion_status="ingested", + limit=final_limit, + selected_sources=candidate.sources, + embedding_config_id=( + None + if semantic_artifact_retrieval is None + else semantic_artifact_retrieval.embedding_config_id + ), + query_vector_dimensions=query_vector_dimensions, + match=candidate.item["source_provenance"]["lexical_match"], + score=candidate.item["source_provenance"]["semantic_score"], + sequence_no=candidate.item["sequence_no"], + char_start=candidate.item["char_start"], + char_end_exclusive=candidate.item["char_end_exclusive"], + ), + ) + ) + continue + + decisions.append( + CompilerDecision( + "excluded", + "artifact_chunk", + UUID(candidate.item["id"]), + "hybrid_artifact_chunk_limit_exceeded", + position, + metadata=_hybrid_artifact_retrieval_decision_metadata( + scope_kind=scope_kind, + task_id=UUID(candidate.item["task_id"]), + task_artifact_id=UUID(candidate.item["task_artifact_id"]), + relative_path=candidate.item["relative_path"], + media_type=candidate.item["media_type"], + ingestion_status="ingested", + limit=final_limit, + selected_sources=candidate.sources, + embedding_config_id=( + None + if semantic_artifact_retrieval is None + else semantic_artifact_retrieval.embedding_config_id + ), + query_vector_dimensions=query_vector_dimensions, + match=candidate.item["source_provenance"]["lexical_match"], + score=candidate.item["source_provenance"]["semantic_score"], + sequence_no=candidate.item["sequence_no"], + char_start=candidate.item["char_start"], + char_end_exclusive=candidate.item["char_end_exclusive"], + ), + ) + ) + + return CompiledArtifactChunkSection( + items=[_serialize_hybrid_artifact_chunk(candidate) for candidate in included_candidates], + summary={ + "requested": True, + "lexical_requested": artifact_retrieval is not None, + "semantic_requested": semantic_artifact_retrieval is not None, + "scope": scope, + "query": query, + "query_terms": list(query_terms), + "embedding_config_id": ( + None + if semantic_artifact_retrieval is None + else str(semantic_artifact_retrieval.embedding_config_id) + ), + "query_vector_dimensions": query_vector_dimensions, + "limit": final_limit, + "lexical_limit": 0 if artifact_retrieval is None else artifact_retrieval.limit, + "semantic_limit": ( + 0 if semantic_artifact_retrieval is None else semantic_artifact_retrieval.limit + ), + "searched_artifact_count": searched_artifact_count, + "lexical_candidate_count": len(lexical_items), + "semantic_candidate_count": len(semantic_items), + "merged_candidate_count": len(merged_candidates), + "deduplicated_count": deduplicated_count, + "included_count": len(included_candidates), + "included_lexical_only_count": included_lexical_only_count, + "included_semantic_only_count": included_semantic_only_count, + "included_dual_source_count": included_dual_source_count, + "excluded_uningested_artifact_count": excluded_uningested_artifact_count, + "excluded_limit_count": len(excluded_candidates), + "matching_rule": ( + None + if artifact_retrieval is None + else TASK_ARTIFACT_CHUNK_RETRIEVAL_MATCHING_RULE + ), + "similarity_metric": ( + None if semantic_artifact_retrieval is None else "cosine_similarity" + ), + "source_precedence": list(HYBRID_ARTIFACT_SOURCE_PRECEDENCE), + "lexical_order": list(TASK_ARTIFACT_CHUNK_RETRIEVAL_ORDER), + "semantic_order": list(TASK_ARTIFACT_CHUNK_SEMANTIC_RETRIEVAL_ORDER), + "merged_order": list(HYBRID_ARTIFACT_MERGED_ORDER), + }, + decisions=decisions, + ) + + +def _task_sort_key(task: TaskRow) -> tuple[str, str]: + return (task["created_at"].isoformat(), str(task["id"])) + + +def _task_step_sort_key(task_step: TaskStepRow) -> tuple[int, str, str]: + return ( + task_step["sequence_no"], + task_step["created_at"].isoformat(), + str(task_step["id"]), + ) + + +def _build_resumption_section_summary( + *, + limit: int, + returned_count: int, + total_count: int, + order: list[str], +) -> ResumptionBriefSectionSummary: + return { + "limit": limit, + "returned_count": returned_count, + "total_count": total_count, + "order": order, + } + + +def _build_resumption_workflow_posture( + *, + store: ContinuityStore, + thread_id: UUID, +) -> ResumptionBriefWorkflowPosture | None: + ordered_tasks = sorted( + [task for task in store.list_tasks() if task["thread_id"] == thread_id], + key=_task_sort_key, + ) + latest_task = ordered_tasks[-1] if ordered_tasks else None + if latest_task is None: + return None + + ordered_task_steps = sorted( + store.list_task_steps_for_task(latest_task["id"]), + key=_task_step_sort_key, + ) + latest_task_step = ordered_task_steps[-1] if ordered_task_steps else None + summary: ResumptionBriefWorkflowSummary = { + "present": True, + "task_order": list(TASK_LIST_ORDER), + "task_step_order": list(TASK_STEP_LIST_ORDER), + } + return { + "task": serialize_task_row(latest_task), + "latest_task_step": None if latest_task_step is None else serialize_task_step_row(latest_task_step), + "summary": summary, + } + + +def compile_resumption_brief( + store: ContinuityStore, + *, + thread: ThreadRow, + event_limit: int = DEFAULT_RESUMPTION_BRIEF_EVENT_LIMIT, + open_loop_limit: int = DEFAULT_RESUMPTION_BRIEF_OPEN_LOOP_LIMIT, + memory_limit: int = DEFAULT_RESUMPTION_BRIEF_MEMORY_LIMIT, +) -> ResumptionBriefRecord: + bounded_event_limit = max(0, event_limit) + bounded_open_loop_limit = max(0, open_loop_limit) + bounded_memory_limit = max(0, memory_limit) + agent_profile_id = _resolve_thread_agent_profile_id(thread) + + conversation_kinds = frozenset(RESUMPTION_BRIEF_CONVERSATION_EVENT_KINDS) + ordered_conversation_events = [ + event for event in store.list_thread_events(thread["id"]) if event["kind"] in conversation_kinds + ] + included_events = ( + ordered_conversation_events[-bounded_event_limit:] if bounded_event_limit > 0 else [] + ) + conversation_section: ResumptionBriefConversationSection = { + "items": [_serialize_event(event) for event in included_events], + "summary": { + **_build_resumption_section_summary( + limit=bounded_event_limit, + returned_count=len(included_events), + total_count=len(ordered_conversation_events), + order=list(RESUMPTION_BRIEF_CONVERSATION_ORDER), + ), + "kinds": list(RESUMPTION_BRIEF_CONVERSATION_EVENT_KINDS), + }, + } + + ordered_open_loops = store.list_open_loops(status="open") + included_open_loops = ( + ordered_open_loops[:bounded_open_loop_limit] if bounded_open_loop_limit > 0 else [] + ) + open_loop_section = { + "items": [_serialize_open_loop(open_loop) for open_loop in included_open_loops], + "summary": _build_resumption_section_summary( + limit=bounded_open_loop_limit, + returned_count=len(included_open_loops), + total_count=len(ordered_open_loops), + order=list(OPEN_LOOP_REVIEW_ORDER), + ), + } + + ordered_memories = sorted( + [ + memory + for memory in _list_context_memories_for_profile( + store, + agent_profile_id=agent_profile_id, + ) + if memory["status"] == "active" + ], + key=_memory_sort_key, + ) + included_memories = ordered_memories[-bounded_memory_limit:] if bounded_memory_limit > 0 else [] + memory_highlight_section: ResumptionBriefMemoryHighlightSection = { + "items": [_serialize_memory(memory) for memory in included_memories], + "summary": _build_resumption_section_summary( + limit=bounded_memory_limit, + returned_count=len(included_memories), + total_count=len(ordered_memories), + order=list(RESUMPTION_BRIEF_MEMORY_ORDER), + ), + } + + workflow_posture = _build_resumption_workflow_posture( + store=store, + thread_id=thread["id"], + ) + sources = ["threads", "events", "open_loops", "memories"] + if workflow_posture is not None: + sources.extend(["tasks", "task_steps"]) + + return { + "assembly_version": RESUMPTION_BRIEF_ASSEMBLY_VERSION_V0, + "thread": _serialize_thread(thread), + "conversation": conversation_section, + "open_loops": open_loop_section, + "memory_highlights": memory_highlight_section, + "workflow": workflow_posture, + "sources": sources, + } + + +def compile_continuity_context( + *, + user: UserRow, + thread: ThreadRow, + sessions: list[SessionRow], + events: list[EventRow], + memories: list[MemoryRow], + entities: list[EntityRow], + entity_edges: list[EntityEdgeRow], + limits: ContextCompilerLimits, + open_loops: list[OpenLoopRow] | None = None, + memory_section: CompiledMemorySection | None = None, + open_loop_section: CompiledOpenLoopSection | None = None, + artifact_chunk_section: CompiledArtifactChunkSection | None = None, +) -> CompilerRunResult: + latest_session_sequence: dict[UUID, int] = {} + for event in events: + session_id = event["session_id"] + if session_id is None: + continue + latest_session_sequence[session_id] = max( + latest_session_sequence.get(session_id, -1), + event["sequence_no"], + ) + + ordered_sessions = sorted( + sessions, + key=lambda session: _session_sort_key(session, latest_session_sequence), + ) + included_sessions = ordered_sessions[-limits.max_sessions :] if limits.max_sessions > 0 else [] + included_session_ids = {session["id"] for session in included_sessions} + + decisions: list[CompilerDecision] = [ + CompilerDecision("included", "user", user["id"], "scope_user", 1), + CompilerDecision("included", "thread", thread["id"], "scope_thread", 1), + ] + + for position, session in enumerate(included_sessions, start=1): + decisions.append( + CompilerDecision( + "included", + "session", + session["id"], + "within_session_limit", + position, + ) + ) + + excluded_sessions = ordered_sessions[: max(len(ordered_sessions) - len(included_sessions), 0)] + for position, session in enumerate(excluded_sessions, start=1): + decisions.append( + CompilerDecision( + "excluded", + "session", + session["id"], + "session_limit_exceeded", + position, + ) + ) + + eligible_events: list[EventRow] = [] + for event in events: + if event["session_id"] is not None and event["session_id"] not in included_session_ids: + decisions.append( + CompilerDecision( + "excluded", + "event", + event["id"], + "session_not_included", + event["sequence_no"], + ) + ) + continue + eligible_events.append(event) + + included_events = eligible_events[-limits.max_events :] if limits.max_events > 0 else [] + included_event_ids = {event["id"] for event in included_events} + + for event in eligible_events: + if event["id"] in included_event_ids: + decisions.append( + CompilerDecision( + "included", + "event", + event["id"], + "within_event_limit", + event["sequence_no"], + ) + ) + continue + + decisions.append( + CompilerDecision( + "excluded", + "event", + event["id"], + "event_limit_exceeded", + event["sequence_no"], + ) + ) + + resolved_memory_section = memory_section or _build_symbolic_memory_section( + memories=memories, + limits=limits, + ) + decisions.extend(resolved_memory_section.decisions) + resolved_open_loop_section = open_loop_section or _compile_open_loop_section( + open_loops=[] if open_loops is None else open_loops, + limits=limits, + ) + decisions.extend(resolved_open_loop_section.decisions) + resolved_artifact_chunk_section = artifact_chunk_section or CompiledArtifactChunkSection( + items=[], + summary=_empty_artifact_chunk_summary(), + decisions=[], + ) + decisions.extend(resolved_artifact_chunk_section.decisions) + ordered_entities = sorted(entities, key=_entity_sort_key) + included_entities = ordered_entities[-limits.max_entities :] if limits.max_entities > 0 else [] + included_entity_ids = {entity["id"] for entity in included_entities} + excluded_entity_limit_count = max(len(ordered_entities) - len(included_entities), 0) + + for position, entity in enumerate(ordered_entities, start=1): + if entity["id"] in included_entity_ids: + decisions.append( + CompilerDecision( + "included", + "entity", + entity["id"], + "within_entity_limit", + position, + metadata={ + "record_entity_type": entity["entity_type"], + "name": entity["name"], + "source_memory_ids": entity["source_memory_ids"], + }, + ) + ) + continue + + decisions.append( + CompilerDecision( + "excluded", + "entity", + entity["id"], + "entity_limit_exceeded", + position, + metadata={ + "record_entity_type": entity["entity_type"], + "name": entity["name"], + "source_memory_ids": entity["source_memory_ids"], + }, + ) + ) + + ordered_candidate_entity_edges = sorted( + [ + edge + for edge in entity_edges + if edge["from_entity_id"] in included_entity_ids + or edge["to_entity_id"] in included_entity_ids + ], + key=_entity_edge_sort_key, + ) + included_entity_edges = ( + ordered_candidate_entity_edges[-limits.max_entity_edges :] + if limits.max_entity_edges > 0 + else [] + ) + included_entity_edge_ids = {edge["id"] for edge in included_entity_edges} + excluded_entity_edge_limit_count = max( + len(ordered_candidate_entity_edges) - len(included_entity_edges), + 0, + ) + + for position, edge in enumerate(ordered_candidate_entity_edges, start=1): + attached_included_entity_ids = [ + str(entity_id) + for entity_id in (edge["from_entity_id"], edge["to_entity_id"]) + if entity_id in included_entity_ids + ] + metadata = { + "from_entity_id": str(edge["from_entity_id"]), + "to_entity_id": str(edge["to_entity_id"]), + "relationship_type": edge["relationship_type"], + "valid_from": isoformat_or_none(edge["valid_from"]), + "valid_to": isoformat_or_none(edge["valid_to"]), + "source_memory_ids": edge["source_memory_ids"], + "attached_included_entity_ids": attached_included_entity_ids, + } + if edge["id"] in included_entity_edge_ids: + decisions.append( + CompilerDecision( + "included", + "entity_edge", + edge["id"], + "within_entity_edge_limit", + position, + metadata=metadata, + ) + ) + continue + + decisions.append( + CompilerDecision( + "excluded", + "entity_edge", + edge["id"], + "entity_edge_limit_exceeded", + position, + metadata=metadata, + ) + ) + + trace_events = [decision.to_trace_event() for decision in decisions] + trace_events.append( + TraceEventRecord( + kind=SUMMARY_TRACE_EVENT_KIND, + payload={ + "included_session_count": len(included_sessions), + "excluded_session_count": len(excluded_sessions), + "included_event_count": len(included_events), + "excluded_event_count": len(events) - len(included_events), + "included_memory_count": resolved_memory_section.summary["included_count"], + "excluded_memory_count": ( + resolved_memory_section.summary["excluded_deleted_count"] + + resolved_memory_section.summary["excluded_limit_count"] + ), + "excluded_deleted_memory_count": resolved_memory_section.summary[ + "excluded_deleted_count" + ], + "excluded_memory_limit_count": resolved_memory_section.summary[ + "excluded_limit_count" + ], + "hybrid_memory_requested": resolved_memory_section.summary["hybrid_retrieval"][ + "requested" + ], + "hybrid_memory_candidate_count": resolved_memory_section.summary["candidate_count"], + "hybrid_memory_merged_candidate_count": resolved_memory_section.summary[ + "hybrid_retrieval" + ]["merged_candidate_count"], + "hybrid_memory_deduplicated_count": resolved_memory_section.summary[ + "hybrid_retrieval" + ]["deduplicated_count"], + "included_dual_source_memory_count": resolved_memory_section.summary[ + "hybrid_retrieval" + ]["included_dual_source_count"], + "included_open_loop_count": resolved_open_loop_section.summary["included_count"], + "excluded_open_loop_limit_count": resolved_open_loop_section.summary[ + "excluded_limit_count" + ], + "artifact_retrieval_requested": resolved_artifact_chunk_section.summary["requested"], + "artifact_retrieval_scope_kind": ( + None + if resolved_artifact_chunk_section.summary["scope"] is None + else resolved_artifact_chunk_section.summary["scope"]["kind"] + ), + "artifact_lexical_retrieval_requested": resolved_artifact_chunk_section.summary[ + "lexical_requested" + ], + "artifact_semantic_retrieval_requested": resolved_artifact_chunk_section.summary[ + "semantic_requested" + ], + "artifact_lexical_candidate_count": resolved_artifact_chunk_section.summary[ + "lexical_candidate_count" + ], + "artifact_semantic_candidate_count": resolved_artifact_chunk_section.summary[ + "semantic_candidate_count" + ], + "artifact_merged_candidate_count": resolved_artifact_chunk_section.summary[ + "merged_candidate_count" + ], + "artifact_deduplicated_count": resolved_artifact_chunk_section.summary[ + "deduplicated_count" + ], + "included_artifact_chunk_count": resolved_artifact_chunk_section.summary[ + "included_count" + ], + "included_dual_source_artifact_chunk_count": resolved_artifact_chunk_section.summary[ + "included_dual_source_count" + ], + "excluded_artifact_chunk_limit_count": resolved_artifact_chunk_section.summary[ + "excluded_limit_count" + ], + "excluded_uningested_artifact_count": resolved_artifact_chunk_section.summary[ + "excluded_uningested_artifact_count" + ], + "included_entity_count": len(included_entities), + "excluded_entity_count": excluded_entity_limit_count, + "excluded_entity_limit_count": excluded_entity_limit_count, + "included_entity_edge_count": len(included_entity_edges), + "excluded_entity_edge_count": excluded_entity_edge_limit_count, + "excluded_entity_edge_limit_count": excluded_entity_edge_limit_count, + "compiler_version": COMPILER_VERSION_V0, + }, + ) + ) + + context_pack: CompiledContextPack = { + "compiler_version": COMPILER_VERSION_V0, + "scope": { + "user_id": str(user["id"]), + "thread_id": str(thread["id"]), + }, + "limits": { + "max_sessions": limits.max_sessions, + "max_events": limits.max_events, + "max_memories": limits.max_memories, + "max_entities": limits.max_entities, + "max_entity_edges": limits.max_entity_edges, + }, + "user": _serialize_user(user), + "thread": _serialize_thread(thread), + "sessions": [_serialize_session(session) for session in included_sessions], + "events": [_serialize_event(event) for event in included_events], + "memories": list(resolved_memory_section.items), + "memory_summary": resolved_memory_section.summary, + "artifact_chunks": list(resolved_artifact_chunk_section.items), + "artifact_chunk_summary": resolved_artifact_chunk_section.summary, + "entities": [_serialize_entity(entity) for entity in included_entities], + "entity_summary": { + "candidate_count": len(ordered_entities), + "included_count": len(included_entities), + "excluded_limit_count": excluded_entity_limit_count, + }, + "entity_edges": [_serialize_entity_edge(edge) for edge in included_entity_edges], + "entity_edge_summary": { + "anchor_entity_count": len(included_entities), + "candidate_count": len(ordered_candidate_entity_edges), + "included_count": len(included_entity_edges), + "excluded_limit_count": excluded_entity_edge_limit_count, + }, + } + if resolved_open_loop_section.summary["candidate_count"] > 0: + context_pack["open_loops"] = list(resolved_open_loop_section.items) + context_pack["open_loop_summary"] = resolved_open_loop_section.summary + + return CompilerRunResult( + context_pack=context_pack, + trace_events=trace_events, + ) + + +def compile_and_persist_trace( + store: ContinuityStore, + *, + user_id: UUID, + thread_id: UUID, + limits: ContextCompilerLimits, + semantic_retrieval: CompileContextSemanticRetrievalInput | None = None, + artifact_retrieval: CompileContextArtifactRetrievalInput | None = None, + semantic_artifact_retrieval: CompileContextSemanticArtifactRetrievalInput | None = None, +) -> CompiledTraceRun: + user = store.get_user(user_id) + thread = store.get_thread(thread_id) + agent_profile_id = _resolve_thread_agent_profile_id(thread) + sessions = store.list_thread_sessions(thread_id) + events = store.list_thread_events(thread_id) + memories = _list_context_memories_for_profile(store, agent_profile_id=agent_profile_id) + open_loops = store.list_open_loops(status="open") + memory_section = _compile_memory_section( + store, + memories=memories, + agent_profile_id=agent_profile_id, + limits=limits, + semantic_retrieval=semantic_retrieval, + ) + open_loop_section = _compile_open_loop_section( + open_loops=open_loops, + limits=limits, + ) + artifact_chunk_section = _compile_artifact_chunk_section( + store, + artifact_retrieval=artifact_retrieval, + semantic_artifact_retrieval=semantic_artifact_retrieval, + ) + entities = store.list_entities() + ordered_entities = sorted(entities, key=_entity_sort_key) + included_entities = ordered_entities[-limits.max_entities :] if limits.max_entities > 0 else [] + entity_edges = store.list_entity_edges_for_entities([entity["id"] for entity in included_entities]) + compiler_run = compile_continuity_context( + user=user, + thread=thread, + sessions=sessions, + events=events, + memories=memories, + open_loops=open_loops, + entities=entities, + entity_edges=entity_edges, + limits=limits, + memory_section=memory_section, + open_loop_section=open_loop_section, + artifact_chunk_section=artifact_chunk_section, + ) + trace = store.create_trace( + user_id=user_id, + thread_id=thread_id, + kind=TRACE_KIND_CONTEXT_COMPILE, + compiler_version=COMPILER_VERSION_V0, + status="completed", + limits=limits.as_payload(), + ) + + for sequence_no, trace_event in enumerate(compiler_run.trace_events, start=1): + store.append_trace_event( + trace_id=trace["id"], + sequence_no=sequence_no, + kind=trace_event.kind, + payload=trace_event.payload, + ) + + return CompiledTraceRun( + trace_id=str(trace["id"]), + context_pack=compiler_run.context_pack, + trace_event_count=len(compiler_run.trace_events), + ) diff --git a/apps/api/src/alicebot_api/config.py b/apps/api/src/alicebot_api/config.py new file mode 100644 index 0000000..a6bf947 --- /dev/null +++ b/apps/api/src/alicebot_api/config.py @@ -0,0 +1,503 @@ +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from functools import lru_cache +import os +from uuid import UUID + +DEFAULT_APP_ENV = "development" +DEFAULT_APP_HOST = "127.0.0.1" +DEFAULT_APP_PORT = 8000 +DEFAULT_DATABASE_NAME = "alicebot" +DEFAULT_DATABASE_HOST = "localhost" +DEFAULT_DATABASE_PORT = 5432 +DEFAULT_DATABASE_URL = ( + f"postgresql://alicebot_app:alicebot_app@{DEFAULT_DATABASE_HOST}:" + f"{DEFAULT_DATABASE_PORT}/{DEFAULT_DATABASE_NAME}" +) +DEFAULT_DATABASE_ADMIN_URL = ( + f"postgresql://alicebot_admin:alicebot_admin@{DEFAULT_DATABASE_HOST}:" + f"{DEFAULT_DATABASE_PORT}/{DEFAULT_DATABASE_NAME}" +) +DEFAULT_REDIS_URL = f"redis://{DEFAULT_DATABASE_HOST}:6379/0" +DEFAULT_S3_ENDPOINT_URL = "http://localhost:9000" +DEFAULT_S3_ACCESS_KEY = "alicebot" +DEFAULT_S3_SECRET_KEY = "alicebot-secret" +DEFAULT_S3_BUCKET = "alicebot-local" +DEFAULT_HEALTHCHECK_TIMEOUT_SECONDS = 2 +DEFAULT_MODEL_PROVIDER = "openai_responses" +DEFAULT_MODEL_BASE_URL = "https://api.openai.com/v1" +DEFAULT_MODEL_NAME = "gpt-5-mini" +DEFAULT_MODEL_API_KEY = "" +DEFAULT_MODEL_TIMEOUT_SECONDS = 30 +DEFAULT_TASK_WORKSPACE_ROOT = "/tmp/alicebot/task-workspaces" +DEFAULT_GMAIL_SECRET_MANAGER_URL = "" +DEFAULT_CALENDAR_SECRET_MANAGER_URL = "" +DEFAULT_AUTH_USER_ID = "" +DEFAULT_RESPONSE_RATE_LIMIT_WINDOW_SECONDS = 60 +DEFAULT_RESPONSE_RATE_LIMIT_MAX_REQUESTS = 20 +DEFAULT_MAGIC_LINK_TTL_SECONDS = 900 +DEFAULT_AUTH_SESSION_TTL_SECONDS = 2_592_000 +DEFAULT_DEVICE_LINK_TTL_SECONDS = 600 +DEFAULT_TELEGRAM_LINK_TTL_SECONDS = 600 +DEFAULT_TELEGRAM_BOT_USERNAME = "alicebot" +DEFAULT_TELEGRAM_WEBHOOK_SECRET = "" +DEFAULT_TELEGRAM_BOT_TOKEN = "" +DEFAULT_HOSTED_CHAT_RATE_LIMIT_WINDOW_SECONDS = 60 +DEFAULT_HOSTED_CHAT_RATE_LIMIT_MAX_REQUESTS = 20 +DEFAULT_HOSTED_SCHEDULER_RATE_LIMIT_WINDOW_SECONDS = 300 +DEFAULT_HOSTED_SCHEDULER_RATE_LIMIT_MAX_REQUESTS = 20 +DEFAULT_HOSTED_ABUSE_WINDOW_SECONDS = 600 +DEFAULT_HOSTED_ABUSE_BLOCK_THRESHOLD = 5 +DEFAULT_HOSTED_RATE_LIMITS_ENABLED_BY_DEFAULT = True +DEFAULT_HOSTED_ABUSE_CONTROLS_ENABLED_BY_DEFAULT = True +DEFAULT_MAGIC_LINK_START_RATE_LIMIT_WINDOW_SECONDS = 300 +DEFAULT_MAGIC_LINK_START_RATE_LIMIT_MAX_REQUESTS = 5 +DEFAULT_MAGIC_LINK_VERIFY_RATE_LIMIT_WINDOW_SECONDS = 300 +DEFAULT_MAGIC_LINK_VERIFY_RATE_LIMIT_MAX_REQUESTS = 10 +DEFAULT_TELEGRAM_WEBHOOK_RATE_LIMIT_WINDOW_SECONDS = 60 +DEFAULT_TELEGRAM_WEBHOOK_RATE_LIMIT_MAX_REQUESTS = 120 +DEFAULT_CORS_ALLOWED_ORIGINS: tuple[str, ...] = () +DEFAULT_CORS_ALLOWED_METHODS: tuple[str, ...] = ( + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "OPTIONS", +) +DEFAULT_CORS_ALLOWED_HEADERS: tuple[str, ...] = ( + "Authorization", + "Content-Type", + "X-AliceBot-User-Id", + "X-Telegram-Bot-Api-Secret-Token", +) +DEFAULT_CORS_ALLOW_CREDENTIALS = False +DEFAULT_CORS_PREFLIGHT_MAX_AGE_SECONDS = 600 +DEFAULT_SECURITY_HEADERS_ENABLED = True +DEFAULT_SECURITY_HEADERS_HSTS_MAX_AGE_SECONDS = 31_536_000 +DEFAULT_SECURITY_HEADERS_HSTS_INCLUDE_SUBDOMAINS = True +DEFAULT_TRUST_PROXY_HEADERS = False +DEFAULT_TRUSTED_PROXY_IPS: tuple[str, ...] = () +DEFAULT_ENTRYPOINT_RATE_LIMIT_BACKEND = "redis" + +Environment = Mapping[str, str] + + +def _get_env_value(env: Environment, key: str, default: str) -> str: + return env.get(key, default) + + +def _get_env_int(env: Environment, key: str, default: int) -> int: + raw_value = env.get(key) + if raw_value is None: + return default + + try: + return int(raw_value) + except ValueError as exc: + raise ValueError(f"{key} must be an integer") from exc + + +def _get_env_csv(env: Environment, key: str, default: tuple[str, ...]) -> tuple[str, ...]: + raw_value = env.get(key) + if raw_value is None: + return default + + return tuple(item.strip() for item in raw_value.split(",") if item.strip() != "") + + +def _normalize_csv_tokens( + values: tuple[str, ...], + *, + uppercase: bool = False, +) -> tuple[str, ...]: + normalized: list[str] = [] + for value in values: + token = value.strip() + if token == "": + continue + if uppercase: + token = token.upper() + if token not in normalized: + normalized.append(token) + return tuple(normalized) + + +@dataclass(frozen=True) +class Settings: + app_env: str = DEFAULT_APP_ENV + app_host: str = DEFAULT_APP_HOST + app_port: int = DEFAULT_APP_PORT + database_url: str = DEFAULT_DATABASE_URL + database_admin_url: str = DEFAULT_DATABASE_ADMIN_URL + redis_url: str = DEFAULT_REDIS_URL + s3_endpoint_url: str = DEFAULT_S3_ENDPOINT_URL + s3_access_key: str = DEFAULT_S3_ACCESS_KEY + s3_secret_key: str = DEFAULT_S3_SECRET_KEY + s3_bucket: str = DEFAULT_S3_BUCKET + healthcheck_timeout_seconds: int = DEFAULT_HEALTHCHECK_TIMEOUT_SECONDS + model_provider: str = DEFAULT_MODEL_PROVIDER + model_base_url: str = DEFAULT_MODEL_BASE_URL + model_name: str = DEFAULT_MODEL_NAME + model_api_key: str = DEFAULT_MODEL_API_KEY + model_timeout_seconds: int = DEFAULT_MODEL_TIMEOUT_SECONDS + task_workspace_root: str = DEFAULT_TASK_WORKSPACE_ROOT + gmail_secret_manager_url: str = DEFAULT_GMAIL_SECRET_MANAGER_URL + calendar_secret_manager_url: str = DEFAULT_CALENDAR_SECRET_MANAGER_URL + auth_user_id: str = DEFAULT_AUTH_USER_ID + response_rate_limit_window_seconds: int = DEFAULT_RESPONSE_RATE_LIMIT_WINDOW_SECONDS + response_rate_limit_max_requests: int = DEFAULT_RESPONSE_RATE_LIMIT_MAX_REQUESTS + magic_link_ttl_seconds: int = DEFAULT_MAGIC_LINK_TTL_SECONDS + auth_session_ttl_seconds: int = DEFAULT_AUTH_SESSION_TTL_SECONDS + device_link_ttl_seconds: int = DEFAULT_DEVICE_LINK_TTL_SECONDS + telegram_link_ttl_seconds: int = DEFAULT_TELEGRAM_LINK_TTL_SECONDS + telegram_bot_username: str = DEFAULT_TELEGRAM_BOT_USERNAME + telegram_webhook_secret: str = DEFAULT_TELEGRAM_WEBHOOK_SECRET + telegram_bot_token: str = DEFAULT_TELEGRAM_BOT_TOKEN + hosted_chat_rate_limit_window_seconds: int = DEFAULT_HOSTED_CHAT_RATE_LIMIT_WINDOW_SECONDS + hosted_chat_rate_limit_max_requests: int = DEFAULT_HOSTED_CHAT_RATE_LIMIT_MAX_REQUESTS + hosted_scheduler_rate_limit_window_seconds: int = DEFAULT_HOSTED_SCHEDULER_RATE_LIMIT_WINDOW_SECONDS + hosted_scheduler_rate_limit_max_requests: int = DEFAULT_HOSTED_SCHEDULER_RATE_LIMIT_MAX_REQUESTS + hosted_abuse_window_seconds: int = DEFAULT_HOSTED_ABUSE_WINDOW_SECONDS + hosted_abuse_block_threshold: int = DEFAULT_HOSTED_ABUSE_BLOCK_THRESHOLD + hosted_rate_limits_enabled_by_default: bool = DEFAULT_HOSTED_RATE_LIMITS_ENABLED_BY_DEFAULT + hosted_abuse_controls_enabled_by_default: bool = DEFAULT_HOSTED_ABUSE_CONTROLS_ENABLED_BY_DEFAULT + magic_link_start_rate_limit_window_seconds: int = ( + DEFAULT_MAGIC_LINK_START_RATE_LIMIT_WINDOW_SECONDS + ) + magic_link_start_rate_limit_max_requests: int = DEFAULT_MAGIC_LINK_START_RATE_LIMIT_MAX_REQUESTS + magic_link_verify_rate_limit_window_seconds: int = ( + DEFAULT_MAGIC_LINK_VERIFY_RATE_LIMIT_WINDOW_SECONDS + ) + magic_link_verify_rate_limit_max_requests: int = DEFAULT_MAGIC_LINK_VERIFY_RATE_LIMIT_MAX_REQUESTS + telegram_webhook_rate_limit_window_seconds: int = ( + DEFAULT_TELEGRAM_WEBHOOK_RATE_LIMIT_WINDOW_SECONDS + ) + telegram_webhook_rate_limit_max_requests: int = DEFAULT_TELEGRAM_WEBHOOK_RATE_LIMIT_MAX_REQUESTS + cors_allowed_origins: tuple[str, ...] = DEFAULT_CORS_ALLOWED_ORIGINS + cors_allowed_methods: tuple[str, ...] = DEFAULT_CORS_ALLOWED_METHODS + cors_allowed_headers: tuple[str, ...] = DEFAULT_CORS_ALLOWED_HEADERS + cors_allow_credentials: bool = DEFAULT_CORS_ALLOW_CREDENTIALS + cors_preflight_max_age_seconds: int = DEFAULT_CORS_PREFLIGHT_MAX_AGE_SECONDS + security_headers_enabled: bool = DEFAULT_SECURITY_HEADERS_ENABLED + security_headers_hsts_max_age_seconds: int = DEFAULT_SECURITY_HEADERS_HSTS_MAX_AGE_SECONDS + security_headers_hsts_include_subdomains: bool = ( + DEFAULT_SECURITY_HEADERS_HSTS_INCLUDE_SUBDOMAINS + ) + trust_proxy_headers: bool = DEFAULT_TRUST_PROXY_HEADERS + trusted_proxy_ips: tuple[str, ...] = DEFAULT_TRUSTED_PROXY_IPS + entrypoint_rate_limit_backend: str = DEFAULT_ENTRYPOINT_RATE_LIMIT_BACKEND + + @classmethod + def from_env(cls, env: Environment | None = None) -> "Settings": + current_env = os.environ if env is None else env + settings = cls( + app_env=_get_env_value(current_env, "APP_ENV", cls.app_env), + app_host=_get_env_value(current_env, "APP_HOST", cls.app_host), + app_port=_get_env_int(current_env, "APP_PORT", cls.app_port), + database_url=_get_env_value(current_env, "DATABASE_URL", cls.database_url), + database_admin_url=_get_env_value( + current_env, + "DATABASE_ADMIN_URL", + cls.database_admin_url, + ), + redis_url=_get_env_value(current_env, "REDIS_URL", cls.redis_url), + s3_endpoint_url=_get_env_value( + current_env, + "S3_ENDPOINT_URL", + cls.s3_endpoint_url, + ), + s3_access_key=_get_env_value(current_env, "S3_ACCESS_KEY", cls.s3_access_key), + s3_secret_key=_get_env_value(current_env, "S3_SECRET_KEY", cls.s3_secret_key), + s3_bucket=_get_env_value(current_env, "S3_BUCKET", cls.s3_bucket), + healthcheck_timeout_seconds=_get_env_int( + current_env, + "HEALTHCHECK_TIMEOUT_SECONDS", + cls.healthcheck_timeout_seconds, + ), + model_provider=_get_env_value(current_env, "MODEL_PROVIDER", cls.model_provider), + model_base_url=_get_env_value(current_env, "MODEL_BASE_URL", cls.model_base_url), + model_name=_get_env_value(current_env, "MODEL_NAME", cls.model_name), + model_api_key=_get_env_value(current_env, "MODEL_API_KEY", cls.model_api_key), + model_timeout_seconds=_get_env_int( + current_env, + "MODEL_TIMEOUT_SECONDS", + cls.model_timeout_seconds, + ), + task_workspace_root=_get_env_value( + current_env, + "TASK_WORKSPACE_ROOT", + cls.task_workspace_root, + ), + gmail_secret_manager_url=_get_env_value( + current_env, + "GMAIL_SECRET_MANAGER_URL", + cls.gmail_secret_manager_url, + ), + calendar_secret_manager_url=_get_env_value( + current_env, + "CALENDAR_SECRET_MANAGER_URL", + cls.calendar_secret_manager_url, + ), + auth_user_id=_get_env_value(current_env, "ALICEBOT_AUTH_USER_ID", cls.auth_user_id).strip(), + response_rate_limit_window_seconds=_get_env_int( + current_env, + "RESPONSE_RATE_LIMIT_WINDOW_SECONDS", + cls.response_rate_limit_window_seconds, + ), + response_rate_limit_max_requests=_get_env_int( + current_env, + "RESPONSE_RATE_LIMIT_MAX_REQUESTS", + cls.response_rate_limit_max_requests, + ), + magic_link_ttl_seconds=_get_env_int( + current_env, + "MAGIC_LINK_TTL_SECONDS", + cls.magic_link_ttl_seconds, + ), + auth_session_ttl_seconds=_get_env_int( + current_env, + "AUTH_SESSION_TTL_SECONDS", + cls.auth_session_ttl_seconds, + ), + device_link_ttl_seconds=_get_env_int( + current_env, + "DEVICE_LINK_TTL_SECONDS", + cls.device_link_ttl_seconds, + ), + telegram_link_ttl_seconds=_get_env_int( + current_env, + "TELEGRAM_LINK_TTL_SECONDS", + cls.telegram_link_ttl_seconds, + ), + telegram_bot_username=_get_env_value( + current_env, + "TELEGRAM_BOT_USERNAME", + cls.telegram_bot_username, + ).strip(), + telegram_webhook_secret=_get_env_value( + current_env, + "TELEGRAM_WEBHOOK_SECRET", + cls.telegram_webhook_secret, + ).strip(), + telegram_bot_token=_get_env_value( + current_env, + "TELEGRAM_BOT_TOKEN", + cls.telegram_bot_token, + ).strip(), + hosted_chat_rate_limit_window_seconds=_get_env_int( + current_env, + "HOSTED_CHAT_RATE_LIMIT_WINDOW_SECONDS", + cls.hosted_chat_rate_limit_window_seconds, + ), + hosted_chat_rate_limit_max_requests=_get_env_int( + current_env, + "HOSTED_CHAT_RATE_LIMIT_MAX_REQUESTS", + cls.hosted_chat_rate_limit_max_requests, + ), + hosted_scheduler_rate_limit_window_seconds=_get_env_int( + current_env, + "HOSTED_SCHEDULER_RATE_LIMIT_WINDOW_SECONDS", + cls.hosted_scheduler_rate_limit_window_seconds, + ), + hosted_scheduler_rate_limit_max_requests=_get_env_int( + current_env, + "HOSTED_SCHEDULER_RATE_LIMIT_MAX_REQUESTS", + cls.hosted_scheduler_rate_limit_max_requests, + ), + hosted_abuse_window_seconds=_get_env_int( + current_env, + "HOSTED_ABUSE_WINDOW_SECONDS", + cls.hosted_abuse_window_seconds, + ), + hosted_abuse_block_threshold=_get_env_int( + current_env, + "HOSTED_ABUSE_BLOCK_THRESHOLD", + cls.hosted_abuse_block_threshold, + ), + hosted_rate_limits_enabled_by_default=_get_env_value( + current_env, + "HOSTED_RATE_LIMITS_ENABLED_BY_DEFAULT", + "true" if cls.hosted_rate_limits_enabled_by_default else "false", + ).strip().lower() + in {"1", "true", "yes", "on"}, + hosted_abuse_controls_enabled_by_default=_get_env_value( + current_env, + "HOSTED_ABUSE_CONTROLS_ENABLED_BY_DEFAULT", + "true" if cls.hosted_abuse_controls_enabled_by_default else "false", + ).strip().lower() + in {"1", "true", "yes", "on"}, + magic_link_start_rate_limit_window_seconds=_get_env_int( + current_env, + "MAGIC_LINK_START_RATE_LIMIT_WINDOW_SECONDS", + cls.magic_link_start_rate_limit_window_seconds, + ), + magic_link_start_rate_limit_max_requests=_get_env_int( + current_env, + "MAGIC_LINK_START_RATE_LIMIT_MAX_REQUESTS", + cls.magic_link_start_rate_limit_max_requests, + ), + magic_link_verify_rate_limit_window_seconds=_get_env_int( + current_env, + "MAGIC_LINK_VERIFY_RATE_LIMIT_WINDOW_SECONDS", + cls.magic_link_verify_rate_limit_window_seconds, + ), + magic_link_verify_rate_limit_max_requests=_get_env_int( + current_env, + "MAGIC_LINK_VERIFY_RATE_LIMIT_MAX_REQUESTS", + cls.magic_link_verify_rate_limit_max_requests, + ), + telegram_webhook_rate_limit_window_seconds=_get_env_int( + current_env, + "TELEGRAM_WEBHOOK_RATE_LIMIT_WINDOW_SECONDS", + cls.telegram_webhook_rate_limit_window_seconds, + ), + telegram_webhook_rate_limit_max_requests=_get_env_int( + current_env, + "TELEGRAM_WEBHOOK_RATE_LIMIT_MAX_REQUESTS", + cls.telegram_webhook_rate_limit_max_requests, + ), + cors_allowed_origins=_normalize_csv_tokens( + _get_env_csv(current_env, "CORS_ALLOWED_ORIGINS", cls.cors_allowed_origins), + ), + cors_allowed_methods=_normalize_csv_tokens( + _get_env_csv(current_env, "CORS_ALLOWED_METHODS", cls.cors_allowed_methods), + uppercase=True, + ), + cors_allowed_headers=_normalize_csv_tokens( + _get_env_csv(current_env, "CORS_ALLOWED_HEADERS", cls.cors_allowed_headers), + ), + cors_allow_credentials=_get_env_value( + current_env, + "CORS_ALLOW_CREDENTIALS", + "true" if cls.cors_allow_credentials else "false", + ).strip().lower() + in {"1", "true", "yes", "on"}, + cors_preflight_max_age_seconds=_get_env_int( + current_env, + "CORS_PREFLIGHT_MAX_AGE_SECONDS", + cls.cors_preflight_max_age_seconds, + ), + security_headers_enabled=_get_env_value( + current_env, + "SECURITY_HEADERS_ENABLED", + "true" if cls.security_headers_enabled else "false", + ).strip().lower() + in {"1", "true", "yes", "on"}, + security_headers_hsts_max_age_seconds=_get_env_int( + current_env, + "SECURITY_HEADERS_HSTS_MAX_AGE_SECONDS", + cls.security_headers_hsts_max_age_seconds, + ), + security_headers_hsts_include_subdomains=_get_env_value( + current_env, + "SECURITY_HEADERS_HSTS_INCLUDE_SUBDOMAINS", + "true" if cls.security_headers_hsts_include_subdomains else "false", + ).strip().lower() + in {"1", "true", "yes", "on"}, + trust_proxy_headers=_get_env_value( + current_env, + "TRUST_PROXY_HEADERS", + "true" if cls.trust_proxy_headers else "false", + ).strip().lower() + in {"1", "true", "yes", "on"}, + trusted_proxy_ips=_normalize_csv_tokens( + _get_env_csv(current_env, "TRUSTED_PROXY_IPS", cls.trusted_proxy_ips), + ), + entrypoint_rate_limit_backend=_get_env_value( + current_env, + "ENTRYPOINT_RATE_LIMIT_BACKEND", + cls.entrypoint_rate_limit_backend, + ).strip().lower(), + ) + return _validate_settings(settings) + + +def _validate_settings(settings: Settings) -> Settings: + if settings.auth_user_id != "": + try: + UUID(settings.auth_user_id) + except ValueError as exc: + raise ValueError("ALICEBOT_AUTH_USER_ID must be a valid UUID") from exc + + if settings.response_rate_limit_window_seconds <= 0: + raise ValueError("RESPONSE_RATE_LIMIT_WINDOW_SECONDS must be a positive integer") + if settings.response_rate_limit_max_requests <= 0: + raise ValueError("RESPONSE_RATE_LIMIT_MAX_REQUESTS must be a positive integer") + if settings.magic_link_ttl_seconds <= 0: + raise ValueError("MAGIC_LINK_TTL_SECONDS must be a positive integer") + if settings.auth_session_ttl_seconds <= 0: + raise ValueError("AUTH_SESSION_TTL_SECONDS must be a positive integer") + if settings.device_link_ttl_seconds <= 0: + raise ValueError("DEVICE_LINK_TTL_SECONDS must be a positive integer") + if settings.telegram_link_ttl_seconds <= 0: + raise ValueError("TELEGRAM_LINK_TTL_SECONDS must be a positive integer") + if settings.telegram_bot_username == "": + raise ValueError("TELEGRAM_BOT_USERNAME must be provided") + if settings.hosted_chat_rate_limit_window_seconds <= 0: + raise ValueError("HOSTED_CHAT_RATE_LIMIT_WINDOW_SECONDS must be a positive integer") + if settings.hosted_chat_rate_limit_max_requests <= 0: + raise ValueError("HOSTED_CHAT_RATE_LIMIT_MAX_REQUESTS must be a positive integer") + if settings.hosted_scheduler_rate_limit_window_seconds <= 0: + raise ValueError("HOSTED_SCHEDULER_RATE_LIMIT_WINDOW_SECONDS must be a positive integer") + if settings.hosted_scheduler_rate_limit_max_requests <= 0: + raise ValueError("HOSTED_SCHEDULER_RATE_LIMIT_MAX_REQUESTS must be a positive integer") + if settings.hosted_abuse_window_seconds <= 0: + raise ValueError("HOSTED_ABUSE_WINDOW_SECONDS must be a positive integer") + if settings.hosted_abuse_block_threshold <= 0: + raise ValueError("HOSTED_ABUSE_BLOCK_THRESHOLD must be a positive integer") + if settings.magic_link_start_rate_limit_window_seconds <= 0: + raise ValueError("MAGIC_LINK_START_RATE_LIMIT_WINDOW_SECONDS must be a positive integer") + if settings.magic_link_start_rate_limit_max_requests <= 0: + raise ValueError("MAGIC_LINK_START_RATE_LIMIT_MAX_REQUESTS must be a positive integer") + if settings.magic_link_verify_rate_limit_window_seconds <= 0: + raise ValueError("MAGIC_LINK_VERIFY_RATE_LIMIT_WINDOW_SECONDS must be a positive integer") + if settings.magic_link_verify_rate_limit_max_requests <= 0: + raise ValueError("MAGIC_LINK_VERIFY_RATE_LIMIT_MAX_REQUESTS must be a positive integer") + if settings.telegram_webhook_rate_limit_window_seconds <= 0: + raise ValueError("TELEGRAM_WEBHOOK_RATE_LIMIT_WINDOW_SECONDS must be a positive integer") + if settings.telegram_webhook_rate_limit_max_requests <= 0: + raise ValueError("TELEGRAM_WEBHOOK_RATE_LIMIT_MAX_REQUESTS must be a positive integer") + if settings.cors_preflight_max_age_seconds <= 0: + raise ValueError("CORS_PREFLIGHT_MAX_AGE_SECONDS must be a positive integer") + if len(settings.cors_allowed_methods) == 0: + raise ValueError("CORS_ALLOWED_METHODS must include at least one method") + if settings.security_headers_hsts_max_age_seconds <= 0: + raise ValueError("SECURITY_HEADERS_HSTS_MAX_AGE_SECONDS must be a positive integer") + if settings.entrypoint_rate_limit_backend not in {"redis", "memory"}: + raise ValueError("ENTRYPOINT_RATE_LIMIT_BACKEND must be either 'redis' or 'memory'") + if settings.trust_proxy_headers and len(settings.trusted_proxy_ips) == 0: + raise ValueError("TRUSTED_PROXY_IPS must include at least one IP when TRUST_PROXY_HEADERS is enabled") + + if settings.app_env not in {"development", "test"}: + if "*" in settings.cors_allowed_origins: + raise ValueError( + "CORS_ALLOWED_ORIGINS cannot include wildcard outside development/test environments" + ) + if settings.auth_user_id == "": + raise ValueError( + "ALICEBOT_AUTH_USER_ID must be configured outside development/test environments" + ) + if settings.database_url == DEFAULT_DATABASE_URL: + raise ValueError("DATABASE_URL must be overridden outside development/test environments") + if settings.database_admin_url == DEFAULT_DATABASE_ADMIN_URL: + raise ValueError( + "DATABASE_ADMIN_URL must be overridden outside development/test environments" + ) + if settings.s3_access_key == DEFAULT_S3_ACCESS_KEY: + raise ValueError("S3_ACCESS_KEY must be overridden outside development/test environments") + if settings.s3_secret_key == DEFAULT_S3_SECRET_KEY: + raise ValueError("S3_SECRET_KEY must be overridden outside development/test environments") + if settings.telegram_webhook_secret == "": + raise ValueError( + "TELEGRAM_WEBHOOK_SECRET must be configured outside development/test environments" + ) + + return settings + + +@lru_cache(maxsize=1) +def get_settings() -> Settings: + return Settings.from_env() diff --git a/apps/api/src/alicebot_api/continuity_capture.py b/apps/api/src/alicebot_api/continuity_capture.py new file mode 100644 index 0000000..579693f --- /dev/null +++ b/apps/api/src/alicebot_api/continuity_capture.py @@ -0,0 +1,337 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass +from uuid import UUID + +from alicebot_api.continuity_objects import ( + create_continuity_object_record, + get_continuity_object_for_capture_event, + list_continuity_objects_for_capture_events, +) +from alicebot_api.contracts import ( + CONTINUITY_CAPTURE_EXPLICIT_SIGNALS, + CONTINUITY_CAPTURE_LIST_ORDER, + DEFAULT_CONTINUITY_CAPTURE_LIMIT, + MAX_CONTINUITY_CAPTURE_LIMIT, + ContinuityCaptureCreateInput, + ContinuityCaptureCreateResponse, + ContinuityCaptureDetailResponse, + ContinuityCaptureEventRecord, + ContinuityCaptureInboxItem, + ContinuityCaptureInboxResponse, +) +from alicebot_api.store import ContinuityCaptureEventRow, ContinuityStore, JsonObject + + +class ContinuityCaptureValidationError(ValueError): + """Raised when a continuity capture request is invalid.""" + + +class ContinuityCaptureNotFoundError(LookupError): + """Raised when a continuity capture event is not visible in scope.""" + + +@dataclass(frozen=True, slots=True) +class DerivedObjectDecision: + object_type: str + normalized_text: str + confidence: float + admission_reason: str + + +_EXPLICIT_SIGNAL_TO_OBJECT_TYPE: dict[str, str] = { + "remember_this": "MemoryFact", + "task": "NextAction", + "decision": "Decision", + "commitment": "Commitment", + "waiting_for": "WaitingFor", + "blocker": "Blocker", + "next_action": "NextAction", + "note": "Note", +} + +_HIGH_CONFIDENCE_PREFIXES: tuple[tuple[str, str, str], ...] = ( + ("decision:", "Decision", "high_confidence_prefix_decision"), + ("task:", "NextAction", "high_confidence_prefix_task"), + ("todo:", "NextAction", "high_confidence_prefix_todo"), + ("next:", "NextAction", "high_confidence_prefix_next_action"), + ("commitment:", "Commitment", "high_confidence_prefix_commitment"), + ("waiting for:", "WaitingFor", "high_confidence_prefix_waiting_for"), + ("blocker:", "Blocker", "high_confidence_prefix_blocker"), + ("remember:", "MemoryFact", "high_confidence_prefix_remember"), + ("fact:", "MemoryFact", "high_confidence_prefix_fact"), + ("note:", "Note", "high_confidence_prefix_note"), +) + + +def _normalize_content(raw_content: str) -> str: + return re.sub(r"\s+", " ", raw_content).strip() + + +def _truncate(value: str, *, max_length: int) -> str: + if len(value) <= max_length: + return value + return value[: max_length - 3].rstrip() + "..." + + +def _title_for_object_type(object_type: str, normalized_text: str) -> str: + if object_type == "Decision": + prefix = "Decision" + elif object_type == "Commitment": + prefix = "Commitment" + elif object_type == "WaitingFor": + prefix = "Waiting For" + elif object_type == "Blocker": + prefix = "Blocker" + elif object_type == "NextAction": + prefix = "Next Action" + elif object_type == "MemoryFact": + prefix = "Memory Fact" + else: + prefix = "Note" + return _truncate(f"{prefix}: {normalized_text}", max_length=280) + + +def _body_for_object_type( + *, + object_type: str, + normalized_text: str, + raw_content: str, + explicit_signal: str | None, +) -> JsonObject: + key_by_type = { + "Note": "body", + "MemoryFact": "fact_text", + "Decision": "decision_text", + "Commitment": "commitment_text", + "WaitingFor": "waiting_for_text", + "Blocker": "blocking_reason", + "NextAction": "action_text", + } + value_key = key_by_type[object_type] + payload: JsonObject = { + value_key: normalized_text, + "raw_content": raw_content, + } + payload["explicit_signal"] = explicit_signal + return payload + + +def _resolve_explicit_signal_decision( + *, + explicit_signal: str, + normalized_text: str, +) -> DerivedObjectDecision: + object_type = _EXPLICIT_SIGNAL_TO_OBJECT_TYPE[explicit_signal] + return DerivedObjectDecision( + object_type=object_type, + normalized_text=normalized_text, + confidence=1.0, + admission_reason=f"explicit_signal_{explicit_signal}", + ) + + +def _resolve_high_confidence_decision(normalized_text: str) -> DerivedObjectDecision | None: + lower = normalized_text.casefold() + + for prefix, object_type, reason in _HIGH_CONFIDENCE_PREFIXES: + if not lower.startswith(prefix): + continue + + stripped = _normalize_content(normalized_text[len(prefix) :]) + if not stripped: + return None + + return DerivedObjectDecision( + object_type=object_type, + normalized_text=stripped, + confidence=0.95, + admission_reason=reason, + ) + + return None + + +def _serialize_capture_event(row: ContinuityCaptureEventRow) -> ContinuityCaptureEventRecord: + return { + "id": str(row["id"]), + "raw_content": row["raw_content"], + "explicit_signal": row["explicit_signal"], + "admission_posture": row["admission_posture"], + "admission_reason": row["admission_reason"], + "created_at": row["created_at"].isoformat(), + } + + +def _build_inbox_item( + capture_event: ContinuityCaptureEventRecord, + derived_object: dict[str, object] | None, +) -> ContinuityCaptureInboxItem: + return { + "capture_event": capture_event, + "derived_object": derived_object, + } + + +def _validate_capture_limit(limit: int) -> None: + if limit < 1 or limit > MAX_CONTINUITY_CAPTURE_LIMIT: + raise ContinuityCaptureValidationError( + f"limit must be between 1 and {MAX_CONTINUITY_CAPTURE_LIMIT}" + ) + + +def capture_continuity_input( + store: ContinuityStore, + *, + user_id: UUID, + request: ContinuityCaptureCreateInput, +) -> ContinuityCaptureCreateResponse: + del user_id + + normalized_text = _normalize_content(request.raw_content) + if not normalized_text: + raise ContinuityCaptureValidationError("raw_content must not be empty") + + explicit_signal = request.explicit_signal + if explicit_signal is not None and explicit_signal not in CONTINUITY_CAPTURE_EXPLICIT_SIGNALS: + allowed = ", ".join(CONTINUITY_CAPTURE_EXPLICIT_SIGNALS) + raise ContinuityCaptureValidationError( + f"explicit_signal must be one of: {allowed}" + ) + + decision: DerivedObjectDecision | None = None + admission_posture = "TRIAGE" + admission_reason = "ambiguous_capture_requires_triage" + + if explicit_signal is not None: + decision = _resolve_explicit_signal_decision( + explicit_signal=explicit_signal, + normalized_text=normalized_text, + ) + admission_posture = "DERIVED" + admission_reason = decision.admission_reason + else: + decision = _resolve_high_confidence_decision(normalized_text) + if decision is not None: + admission_posture = "DERIVED" + admission_reason = decision.admission_reason + + capture_event_row = store.create_continuity_capture_event( + raw_content=normalized_text, + explicit_signal=explicit_signal, + admission_posture=admission_posture, + admission_reason=admission_reason, + ) + + serialized_capture = _serialize_capture_event(capture_event_row) + derived_object = None + + if decision is not None: + provenance: JsonObject = { + "capture_event_id": str(capture_event_row["id"]), + "source_kind": "continuity_capture_event", + "admission_reason": decision.admission_reason, + "explicit_signal": explicit_signal, + } + derived_object = create_continuity_object_record( + store, + user_id=UUID(str(capture_event_row["user_id"])), + capture_event_id=capture_event_row["id"], + object_type=decision.object_type, + status="active", + title=_title_for_object_type(decision.object_type, decision.normalized_text), + body=_body_for_object_type( + object_type=decision.object_type, + normalized_text=decision.normalized_text, + raw_content=normalized_text, + explicit_signal=explicit_signal, + ), + provenance=provenance, + confidence=decision.confidence, + ) + + return { + "capture": _build_inbox_item(serialized_capture, derived_object), + } + + +def list_continuity_capture_inbox( + store: ContinuityStore, + *, + user_id: UUID, + limit: int = DEFAULT_CONTINUITY_CAPTURE_LIMIT, +) -> ContinuityCaptureInboxResponse: + _validate_capture_limit(limit) + + events = store.list_continuity_capture_events(limit=limit) + event_ids = [row["id"] for row in events] + object_map = list_continuity_objects_for_capture_events( + store, + user_id=user_id, + capture_event_ids=event_ids, + ) + + items: list[ContinuityCaptureInboxItem] = [] + triage_count = 0 + + for event in events: + serialized_capture = _serialize_capture_event(event) + if serialized_capture["admission_posture"] == "TRIAGE": + triage_count += 1 + items.append( + _build_inbox_item( + serialized_capture, + object_map.get(str(event["id"])), + ) + ) + + total_count = store.count_continuity_capture_events() + derived_count = len(items) - triage_count + + return { + "items": items, + "summary": { + "limit": limit, + "returned_count": len(items), + "total_count": total_count, + "derived_count": derived_count, + "triage_count": triage_count, + "order": list(CONTINUITY_CAPTURE_LIST_ORDER), + }, + } + + +def get_continuity_capture_detail( + store: ContinuityStore, + *, + user_id: UUID, + capture_event_id: UUID, +) -> ContinuityCaptureDetailResponse: + del user_id + + event = store.get_continuity_capture_event_optional(capture_event_id) + if event is None: + raise ContinuityCaptureNotFoundError( + f"continuity capture event {capture_event_id} was not found" + ) + + serialized_event = _serialize_capture_event(event) + derived_object = get_continuity_object_for_capture_event( + store, + user_id=UUID(str(event["user_id"])), + capture_event_id=capture_event_id, + ) + + return { + "capture": _build_inbox_item(serialized_event, derived_object), + } + + +__all__ = [ + "ContinuityCaptureNotFoundError", + "ContinuityCaptureValidationError", + "capture_continuity_input", + "get_continuity_capture_detail", + "list_continuity_capture_inbox", +] diff --git a/apps/api/src/alicebot_api/continuity_lifecycle.py b/apps/api/src/alicebot_api/continuity_lifecycle.py new file mode 100644 index 0000000..5c81e9f --- /dev/null +++ b/apps/api/src/alicebot_api/continuity_lifecycle.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +from uuid import UUID + +from alicebot_api.continuity_objects import serialize_continuity_lifecycle_state_from_record +from alicebot_api.contracts import ( + CONTINUITY_LIFECYCLE_LIST_ORDER, + DEFAULT_CONTINUITY_LIFECYCLE_LIMIT, + MAX_CONTINUITY_LIFECYCLE_LIMIT, + ContinuityLifecycleDetailResponse, + ContinuityLifecycleListResponse, + ContinuityLifecycleQueryInput, + ContinuityReviewObjectRecord, + isoformat_or_none, +) +from alicebot_api.store import ContinuityObjectRow, ContinuityRecallCandidateRow, ContinuityStore + + +class ContinuityLifecycleValidationError(ValueError): + """Raised when a continuity lifecycle inspection request is invalid.""" + + +class ContinuityLifecycleNotFoundError(LookupError): + """Raised when a continuity object is not visible in scope.""" + + +def _serialize_object(record: ContinuityObjectRow | ContinuityRecallCandidateRow) -> ContinuityReviewObjectRecord: + return { + "id": str(record["id"]), + "capture_event_id": str(record["capture_event_id"]), + "object_type": record["object_type"], # type: ignore[typeddict-item] + "status": record["status"], + "lifecycle": serialize_continuity_lifecycle_state_from_record(record), + "title": record["title"], + "body": record["body"], + "provenance": record["provenance"], + "confidence": float(record["confidence"]), + "last_confirmed_at": isoformat_or_none(record["last_confirmed_at"]), + "supersedes_object_id": ( + None if record["supersedes_object_id"] is None else str(record["supersedes_object_id"]) + ), + "superseded_by_object_id": ( + None if record["superseded_by_object_id"] is None else str(record["superseded_by_object_id"]) + ), + "created_at": ( + record["object_created_at"].isoformat() + if "object_created_at" in record + else record["created_at"].isoformat() + ), + "updated_at": ( + record["object_updated_at"].isoformat() + if "object_updated_at" in record + else record["updated_at"].isoformat() + ), + } + + +def _validate_limit(limit: int) -> None: + if limit < 1 or limit > MAX_CONTINUITY_LIFECYCLE_LIMIT: + raise ContinuityLifecycleValidationError( + f"limit must be between 1 and {MAX_CONTINUITY_LIFECYCLE_LIMIT}" + ) + + +def list_continuity_lifecycle_state( + store: ContinuityStore, + *, + user_id: UUID, + request: ContinuityLifecycleQueryInput, +) -> ContinuityLifecycleListResponse: + del user_id + + _validate_limit(request.limit) + rows = sorted( + store.list_continuity_recall_candidates(), + key=lambda row: (row["object_updated_at"], str(row["id"])), + reverse=True, + ) + items = [_serialize_object(row) for row in rows[: request.limit]] + total_count = len(rows) + searchable_count = sum(1 for row in rows if row["is_searchable"]) + promotable_count = sum(1 for row in rows if row["is_promotable"]) + + return { + "items": items, + "summary": { + "limit": request.limit, + "returned_count": len(items), + "total_count": total_count, + "counts": { + "preserved_count": sum(1 for row in rows if row["is_preserved"]), + "searchable_count": searchable_count, + "promotable_count": promotable_count, + "not_searchable_count": total_count - searchable_count, + "not_promotable_count": total_count - promotable_count, + }, + "order": list(CONTINUITY_LIFECYCLE_LIST_ORDER), + }, + } + + +def get_continuity_lifecycle_state( + store: ContinuityStore, + *, + user_id: UUID, + continuity_object_id: UUID, +) -> ContinuityLifecycleDetailResponse: + del user_id + + record = store.get_continuity_object_optional(continuity_object_id) + if record is None: + raise ContinuityLifecycleNotFoundError( + f"continuity object {continuity_object_id} was not found" + ) + return { + "continuity_object": _serialize_object(record), + } + + +def build_default_continuity_lifecycle_query() -> ContinuityLifecycleQueryInput: + return ContinuityLifecycleQueryInput(limit=DEFAULT_CONTINUITY_LIFECYCLE_LIMIT) diff --git a/apps/api/src/alicebot_api/continuity_objects.py b/apps/api/src/alicebot_api/continuity_objects.py new file mode 100644 index 0000000..fac5c1a --- /dev/null +++ b/apps/api/src/alicebot_api/continuity_objects.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +from collections.abc import Mapping +from uuid import UUID + +from alicebot_api.contracts import ( + CONTINUITY_OBJECT_TYPES, + ContinuityLifecycleStateRecord, + ContinuityObjectRecord, +) +from alicebot_api.store import ContinuityObjectRow, ContinuityStore, JsonObject + + +class ContinuityObjectValidationError(ValueError): + """Raised when a continuity object request is invalid.""" + + +def default_continuity_searchable(object_type: str) -> bool: + return object_type != "Note" + + +def default_continuity_promotable(object_type: str) -> bool: + return object_type in {"Decision", "Commitment", "WaitingFor", "Blocker", "NextAction"} + + +def serialize_continuity_lifecycle_state( + *, + is_preserved: bool, + is_searchable: bool, + is_promotable: bool, +) -> ContinuityLifecycleStateRecord: + return { + "is_preserved": is_preserved, + "preservation_status": "preserved" if is_preserved else "not_preserved", + "is_searchable": is_searchable, + "searchability_status": "searchable" if is_searchable else "not_searchable", + "is_promotable": is_promotable, + "promotion_status": "promotable" if is_promotable else "not_promotable", + } + + +def serialize_continuity_lifecycle_state_from_record( + record: Mapping[str, object], +) -> ContinuityLifecycleStateRecord: + object_type = str(record["object_type"]) + return serialize_continuity_lifecycle_state( + is_preserved=bool(record.get("is_preserved", True)), + is_searchable=bool(record.get("is_searchable", default_continuity_searchable(object_type))), + is_promotable=bool(record.get("is_promotable", default_continuity_promotable(object_type))), + ) + + +def _serialize_continuity_object(record: ContinuityObjectRow) -> ContinuityObjectRecord: + return { + "id": str(record["id"]), + "capture_event_id": str(record["capture_event_id"]), + "object_type": record["object_type"], + "status": record["status"], + "lifecycle": serialize_continuity_lifecycle_state_from_record(record), + "title": record["title"], + "body": record["body"], + "provenance": record["provenance"], + "confidence": record["confidence"], + "created_at": record["created_at"].isoformat(), + "updated_at": record["updated_at"].isoformat(), + } + + +def _validate_object_type(object_type: str) -> None: + if object_type not in CONTINUITY_OBJECT_TYPES: + allowed = ", ".join(CONTINUITY_OBJECT_TYPES) + raise ContinuityObjectValidationError( + f"object_type must be one of: {allowed}" + ) + + +def _validate_title(title: str) -> None: + normalized = title.strip() + if not normalized: + raise ContinuityObjectValidationError("title must not be empty") + if len(normalized) > 280: + raise ContinuityObjectValidationError("title must be 280 characters or fewer") + + +def _validate_confidence(confidence: float) -> None: + if confidence < 0.0 or confidence > 1.0: + raise ContinuityObjectValidationError("confidence must be between 0.0 and 1.0") + + +def create_continuity_object_record( + store: ContinuityStore, + *, + user_id: UUID, + capture_event_id: UUID, + object_type: str, + title: str, + body: JsonObject, + provenance: JsonObject, + confidence: float, + status: str = "active", + is_preserved: bool = True, + is_searchable: bool | None = None, + is_promotable: bool | None = None, +) -> ContinuityObjectRecord: + del user_id + + _validate_object_type(object_type) + _validate_title(title) + _validate_confidence(confidence) + resolved_is_searchable = ( + default_continuity_searchable(object_type) + if is_searchable is None + else is_searchable + ) + resolved_is_promotable = ( + default_continuity_promotable(object_type) + if is_promotable is None + else is_promotable + ) + + row = store.create_continuity_object( + capture_event_id=capture_event_id, + object_type=object_type, + status=status, + is_preserved=is_preserved, + is_searchable=resolved_is_searchable, + is_promotable=resolved_is_promotable, + title=title.strip(), + body=body, + provenance=provenance, + confidence=confidence, + ) + return _serialize_continuity_object(row) + + +def get_continuity_object_for_capture_event( + store: ContinuityStore, + *, + user_id: UUID, + capture_event_id: UUID, +) -> ContinuityObjectRecord | None: + del user_id + + row = store.get_continuity_object_by_capture_event_optional(capture_event_id) + if row is None: + return None + return _serialize_continuity_object(row) + + +def list_continuity_objects_for_capture_events( + store: ContinuityStore, + *, + user_id: UUID, + capture_event_ids: list[UUID], +) -> dict[str, ContinuityObjectRecord]: + del user_id + + rows = store.list_continuity_objects_for_capture_events(capture_event_ids) + serialized = [_serialize_continuity_object(row) for row in rows] + return {item["capture_event_id"]: item for item in serialized} diff --git a/apps/api/src/alicebot_api/continuity_open_loops.py b/apps/api/src/alicebot_api/continuity_open_loops.py new file mode 100644 index 0000000..d426653 --- /dev/null +++ b/apps/api/src/alicebot_api/continuity_open_loops.py @@ -0,0 +1,582 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime +from uuid import UUID + +from alicebot_api.continuity_objects import serialize_continuity_lifecycle_state_from_record +from alicebot_api.continuity_recall import query_continuity_recall +from alicebot_api.contracts import ( + CONTINUITY_DAILY_BRIEF_ASSEMBLY_VERSION_V0, + CONTINUITY_OPEN_LOOP_ITEM_ORDER, + CONTINUITY_OPEN_LOOP_POSTURE_ORDER, + CONTINUITY_OPEN_LOOP_POSTURES, + CONTINUITY_OPEN_LOOP_REVIEW_ACTIONS, + CONTINUITY_WEEKLY_REVIEW_ASSEMBLY_VERSION_V0, + MAX_CONTINUITY_DAILY_BRIEF_LIMIT, + MAX_CONTINUITY_OPEN_LOOP_LIMIT, + MAX_CONTINUITY_RECALL_LIMIT, + MAX_CONTINUITY_WEEKLY_REVIEW_LIMIT, + ContinuityDailyBriefRecord, + ContinuityDailyBriefRequestInput, + ContinuityDailyBriefResponse, + ContinuityOpenLoopDashboardQueryInput, + ContinuityOpenLoopDashboardRecord, + ContinuityOpenLoopDashboardResponse, + ContinuityOpenLoopPosture, + ContinuityOpenLoopReviewActionInput, + ContinuityOpenLoopReviewActionResponse, + ContinuityOpenLoopSection, + ContinuityRecallQueryInput, + ContinuityRecallResultRecord, + ContinuityResumptionEmptyState, + ContinuityResumptionSingleSection, + ContinuityReviewObjectRecord, + ContinuityWeeklyReviewRecord, + ContinuityWeeklyReviewRequestInput, + ContinuityWeeklyReviewResponse, + isoformat_or_none, +) +from alicebot_api.store import ( + ContinuityCorrectionEventRow, + ContinuityObjectRow, + ContinuityStore, + JsonObject, +) + + +class ContinuityOpenLoopValidationError(ValueError): + """Raised when an open-loop continuity request is invalid.""" + + +class ContinuityOpenLoopNotFoundError(LookupError): + """Raised when the selected continuity object is not visible in scope.""" + + +@dataclass(frozen=True, slots=True) +class _LifecycleTransition: + correction_action: str + lifecycle_outcome: str + + +_OPEN_LOOP_OBJECT_TYPES = {"WaitingFor", "Blocker", "NextAction"} +_EMPTY_MESSAGES: dict[ContinuityOpenLoopPosture, str] = { + "waiting_for": "No waiting-for items in the requested scope.", + "blocker": "No blocker items in the requested scope.", + "stale": "No stale items in the requested scope.", + "next_action": "No next-action items in the requested scope.", +} +_DAILY_WAITING_EMPTY = "No waiting-for highlights for today in the requested scope." +_DAILY_BLOCKER_EMPTY = "No blocker highlights for today in the requested scope." +_DAILY_STALE_EMPTY = "No stale items for today in the requested scope." +_DAILY_NEXT_EMPTY = "No next suggested action in the requested scope." + +_REVIEW_ACTION_TRANSITIONS: dict[str, _LifecycleTransition] = { + "done": _LifecycleTransition(correction_action="edit", lifecycle_outcome="completed"), + "deferred": _LifecycleTransition(correction_action="mark_stale", lifecycle_outcome="stale"), + "still_blocked": _LifecycleTransition(correction_action="confirm", lifecycle_outcome="active"), +} + + +def _utcnow() -> datetime: + return datetime.now(UTC) + + +def _normalize_optional_text(value: str | None) -> str | None: + if value is None: + return None + normalized = " ".join(value.split()).strip() + if not normalized: + return None + return normalized + + +def _serialize_review_object(record: ContinuityObjectRow) -> ContinuityReviewObjectRecord: + return { + "id": str(record["id"]), + "capture_event_id": str(record["capture_event_id"]), + "object_type": record["object_type"], # type: ignore[typeddict-item] + "status": record["status"], + "lifecycle": serialize_continuity_lifecycle_state_from_record(record), + "title": record["title"], + "body": record["body"], + "provenance": record["provenance"], + "confidence": float(record["confidence"]), + "last_confirmed_at": isoformat_or_none(record["last_confirmed_at"]), + "supersedes_object_id": ( + None if record["supersedes_object_id"] is None else str(record["supersedes_object_id"]) + ), + "superseded_by_object_id": ( + None if record["superseded_by_object_id"] is None else str(record["superseded_by_object_id"]) + ), + "created_at": record["created_at"].isoformat(), + "updated_at": record["updated_at"].isoformat(), + } + + +def _serialize_correction_event(record: ContinuityCorrectionEventRow): + return { + "id": str(record["id"]), + "continuity_object_id": str(record["continuity_object_id"]), + "action": record["action"], + "reason": record["reason"], + "before_snapshot": record["before_snapshot"], + "after_snapshot": record["after_snapshot"], + "payload": record["payload"], + "created_at": record["created_at"].isoformat(), + } + + +def _snapshot(record: ContinuityObjectRow) -> JsonObject: + return { + "id": str(record["id"]), + "capture_event_id": str(record["capture_event_id"]), + "object_type": record["object_type"], + "status": record["status"], + "is_preserved": record["is_preserved"], + "is_searchable": record["is_searchable"], + "is_promotable": record["is_promotable"], + "title": record["title"], + "body": record["body"], + "provenance": record["provenance"], + "confidence": float(record["confidence"]), + "last_confirmed_at": isoformat_or_none(record["last_confirmed_at"]), + "supersedes_object_id": ( + None if record["supersedes_object_id"] is None else str(record["supersedes_object_id"]) + ), + "superseded_by_object_id": ( + None if record["superseded_by_object_id"] is None else str(record["superseded_by_object_id"]) + ), + } + + +def _open_loop_posture(item: ContinuityRecallResultRecord) -> ContinuityOpenLoopPosture | None: + if item["object_type"] not in _OPEN_LOOP_OBJECT_TYPES: + return None + + if item["status"] == "stale": + return "stale" + + if item["status"] != "active": + return None + + if item["object_type"] == "WaitingFor": + return "waiting_for" + if item["object_type"] == "Blocker": + return "blocker" + if item["object_type"] == "NextAction": + return "next_action" + + return None + + +def _recency_sort_key(item: ContinuityRecallResultRecord) -> tuple[str, str]: + return (item["created_at"], item["id"]) + + +def _empty_state(*, is_empty: bool, message: str) -> ContinuityResumptionEmptyState: + return { + "is_empty": is_empty, + "message": message, + } + + +def _build_section( + *, + all_items: list[ContinuityRecallResultRecord], + limit: int, + empty_message: str, +) -> ContinuityOpenLoopSection: + items = all_items[:limit] if limit > 0 else [] + return { + "items": items, + "summary": { + "limit": limit, + "returned_count": len(items), + "total_count": len(all_items), + "order": list(CONTINUITY_OPEN_LOOP_ITEM_ORDER), + }, + "empty_state": _empty_state( + is_empty=len(items) == 0, + message=empty_message, + ), + } + + +def _validate_limit(limit: int, *, max_limit: int) -> None: + if limit < 0 or limit > max_limit: + raise ContinuityOpenLoopValidationError(f"limit must be between 0 and {max_limit}") + + +def _is_offset_aware(value: datetime) -> bool: + return value.tzinfo is not None and value.utcoffset() is not None + + +def _validate_time_window(*, since: datetime | None, until: datetime | None) -> None: + if since is None or until is None: + return + + if _is_offset_aware(since) != _is_offset_aware(until): + raise ContinuityOpenLoopValidationError( + "since and until must both include timezone offsets or both omit timezone offsets" + ) + + try: + if until < since: + raise ContinuityOpenLoopValidationError("until must be greater than or equal to since") + except TypeError as exc: + raise ContinuityOpenLoopValidationError( + "since and until must both include timezone offsets or both omit timezone offsets" + ) from exc + + +def _group_open_loops( + recall_items: list[ContinuityRecallResultRecord], +) -> dict[ContinuityOpenLoopPosture, list[ContinuityRecallResultRecord]]: + grouped: dict[ContinuityOpenLoopPosture, list[ContinuityRecallResultRecord]] = { + "waiting_for": [], + "blocker": [], + "stale": [], + "next_action": [], + } + + for item in recall_items: + posture = _open_loop_posture(item) + if posture is None: + continue + grouped[posture].append(item) + + for posture in CONTINUITY_OPEN_LOOP_POSTURES: + grouped[posture].sort(key=_recency_sort_key, reverse=True) + + return grouped + + +def _correction_recurrence_count( + store: ContinuityStore, + *, + grouped: dict[ContinuityOpenLoopPosture, list[ContinuityRecallResultRecord]], +) -> int: + object_ids: set[UUID] = set() + for posture in CONTINUITY_OPEN_LOOP_POSTURES: + for item in grouped[posture]: + try: + object_ids.add(UUID(item["id"])) + except (TypeError, ValueError): + continue + + recurrence_count = 0 + for continuity_object_id in sorted(object_ids, key=str): + correction_events = store.list_continuity_correction_events( + continuity_object_id=continuity_object_id, + limit=2, + ) + if len(correction_events) >= 2: + recurrence_count += 1 + + return recurrence_count + + +def _load_grouped_open_loop_candidates( + store: ContinuityStore, + *, + user_id: UUID, + query: str | None, + thread_id: UUID | None, + task_id: UUID | None, + project: str | None, + person: str | None, + since: datetime | None, + until: datetime | None, +) -> tuple[dict[str, str | None], dict[ContinuityOpenLoopPosture, list[ContinuityRecallResultRecord]]]: + recall_payload = query_continuity_recall( + store, + user_id=user_id, + request=ContinuityRecallQueryInput( + query=query, + thread_id=thread_id, + task_id=task_id, + project=project, + person=person, + since=since, + until=until, + limit=MAX_CONTINUITY_RECALL_LIMIT, + ), + apply_limit=False, + ) + + return recall_payload["summary"]["filters"], _group_open_loops(recall_payload["items"]) + + +def compile_continuity_open_loop_dashboard( + store: ContinuityStore, + *, + user_id: UUID, + request: ContinuityOpenLoopDashboardQueryInput, +) -> ContinuityOpenLoopDashboardResponse: + _validate_limit(request.limit, max_limit=MAX_CONTINUITY_OPEN_LOOP_LIMIT) + _validate_time_window(since=request.since, until=request.until) + + scope, grouped = _load_grouped_open_loop_candidates( + store, + user_id=user_id, + query=request.query, + thread_id=request.thread_id, + task_id=request.task_id, + project=request.project, + person=request.person, + since=request.since, + until=request.until, + ) + + dashboard: ContinuityOpenLoopDashboardRecord = { + "scope": scope, + "waiting_for": _build_section( + all_items=grouped["waiting_for"], + limit=request.limit, + empty_message=_EMPTY_MESSAGES["waiting_for"], + ), + "blocker": _build_section( + all_items=grouped["blocker"], + limit=request.limit, + empty_message=_EMPTY_MESSAGES["blocker"], + ), + "stale": _build_section( + all_items=grouped["stale"], + limit=request.limit, + empty_message=_EMPTY_MESSAGES["stale"], + ), + "next_action": _build_section( + all_items=grouped["next_action"], + limit=request.limit, + empty_message=_EMPTY_MESSAGES["next_action"], + ), + "summary": { + "limit": request.limit, + "total_count": sum(len(grouped[posture]) for posture in CONTINUITY_OPEN_LOOP_POSTURES), + "posture_order": list(CONTINUITY_OPEN_LOOP_POSTURE_ORDER), + "item_order": list(CONTINUITY_OPEN_LOOP_ITEM_ORDER), + }, + "sources": ["continuity_capture_events", "continuity_objects", "continuity_correction_events"], + } + + return {"dashboard": dashboard} + + +def compile_continuity_daily_brief( + store: ContinuityStore, + *, + user_id: UUID, + request: ContinuityDailyBriefRequestInput, +) -> ContinuityDailyBriefResponse: + _validate_limit(request.limit, max_limit=MAX_CONTINUITY_DAILY_BRIEF_LIMIT) + _validate_time_window(since=request.since, until=request.until) + + scope, grouped = _load_grouped_open_loop_candidates( + store, + user_id=user_id, + query=request.query, + thread_id=request.thread_id, + task_id=request.task_id, + project=request.project, + person=request.person, + since=request.since, + until=request.until, + ) + + next_item = grouped["next_action"][0] if request.limit > 0 and grouped["next_action"] else None + next_section: ContinuityResumptionSingleSection = { + "item": next_item, + "empty_state": _empty_state( + is_empty=next_item is None, + message=_DAILY_NEXT_EMPTY, + ), + } + + brief: ContinuityDailyBriefRecord = { + "assembly_version": CONTINUITY_DAILY_BRIEF_ASSEMBLY_VERSION_V0, + "scope": scope, + "waiting_for_highlights": _build_section( + all_items=grouped["waiting_for"], + limit=request.limit, + empty_message=_DAILY_WAITING_EMPTY, + ), + "blocker_highlights": _build_section( + all_items=grouped["blocker"], + limit=request.limit, + empty_message=_DAILY_BLOCKER_EMPTY, + ), + "stale_items": _build_section( + all_items=grouped["stale"], + limit=request.limit, + empty_message=_DAILY_STALE_EMPTY, + ), + "next_suggested_action": next_section, + "sources": ["continuity_capture_events", "continuity_objects", "continuity_correction_events"], + } + + return {"brief": brief} + + +def compile_continuity_weekly_review( + store: ContinuityStore, + *, + user_id: UUID, + request: ContinuityWeeklyReviewRequestInput, +) -> ContinuityWeeklyReviewResponse: + _validate_limit(request.limit, max_limit=MAX_CONTINUITY_WEEKLY_REVIEW_LIMIT) + _validate_time_window(since=request.since, until=request.until) + + scope, grouped = _load_grouped_open_loop_candidates( + store, + user_id=user_id, + query=request.query, + thread_id=request.thread_id, + task_id=request.task_id, + project=request.project, + person=request.person, + since=request.since, + until=request.until, + ) + correction_recurrence_count = _correction_recurrence_count( + store, + grouped=grouped, + ) + freshness_drift_count = len(grouped["stale"]) + + review: ContinuityWeeklyReviewRecord = { + "assembly_version": CONTINUITY_WEEKLY_REVIEW_ASSEMBLY_VERSION_V0, + "scope": scope, + "rollup": { + "total_count": sum(len(grouped[posture]) for posture in CONTINUITY_OPEN_LOOP_POSTURES), + "waiting_for_count": len(grouped["waiting_for"]), + "blocker_count": len(grouped["blocker"]), + "stale_count": len(grouped["stale"]), + "correction_recurrence_count": correction_recurrence_count, + "freshness_drift_count": freshness_drift_count, + "next_action_count": len(grouped["next_action"]), + "posture_order": list(CONTINUITY_OPEN_LOOP_POSTURE_ORDER), + }, + "waiting_for": _build_section( + all_items=grouped["waiting_for"], + limit=request.limit, + empty_message=_EMPTY_MESSAGES["waiting_for"], + ), + "blocker": _build_section( + all_items=grouped["blocker"], + limit=request.limit, + empty_message=_EMPTY_MESSAGES["blocker"], + ), + "stale": _build_section( + all_items=grouped["stale"], + limit=request.limit, + empty_message=_EMPTY_MESSAGES["stale"], + ), + "next_action": _build_section( + all_items=grouped["next_action"], + limit=request.limit, + empty_message=_EMPTY_MESSAGES["next_action"], + ), + "sources": ["continuity_capture_events", "continuity_objects", "continuity_correction_events"], + } + + return {"review": review} + + +def apply_continuity_open_loop_review_action( + store: ContinuityStore, + *, + user_id: UUID, + continuity_object_id: UUID, + request: ContinuityOpenLoopReviewActionInput, +) -> ContinuityOpenLoopReviewActionResponse: + del user_id + + action = request.action + if action not in CONTINUITY_OPEN_LOOP_REVIEW_ACTIONS: + allowed = ", ".join(CONTINUITY_OPEN_LOOP_REVIEW_ACTIONS) + raise ContinuityOpenLoopValidationError(f"action must be one of: {allowed}") + + note = _normalize_optional_text(request.note) + if note is not None and len(note) > 500: + raise ContinuityOpenLoopValidationError("note must be 500 characters or fewer") + + current = store.get_continuity_object_optional(continuity_object_id) + if current is None: + raise ContinuityOpenLoopNotFoundError(f"continuity object {continuity_object_id} was not found") + + if current["object_type"] not in _OPEN_LOOP_OBJECT_TYPES: + allowed_types = ", ".join(sorted(_OPEN_LOOP_OBJECT_TYPES)) + raise ContinuityOpenLoopValidationError( + f"review action requires one of object types: {allowed_types}" + ) + + if current["status"] in {"superseded", "deleted", "cancelled"}: + raise ContinuityOpenLoopValidationError( + f"review action cannot be applied when status is {current['status']}" + ) + + transition = _REVIEW_ACTION_TRANSITIONS[action] + next_last_confirmed_at = current["last_confirmed_at"] + if action == "still_blocked": + next_last_confirmed_at = _utcnow() + + before_snapshot = _snapshot(current) + after_snapshot: JsonObject = { + **before_snapshot, + "status": transition.lifecycle_outcome, + "last_confirmed_at": isoformat_or_none(next_last_confirmed_at), + } + + correction_event = store.create_continuity_correction_event( + continuity_object_id=continuity_object_id, + action=transition.correction_action, + reason=note, + before_snapshot=before_snapshot, + after_snapshot=after_snapshot, + payload={ + "review_action": action, + "note": note, + "mapped_correction_action": transition.correction_action, + "lifecycle_outcome": transition.lifecycle_outcome, + }, + ) + + updated = store.update_continuity_object_optional( + continuity_object_id=continuity_object_id, + status=transition.lifecycle_outcome, + is_preserved=current["is_preserved"], + is_searchable=current["is_searchable"], + is_promotable=current["is_promotable"], + title=current["title"], + body=current["body"], + provenance=current["provenance"], + confidence=float(current["confidence"]), + last_confirmed_at=next_last_confirmed_at, + supersedes_object_id=current["supersedes_object_id"], + superseded_by_object_id=current["superseded_by_object_id"], + ) + if updated is None: + raise ContinuityOpenLoopNotFoundError(f"continuity object {continuity_object_id} was not found") + + return { + "continuity_object": _serialize_review_object(updated), + "correction_event": _serialize_correction_event(correction_event), + "review_action": action, + "lifecycle_outcome": transition.lifecycle_outcome, + } + + +def build_default_continuity_open_loop_query() -> ContinuityOpenLoopDashboardQueryInput: + return ContinuityOpenLoopDashboardQueryInput() + + +__all__ = [ + "ContinuityOpenLoopNotFoundError", + "ContinuityOpenLoopValidationError", + "apply_continuity_open_loop_review_action", + "build_default_continuity_open_loop_query", + "compile_continuity_daily_brief", + "compile_continuity_open_loop_dashboard", + "compile_continuity_weekly_review", +] diff --git a/apps/api/src/alicebot_api/continuity_recall.py b/apps/api/src/alicebot_api/continuity_recall.py new file mode 100644 index 0000000..aa317e7 --- /dev/null +++ b/apps/api/src/alicebot_api/continuity_recall.py @@ -0,0 +1,701 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass +from datetime import datetime +from typing import cast +from uuid import UUID + +from alicebot_api.continuity_objects import ( + default_continuity_searchable, + serialize_continuity_lifecycle_state_from_record, +) +from alicebot_api.contracts import ( + CONTINUITY_RECALL_LIST_ORDER, + DEFAULT_CONTINUITY_RECALL_LIMIT, + MAX_CONTINUITY_RECALL_LIMIT, + ContinuityRecallFreshnessPosture, + ContinuityRecallOrderingMetadata, + ContinuityRecallProvenancePosture, + ContinuityRecallProvenanceReference, + ContinuityRecallQueryInput, + ContinuityRecallResponse, + ContinuityRecallResultRecord, + ContinuityRecallScopeFilters, + ContinuityRecallScopeKind, + ContinuityRecallScopeMatch, + ContinuityRecallSupersessionPosture, + MemoryConfirmationStatus, + isoformat_or_none, +) +from alicebot_api.store import ContinuityRecallCandidateRow, ContinuityStore, JsonObject + + +class ContinuityRecallValidationError(ValueError): + """Raised when a continuity recall query is invalid.""" + + +@dataclass(frozen=True, slots=True) +class RankedRecallCandidate: + row: ContinuityRecallCandidateRow + scope_matches: list[ContinuityRecallScopeMatch] + query_term_match_count: int + confirmation_status: MemoryConfirmationStatus + confirmation_rank: int + freshness_posture: ContinuityRecallFreshnessPosture + freshness_rank: int + provenance_posture: ContinuityRecallProvenancePosture + provenance_rank: int + supersession_posture: ContinuityRecallSupersessionPosture + supersession_rank: int + posture_rank: int + lifecycle_rank: int + relevance: float + + +_SCOPE_FILTER_KEYS: dict[ContinuityRecallScopeKind, set[str]] = { + "thread": {"thread_id", "thread"}, + "task": {"task_id", "task"}, + "project": {"project", "project_id", "project_name"}, + "person": {"person", "person_id", "person_name", "owner", "assignee"}, +} +_CONFIRMATION_RANK: dict[MemoryConfirmationStatus, int] = { + "confirmed": 3, + "unconfirmed": 2, + "contested": 1, +} +_FRESHNESS_RANK: dict[ContinuityRecallFreshnessPosture, int] = { + "fresh": 4, + "aging": 3, + "stale": 2, + "superseded": 1, + "unknown": 0, +} +_PROVENANCE_RANK: dict[ContinuityRecallProvenancePosture, int] = { + "strong": 3, + "partial": 2, + "weak": 1, + "missing": 0, +} +_SUPERSESSION_RANK: dict[ContinuityRecallSupersessionPosture, int] = { + "current": 3, + "historical": 2, + "superseded": 1, + "deleted": 0, +} +_POSTURE_RANK: dict[str, int] = { + "DERIVED": 2, + "TRIAGE": 1, +} +_LIFECYCLE_RANK: dict[str, int] = { + "active": 4, + "stale": 3, + "completed": 2, + "cancelled": 2, + "superseded": 1, + "deleted": 0, +} + + +def _normalize_optional_text(value: str | None) -> str | None: + if value is None: + return None + normalized = re.sub(r"\s+", " ", value).strip() + if not normalized: + return None + return normalized + + +def _normalize_uuid_string(value: UUID | str | None) -> str | None: + if value is None: + return None + return str(value).strip().lower() + + +def _collect_strings(payload: object, *, keys: set[str]) -> set[str]: + values: set[str] = set() + + def visit(value: object) -> None: + if isinstance(value, dict): + for key, child in value.items(): + normalized_key = key.strip().casefold() + if normalized_key in keys: + if isinstance(child, str): + normalized = _normalize_optional_text(child) + if normalized is not None: + values.add(normalized) + elif isinstance(child, list): + for item in child: + if isinstance(item, str): + normalized = _normalize_optional_text(item) + if normalized is not None: + values.add(normalized) + visit(child) + return + + if isinstance(value, list): + for child in value: + visit(child) + + visit(payload) + return values + + +def _collect_strings_in_order(payload: object, *, keys: set[str]) -> list[str]: + values: list[str] = [] + seen: set[str] = set() + + def add_value(value: str) -> None: + normalized = _normalize_optional_text(value) + if normalized is None: + return + if normalized in seen: + return + seen.add(normalized) + values.append(normalized) + + def visit(value: object) -> None: + if isinstance(value, dict): + for key, child in value.items(): + normalized_key = key.strip().casefold() + if normalized_key in keys: + if isinstance(child, str): + add_value(child) + elif isinstance(child, list): + for item in child: + if isinstance(item, str): + add_value(item) + visit(child) + return + + if isinstance(value, list): + for child in value: + visit(child) + + visit(payload) + return values + + +def _select_ranked_value(values: list[str], *, rank_map: dict[str, int]) -> str | None: + candidates = { + value.casefold() + for value in values + if value.casefold() in rank_map + } + if not candidates: + return None + return max( + candidates, + key=lambda candidate: (rank_map[candidate], candidate), + ) + + +def _flatten_text(payload: object) -> str: + values: list[str] = [] + + def visit(value: object) -> None: + if isinstance(value, str): + normalized = _normalize_optional_text(value) + if normalized is not None: + values.append(normalized) + return + + if isinstance(value, dict): + for child in value.values(): + visit(child) + return + + if isinstance(value, list): + for child in value: + visit(child) + + visit(payload) + return " ".join(values) + + +def _extract_confirmation_status(row: ContinuityRecallCandidateRow) -> MemoryConfirmationStatus: + for source in (row["provenance"], row["body"]): + values = _collect_strings_in_order( + source, + keys={"confirmation_status", "memory_confirmation_status"}, + ) + ranked_value = _select_ranked_value(values, rank_map=_CONFIRMATION_RANK) + if ranked_value is not None: + return cast(MemoryConfirmationStatus, ranked_value) + + if row["last_confirmed_at"] is not None: + return "confirmed" + + return "unconfirmed" + + +def _extract_freshness_posture( + row: ContinuityRecallCandidateRow, + *, + confirmation_status: MemoryConfirmationStatus, +) -> ContinuityRecallFreshnessPosture: + for source in (row["provenance"], row["body"]): + explicit_values = _collect_strings_in_order( + source, + keys={"freshness_posture", "freshness_status"}, + ) + ranked_value = _select_ranked_value(explicit_values, rank_map=_FRESHNESS_RANK) + if ranked_value is not None: + return cast(ContinuityRecallFreshnessPosture, ranked_value) + + if row["status"] == "superseded" or row["superseded_by_object_id"] is not None: + return "superseded" + if row["status"] == "stale": + return "stale" + if confirmation_status == "confirmed" or row["last_confirmed_at"] is not None: + return "fresh" + if row["status"] in {"active", "completed", "cancelled"}: + return "aging" + return "unknown" + + +def _extract_supersession_posture( + row: ContinuityRecallCandidateRow, +) -> ContinuityRecallSupersessionPosture: + if row["status"] == "deleted": + return "deleted" + if row["status"] == "superseded" or row["superseded_by_object_id"] is not None: + return "superseded" + if row["status"] == "stale": + return "historical" + return "current" + + +def _extract_provenance_posture( + row: ContinuityRecallCandidateRow, + *, + scope_matches: list[ContinuityRecallScopeMatch], +) -> ContinuityRecallProvenancePosture: + has_source_events = bool( + _collect_strings( + row["provenance"], + keys={"source_event_id", "source_event_ids"}, + ) + | _collect_strings( + row["body"], + keys={"source_event_id", "source_event_ids"}, + ) + ) + has_scope_context = len(scope_matches) > 0 or bool( + _collect_strings( + row["provenance"], + keys=( + _SCOPE_FILTER_KEYS["thread"] + | _SCOPE_FILTER_KEYS["task"] + | _SCOPE_FILTER_KEYS["project"] + | _SCOPE_FILTER_KEYS["person"] + ), + ) + ) + if has_source_events and has_scope_context: + return "strong" + if has_source_events or has_scope_context: + return "partial" + if row["provenance"]: + return "weak" + return "missing" + + +def _compute_scope_matches( + row: ContinuityRecallCandidateRow, + *, + thread_filter: str | None, + task_filter: str | None, + project_filter: str | None, + person_filter: str | None, +) -> list[ContinuityRecallScopeMatch]: + scope_matches: list[ContinuityRecallScopeMatch] = [] + + if thread_filter is not None: + candidate_values = { + value.casefold() + for value in _collect_strings( + row["provenance"], + keys=_SCOPE_FILTER_KEYS["thread"], + ) + | _collect_strings( + row["body"], + keys=_SCOPE_FILTER_KEYS["thread"], + ) + } + if thread_filter in candidate_values: + scope_matches.append({"kind": "thread", "value": thread_filter}) + + if task_filter is not None: + candidate_values = { + value.casefold() + for value in _collect_strings( + row["provenance"], + keys=_SCOPE_FILTER_KEYS["task"], + ) + | _collect_strings( + row["body"], + keys=_SCOPE_FILTER_KEYS["task"], + ) + } + if task_filter in candidate_values: + scope_matches.append({"kind": "task", "value": task_filter}) + + if project_filter is not None: + candidate_values = { + value.casefold() + for value in _collect_strings( + row["provenance"], + keys=_SCOPE_FILTER_KEYS["project"], + ) + | _collect_strings( + row["body"], + keys=_SCOPE_FILTER_KEYS["project"], + ) + } + if project_filter in candidate_values: + scope_matches.append({"kind": "project", "value": project_filter}) + + if person_filter is not None: + candidate_values = { + value.casefold() + for value in _collect_strings( + row["provenance"], + keys=_SCOPE_FILTER_KEYS["person"], + ) + | _collect_strings( + row["body"], + keys=_SCOPE_FILTER_KEYS["person"], + ) + } + if person_filter in candidate_values: + scope_matches.append({"kind": "person", "value": person_filter}) + + return scope_matches + + +def _query_terms(query: str | None) -> list[str]: + if query is None: + return [] + return [ + term + for term in re.findall(r"[a-z0-9]+", query.casefold()) + if term + ] + + +def _count_query_term_matches(row: ContinuityRecallCandidateRow, terms: list[str]) -> int: + if not terms: + return 0 + + text = " ".join( + [ + row["title"], + _flatten_text(row["body"]), + _flatten_text(row["provenance"]), + ] + ).casefold() + return sum(1 for term in terms if term in text) + + +def _matches_time_window( + row: ContinuityRecallCandidateRow, + *, + since: datetime | None, + until: datetime | None, +) -> bool: + created_at = row["object_created_at"] + if since is not None and created_at < since: + return False + if until is not None and created_at > until: + return False + return True + + +def _provenance_reference_kind(key: str) -> str: + if key.endswith("_id"): + return key[: -len("_id")] + if key.endswith("_ids"): + return key[: -len("_ids")] + return key + + +def _build_provenance_references( + capture_event_id: UUID, + provenance: JsonObject, +) -> list[ContinuityRecallProvenanceReference]: + references: set[tuple[str, str]] = { + ("continuity_capture_event", str(capture_event_id)), + } + + def visit(value: object) -> None: + if isinstance(value, dict): + for key, child in value.items(): + normalized_key = key.strip().casefold() + if normalized_key.endswith("_id"): + if isinstance(child, (str, UUID)): + references.add((_provenance_reference_kind(normalized_key), str(child))) + elif normalized_key.endswith("_ids") and isinstance(child, list): + for item in child: + if isinstance(item, (str, UUID)): + references.add((_provenance_reference_kind(normalized_key), str(item))) + visit(child) + return + + if isinstance(value, list): + for child in value: + visit(child) + + visit(provenance) + + return [ + {"source_kind": source_kind, "source_id": source_id} + for source_kind, source_id in sorted(references) + ] + + +def _serialize_recall_result(item: RankedRecallCandidate) -> ContinuityRecallResultRecord: + row = item.row + + ordering: ContinuityRecallOrderingMetadata = { + "scope_match_count": len(item.scope_matches), + "query_term_match_count": item.query_term_match_count, + "confirmation_rank": item.confirmation_rank, + "freshness_posture": item.freshness_posture, + "freshness_rank": item.freshness_rank, + "provenance_posture": item.provenance_posture, + "provenance_rank": item.provenance_rank, + "supersession_posture": item.supersession_posture, + "supersession_rank": item.supersession_rank, + "posture_rank": item.posture_rank, + "lifecycle_rank": item.lifecycle_rank, + "confidence": float(row["confidence"]), + } + + return { + "id": str(row["id"]), + "capture_event_id": str(row["capture_event_id"]), + "object_type": row["object_type"], # type: ignore[typeddict-item] + "status": row["status"], + "lifecycle": serialize_continuity_lifecycle_state_from_record(row), + "title": row["title"], + "body": row["body"], + "provenance": row["provenance"], + "confirmation_status": item.confirmation_status, + "admission_posture": row["admission_posture"], # type: ignore[typeddict-item] + "confidence": float(row["confidence"]), + "relevance": item.relevance, + "last_confirmed_at": isoformat_or_none(row["last_confirmed_at"]), + "supersedes_object_id": ( + None if row["supersedes_object_id"] is None else str(row["supersedes_object_id"]) + ), + "superseded_by_object_id": ( + None if row["superseded_by_object_id"] is None else str(row["superseded_by_object_id"]) + ), + "scope_matches": item.scope_matches, + "provenance_references": _build_provenance_references( + row["capture_event_id"], + row["provenance"], + ), + "ordering": ordering, + "created_at": row["object_created_at"].isoformat(), + "updated_at": row["object_updated_at"].isoformat(), + } + + +def _scope_filters_payload(request: ContinuityRecallQueryInput) -> ContinuityRecallScopeFilters: + payload: ContinuityRecallScopeFilters = { + "since": isoformat_or_none(request.since), + "until": isoformat_or_none(request.until), + } + if request.thread_id is not None: + payload["thread_id"] = str(request.thread_id) + if request.task_id is not None: + payload["task_id"] = str(request.task_id) + if request.project is not None: + payload["project"] = request.project + if request.person is not None: + payload["person"] = request.person + return payload + + +def _validate_request(request: ContinuityRecallQueryInput) -> None: + if request.limit < 1 or request.limit > MAX_CONTINUITY_RECALL_LIMIT: + raise ContinuityRecallValidationError( + f"limit must be between 1 and {MAX_CONTINUITY_RECALL_LIMIT}" + ) + + if request.since is not None and request.until is not None and request.until < request.since: + raise ContinuityRecallValidationError("until must be greater than or equal to since") + + +def _ordered_recall_candidates( + store: ContinuityStore, + *, + request: ContinuityRecallQueryInput, +) -> list[RankedRecallCandidate]: + thread_filter = _normalize_uuid_string(request.thread_id) + task_filter = _normalize_uuid_string(request.task_id) + project_filter = ( + request.project.casefold() + if request.project is not None + else None + ) + person_filter = ( + request.person.casefold() + if request.person is not None + else None + ) + query_terms = _query_terms(request.query) + + ranked_candidates: list[RankedRecallCandidate] = [] + + for row in store.list_continuity_recall_candidates(): + if row["status"] == "deleted": + continue + if not bool(row.get("is_searchable", default_continuity_searchable(row["object_type"]))): + continue + + if not _matches_time_window( + row, + since=request.since, + until=request.until, + ): + continue + + scope_matches = _compute_scope_matches( + row, + thread_filter=thread_filter, + task_filter=task_filter, + project_filter=project_filter, + person_filter=person_filter, + ) + + required_scope_count = sum( + value is not None + for value in (thread_filter, task_filter, project_filter, person_filter) + ) + if len(scope_matches) != required_scope_count: + continue + + query_term_match_count = _count_query_term_matches(row, query_terms) + if query_terms and query_term_match_count == 0: + continue + + confirmation_status = _extract_confirmation_status(row) + confirmation_rank = _CONFIRMATION_RANK[confirmation_status] + freshness_posture = _extract_freshness_posture( + row, + confirmation_status=confirmation_status, + ) + freshness_rank = _FRESHNESS_RANK[freshness_posture] + provenance_posture = _extract_provenance_posture( + row, + scope_matches=scope_matches, + ) + provenance_rank = _PROVENANCE_RANK[provenance_posture] + supersession_posture = _extract_supersession_posture(row) + supersession_rank = _SUPERSESSION_RANK[supersession_posture] + posture_rank = _POSTURE_RANK.get(row["admission_posture"], 0) + lifecycle_rank = _LIFECYCLE_RANK.get(row["status"], 0) + relevance = ( + float(len(scope_matches)) * 100.0 + + float(query_term_match_count) * 20.0 + + float(confirmation_rank) * 14.0 + + float(freshness_rank) * 12.0 + + float(provenance_rank) * 8.0 + + float(supersession_rank) * 10.0 + + float(posture_rank) * 4.0 + + float(lifecycle_rank) * 2.0 + + float(row["confidence"]) + ) + + ranked_candidates.append( + RankedRecallCandidate( + row=row, + scope_matches=scope_matches, + query_term_match_count=query_term_match_count, + confirmation_status=confirmation_status, + confirmation_rank=confirmation_rank, + freshness_posture=freshness_posture, + freshness_rank=freshness_rank, + provenance_posture=provenance_posture, + provenance_rank=provenance_rank, + supersession_posture=supersession_posture, + supersession_rank=supersession_rank, + posture_rank=posture_rank, + lifecycle_rank=lifecycle_rank, + relevance=relevance, + ) + ) + + return sorted( + ranked_candidates, + key=lambda candidate: ( + len(candidate.scope_matches), + candidate.query_term_match_count, + candidate.confirmation_rank, + candidate.freshness_rank, + candidate.provenance_rank, + candidate.supersession_rank, + candidate.posture_rank, + candidate.lifecycle_rank, + float(candidate.row["confidence"]), + candidate.row["object_created_at"], + str(candidate.row["id"]), + ), + reverse=True, + ) + + +def query_continuity_recall( + store: ContinuityStore, + *, + user_id: UUID, + request: ContinuityRecallQueryInput, + apply_limit: bool = True, +) -> ContinuityRecallResponse: + del user_id + + normalized_query = _normalize_optional_text(request.query) + normalized_project = _normalize_optional_text(request.project) + normalized_person = _normalize_optional_text(request.person) + normalized_request = ContinuityRecallQueryInput( + query=normalized_query, + thread_id=request.thread_id, + task_id=request.task_id, + project=normalized_project, + person=normalized_person, + since=request.since, + until=request.until, + limit=request.limit, + ) + + _validate_request(normalized_request) + + ordered_candidates = _ordered_recall_candidates( + store, + request=normalized_request, + ) + + if apply_limit: + returned_candidates = ordered_candidates[: normalized_request.limit] + else: + returned_candidates = ordered_candidates + items = [_serialize_recall_result(item) for item in returned_candidates] + + return { + "items": items, + "summary": { + "query": normalized_request.query, + "filters": _scope_filters_payload(normalized_request), + "limit": normalized_request.limit, + "returned_count": len(items), + "total_count": len(ordered_candidates), + "order": list(CONTINUITY_RECALL_LIST_ORDER), + }, + } + + +def build_default_recall_query_input() -> ContinuityRecallQueryInput: + return ContinuityRecallQueryInput(limit=DEFAULT_CONTINUITY_RECALL_LIMIT) diff --git a/apps/api/src/alicebot_api/continuity_resumption.py b/apps/api/src/alicebot_api/continuity_resumption.py new file mode 100644 index 0000000..4bc8072 --- /dev/null +++ b/apps/api/src/alicebot_api/continuity_resumption.py @@ -0,0 +1,226 @@ +from __future__ import annotations + +from uuid import UUID + +from alicebot_api.continuity_recall import query_continuity_recall +from alicebot_api.contracts import ( + CONTINUITY_RESUMPTION_BRIEF_ASSEMBLY_VERSION_V0, + CONTINUITY_RESUMPTION_OPEN_LOOP_ORDER, + CONTINUITY_RESUMPTION_RECENT_CHANGE_ORDER, + DEFAULT_CONTINUITY_RESUMPTION_OPEN_LOOP_LIMIT, + DEFAULT_CONTINUITY_RESUMPTION_RECENT_CHANGES_LIMIT, + MAX_CONTINUITY_RECALL_LIMIT, + MAX_CONTINUITY_RESUMPTION_OPEN_LOOP_LIMIT, + MAX_CONTINUITY_RESUMPTION_RECENT_CHANGES_LIMIT, + ContinuityRecallQueryInput, + ContinuityRecallResultRecord, + ContinuityResumptionBriefRecord, + ContinuityResumptionBriefRequestInput, + ContinuityResumptionBriefResponse, + ContinuityResumptionEmptyState, + ContinuityResumptionListSection, + ContinuityResumptionSingleSection, + ResumptionBriefSectionSummary, +) +from alicebot_api.store import ContinuityStore + + +class ContinuityResumptionValidationError(ValueError): + """Raised when a continuity resumption request is invalid.""" + + +def _is_active_truth(item: ContinuityRecallResultRecord) -> bool: + return item["status"] == "active" + + +def _is_recent_change_candidate(item: ContinuityRecallResultRecord) -> bool: + return item["status"] in {"active", "stale", "superseded", "completed", "cancelled"} + + +def _is_promotable_fact( + item: ContinuityRecallResultRecord, + *, + include_non_promotable_facts: bool, +) -> bool: + if item["object_type"] != "MemoryFact": + return True + if include_non_promotable_facts: + return True + return item["lifecycle"]["is_promotable"] + + +def _build_empty_state(*, is_empty: bool, message: str) -> ContinuityResumptionEmptyState: + return { + "is_empty": is_empty, + "message": message, + } + + +def _build_single_section( + item: ContinuityRecallResultRecord | None, + *, + empty_message: str, +) -> ContinuityResumptionSingleSection: + return { + "item": item, + "empty_state": _build_empty_state( + is_empty=item is None, + message=empty_message, + ), + } + + +def _build_list_section( + *, + items: list[ContinuityRecallResultRecord], + limit: int, + total_count: int, + order: list[str], + empty_message: str, +) -> ContinuityResumptionListSection: + summary: ResumptionBriefSectionSummary = { + "limit": limit, + "returned_count": len(items), + "total_count": total_count, + "order": list(order), + } + return { + "items": items, + "summary": summary, + "empty_state": _build_empty_state( + is_empty=len(items) == 0, + message=empty_message, + ), + } + + +def _validate_request(request: ContinuityResumptionBriefRequestInput) -> None: + if request.max_recent_changes < 0 or request.max_recent_changes > MAX_CONTINUITY_RESUMPTION_RECENT_CHANGES_LIMIT: + raise ContinuityResumptionValidationError( + "max_recent_changes must be between " + f"0 and {MAX_CONTINUITY_RESUMPTION_RECENT_CHANGES_LIMIT}" + ) + + if request.max_open_loops < 0 or request.max_open_loops > MAX_CONTINUITY_RESUMPTION_OPEN_LOOP_LIMIT: + raise ContinuityResumptionValidationError( + f"max_open_loops must be between 0 and {MAX_CONTINUITY_RESUMPTION_OPEN_LOOP_LIMIT}" + ) + + if request.since is not None and request.until is not None and request.until < request.since: + raise ContinuityResumptionValidationError("until must be greater than or equal to since") + + +def _recency_sort_key(item: ContinuityRecallResultRecord) -> tuple[str, str]: + return (item["created_at"], item["id"]) + + +def compile_continuity_resumption_brief( + store: ContinuityStore, + *, + user_id: UUID, + request: ContinuityResumptionBriefRequestInput, +) -> ContinuityResumptionBriefResponse: + _validate_request(request) + + recall_payload = query_continuity_recall( + store, + user_id=user_id, + request=ContinuityRecallQueryInput( + query=request.query, + thread_id=request.thread_id, + task_id=request.task_id, + project=request.project, + person=request.person, + since=request.since, + until=request.until, + limit=MAX_CONTINUITY_RECALL_LIMIT, + ), + apply_limit=False, + ) + + recent_ordered_items = sorted( + recall_payload["items"], + key=_recency_sort_key, + reverse=True, + ) + + latest_decision = next( + ( + item + for item in recent_ordered_items + if item["object_type"] == "Decision" and _is_active_truth(item) + ), + None, + ) + latest_next_action = next( + ( + item + for item in recent_ordered_items + if item["object_type"] == "NextAction" and _is_active_truth(item) + ), + None, + ) + + open_loop_candidates = [ + item + for item in recent_ordered_items + if _is_active_truth(item) + and item["object_type"] in {"Commitment", "WaitingFor", "Blocker"} + ] + open_loop_items = ( + open_loop_candidates[: request.max_open_loops] + if request.max_open_loops > 0 + else [] + ) + + recent_change_candidates = [ + item + for item in recent_ordered_items + if _is_recent_change_candidate(item) + and _is_promotable_fact( + item, + include_non_promotable_facts=request.include_non_promotable_facts, + ) + ] + recent_change_items = ( + recent_change_candidates[: request.max_recent_changes] + if request.max_recent_changes > 0 + else [] + ) + + brief: ContinuityResumptionBriefRecord = { + "assembly_version": CONTINUITY_RESUMPTION_BRIEF_ASSEMBLY_VERSION_V0, + "scope": recall_payload["summary"]["filters"], + "last_decision": _build_single_section( + latest_decision, + empty_message="No decision found in the requested scope.", + ), + "open_loops": _build_list_section( + items=open_loop_items, + limit=request.max_open_loops, + total_count=len(open_loop_candidates), + order=list(CONTINUITY_RESUMPTION_OPEN_LOOP_ORDER), + empty_message="No open loops found in the requested scope.", + ), + "recent_changes": _build_list_section( + items=recent_change_items, + limit=request.max_recent_changes, + total_count=len(recent_change_candidates), + order=list(CONTINUITY_RESUMPTION_RECENT_CHANGE_ORDER), + empty_message="No recent changes found in the requested scope.", + ), + "next_action": _build_single_section( + latest_next_action, + empty_message="No next action found in the requested scope.", + ), + "sources": ["continuity_capture_events", "continuity_objects"], + } + + return {"brief": brief} + + +def build_default_resumption_request() -> ContinuityResumptionBriefRequestInput: + return ContinuityResumptionBriefRequestInput( + max_recent_changes=DEFAULT_CONTINUITY_RESUMPTION_RECENT_CHANGES_LIMIT, + max_open_loops=DEFAULT_CONTINUITY_RESUMPTION_OPEN_LOOP_LIMIT, + ) diff --git a/apps/api/src/alicebot_api/continuity_review.py b/apps/api/src/alicebot_api/continuity_review.py new file mode 100644 index 0000000..685b663 --- /dev/null +++ b/apps/api/src/alicebot_api/continuity_review.py @@ -0,0 +1,478 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from uuid import UUID + +from alicebot_api.continuity_objects import serialize_continuity_lifecycle_state_from_record +from alicebot_api.contracts import ( + CONTINUITY_CORRECTION_ACTIONS, + CONTINUITY_REVIEW_QUEUE_ORDER, + DEFAULT_CONTINUITY_REVIEW_LIMIT, + MAX_CONTINUITY_REVIEW_LIMIT, + ContinuityCorrectionApplyResponse, + ContinuityCorrectionEventRecord, + ContinuityCorrectionInput, + ContinuityReviewDetail, + ContinuityReviewDetailResponse, + ContinuityReviewObjectRecord, + ContinuityReviewQueueQueryInput, + ContinuityReviewQueueResponse, + ContinuityReviewStatusFilter, + ContinuitySupersessionChain, + isoformat_or_none, +) +from alicebot_api.store import ( + ContinuityCorrectionEventRow, + ContinuityObjectRow, + ContinuityStore, + JsonObject, +) + + +class ContinuityReviewValidationError(ValueError): + """Raised when a continuity review request is invalid.""" + + +class ContinuityReviewNotFoundError(LookupError): + """Raised when a continuity object is not visible in scope.""" + + +_STATUS_FILTERS: dict[ContinuityReviewStatusFilter, list[str]] = { + "correction_ready": ["active", "stale"], + "active": ["active"], + "stale": ["stale"], + "superseded": ["superseded"], + "deleted": ["deleted"], + "all": ["active", "stale", "superseded", "deleted"], +} + + +def _utcnow() -> datetime: + return datetime.now(UTC) + + +def _normalize_optional_text(value: str | None) -> str | None: + if value is None: + return None + normalized = " ".join(value.split()).strip() + if not normalized: + return None + return normalized + + +def _validate_title(title: str) -> str: + normalized = " ".join(title.split()).strip() + if not normalized: + raise ContinuityReviewValidationError("title must not be empty") + if len(normalized) > 280: + raise ContinuityReviewValidationError("title must be 280 characters or fewer") + return normalized + + +def _validate_confidence(confidence: float) -> float: + if confidence < 0.0 or confidence > 1.0: + raise ContinuityReviewValidationError("confidence must be between 0.0 and 1.0") + return confidence + + +def _serialize_review_object(record: ContinuityObjectRow) -> ContinuityReviewObjectRecord: + return { + "id": str(record["id"]), + "capture_event_id": str(record["capture_event_id"]), + "object_type": record["object_type"], # type: ignore[typeddict-item] + "status": record["status"], + "lifecycle": serialize_continuity_lifecycle_state_from_record(record), + "title": record["title"], + "body": record["body"], + "provenance": record["provenance"], + "confidence": float(record["confidence"]), + "last_confirmed_at": isoformat_or_none(record["last_confirmed_at"]), + "supersedes_object_id": ( + None if record["supersedes_object_id"] is None else str(record["supersedes_object_id"]) + ), + "superseded_by_object_id": ( + None if record["superseded_by_object_id"] is None else str(record["superseded_by_object_id"]) + ), + "created_at": record["created_at"].isoformat(), + "updated_at": record["updated_at"].isoformat(), + } + + +def _serialize_correction_event(record: ContinuityCorrectionEventRow) -> ContinuityCorrectionEventRecord: + return { + "id": str(record["id"]), + "continuity_object_id": str(record["continuity_object_id"]), + "action": record["action"], # type: ignore[typeddict-item] + "reason": record["reason"], + "before_snapshot": record["before_snapshot"], + "after_snapshot": record["after_snapshot"], + "payload": record["payload"], + "created_at": record["created_at"].isoformat(), + } + + +def _snapshot(record: ContinuityObjectRow) -> JsonObject: + return { + "id": str(record["id"]), + "capture_event_id": str(record["capture_event_id"]), + "object_type": record["object_type"], + "status": record["status"], + "is_preserved": record["is_preserved"], + "is_searchable": record["is_searchable"], + "is_promotable": record["is_promotable"], + "title": record["title"], + "body": record["body"], + "provenance": record["provenance"], + "confidence": float(record["confidence"]), + "last_confirmed_at": isoformat_or_none(record["last_confirmed_at"]), + "supersedes_object_id": ( + None if record["supersedes_object_id"] is None else str(record["supersedes_object_id"]) + ), + "superseded_by_object_id": ( + None if record["superseded_by_object_id"] is None else str(record["superseded_by_object_id"]) + ), + } + + +def _lookup_object_or_raise( + store: ContinuityStore, + *, + continuity_object_id: UUID, +) -> ContinuityObjectRow: + record = store.get_continuity_object_optional(continuity_object_id) + if record is None: + raise ContinuityReviewNotFoundError(f"continuity object {continuity_object_id} was not found") + return record + + +def _validate_queue_query(request: ContinuityReviewQueueQueryInput) -> list[str]: + if request.limit < 1 or request.limit > MAX_CONTINUITY_REVIEW_LIMIT: + raise ContinuityReviewValidationError( + f"limit must be between 1 and {MAX_CONTINUITY_REVIEW_LIMIT}" + ) + + if request.status not in _STATUS_FILTERS: + allowed = ", ".join(_STATUS_FILTERS) + raise ContinuityReviewValidationError(f"status must be one of: {allowed}") + + return _STATUS_FILTERS[request.status] + + +def list_continuity_review_queue( + store: ContinuityStore, + *, + user_id: UUID, + request: ContinuityReviewQueueQueryInput, +) -> ContinuityReviewQueueResponse: + del user_id + + statuses = _validate_queue_query(request) + rows = store.list_continuity_review_queue(statuses=statuses, limit=request.limit) + total_count = store.count_continuity_review_queue(statuses=statuses) + + return { + "items": [_serialize_review_object(row) for row in rows], + "summary": { + "status": request.status, + "limit": request.limit, + "returned_count": len(rows), + "total_count": total_count, + "order": list(CONTINUITY_REVIEW_QUEUE_ORDER), + }, + } + + +def get_continuity_review_detail( + store: ContinuityStore, + *, + user_id: UUID, + continuity_object_id: UUID, +) -> ContinuityReviewDetailResponse: + del user_id + + continuity_object = _lookup_object_or_raise( + store, + continuity_object_id=continuity_object_id, + ) + + supersedes_object = None + if continuity_object["supersedes_object_id"] is not None: + supersedes_object = store.get_continuity_object_optional(continuity_object["supersedes_object_id"]) + + superseded_by_object = None + if continuity_object["superseded_by_object_id"] is not None: + superseded_by_object = store.get_continuity_object_optional(continuity_object["superseded_by_object_id"]) + + correction_events = store.list_continuity_correction_events( + continuity_object_id=continuity_object_id, + limit=100, + ) + + supersession_chain: ContinuitySupersessionChain = { + "supersedes": None if supersedes_object is None else _serialize_review_object(supersedes_object), + "superseded_by": ( + None if superseded_by_object is None else _serialize_review_object(superseded_by_object) + ), + } + + detail: ContinuityReviewDetail = { + "continuity_object": _serialize_review_object(continuity_object), + "correction_events": [_serialize_correction_event(event) for event in correction_events], + "supersession_chain": supersession_chain, + } + + return { + "review": detail, + } + + +def _create_correction_event( + store: ContinuityStore, + *, + continuity_object_id: UUID, + action: str, + reason: str | None, + before_snapshot: JsonObject, + after_snapshot: JsonObject, + payload: JsonObject, +) -> ContinuityCorrectionEventRow: + return store.create_continuity_correction_event( + continuity_object_id=continuity_object_id, + action=action, + reason=reason, + before_snapshot=before_snapshot, + after_snapshot=after_snapshot, + payload=payload, + ) + + +def apply_continuity_correction( + store: ContinuityStore, + *, + user_id: UUID, + continuity_object_id: UUID, + request: ContinuityCorrectionInput, +) -> ContinuityCorrectionApplyResponse: + del user_id + + action = request.action + if action not in CONTINUITY_CORRECTION_ACTIONS: + allowed = ", ".join(CONTINUITY_CORRECTION_ACTIONS) + raise ContinuityReviewValidationError(f"action must be one of: {allowed}") + + reason = _normalize_optional_text(request.reason) + if reason is not None and len(reason) > 500: + raise ContinuityReviewValidationError("reason must be 500 characters or fewer") + + current = _lookup_object_or_raise(store, continuity_object_id=continuity_object_id) + before_snapshot = _snapshot(current) + + next_title = current["title"] + next_body = current["body"] + next_provenance = current["provenance"] + next_confidence = float(current["confidence"]) + next_status = current["status"] + next_last_confirmed_at = current["last_confirmed_at"] + next_supersedes_object_id = current["supersedes_object_id"] + next_superseded_by_object_id = current["superseded_by_object_id"] + + if action == "confirm": + if current["status"] not in {"active", "stale"}: + raise ContinuityReviewValidationError("confirm requires an active or stale continuity object") + next_status = "active" + next_last_confirmed_at = _utcnow() + + elif action == "edit": + if current["status"] not in {"active", "stale"}: + raise ContinuityReviewValidationError("edit requires an active or stale continuity object") + + if ( + request.title is None + and request.body is None + and request.provenance is None + and request.confidence is None + ): + raise ContinuityReviewValidationError( + "edit requires at least one of title, body, provenance, or confidence" + ) + + if request.title is not None: + next_title = _validate_title(request.title) + if request.body is not None: + next_body = request.body + if request.provenance is not None: + next_provenance = request.provenance + if request.confidence is not None: + next_confidence = _validate_confidence(float(request.confidence)) + + next_status = "active" + next_last_confirmed_at = _utcnow() + + elif action == "delete": + if current["status"] not in {"active", "stale"}: + raise ContinuityReviewValidationError("delete requires an active or stale continuity object") + next_status = "deleted" + + elif action == "mark_stale": + if current["status"] != "active": + raise ContinuityReviewValidationError("mark_stale requires an active continuity object") + next_status = "stale" + + elif action == "supersede": + if current["status"] not in {"active", "stale"}: + raise ContinuityReviewValidationError("supersede requires an active or stale continuity object") + + replacement_title = _validate_title(request.replacement_title or current["title"]) + replacement_body = request.replacement_body if request.replacement_body is not None else current["body"] + replacement_provenance = ( + request.replacement_provenance + if request.replacement_provenance is not None + else current["provenance"] + ) + replacement_confidence = _validate_confidence( + float(request.replacement_confidence) + if request.replacement_confidence is not None + else float(current["confidence"]) + ) + + supersede_payload: JsonObject = { + "replacement_title": replacement_title, + "replacement_body": replacement_body, + "replacement_provenance": replacement_provenance, + "replacement_confidence": replacement_confidence, + } + supersede_after_snapshot: JsonObject = { + **before_snapshot, + "status": "superseded", + "superseded_by_object_id": None, + } + + correction_event = _create_correction_event( + store, + continuity_object_id=continuity_object_id, + action=action, + reason=reason, + before_snapshot=before_snapshot, + after_snapshot=supersede_after_snapshot, + payload=supersede_payload, + ) + + capture_event = store.create_continuity_capture_event( + raw_content=replacement_title, + explicit_signal=None, + admission_posture="DERIVED", + admission_reason="correction_supersede", + ) + + replacement_provenance_payload = { + **replacement_provenance, + "supersedes_object_id": str(current["id"]), + "correction_action": "supersede", + "capture_event_id": str(capture_event["id"]), + "source_kind": "continuity_correction_event", + } + + replacement_object = store.create_continuity_object( + capture_event_id=capture_event["id"], + object_type=current["object_type"], + status="active", + is_preserved=current["is_preserved"], + is_searchable=current["is_searchable"], + is_promotable=current["is_promotable"], + title=replacement_title, + body=replacement_body, + provenance=replacement_provenance_payload, + confidence=replacement_confidence, + last_confirmed_at=_utcnow(), + supersedes_object_id=current["id"], + superseded_by_object_id=None, + ) + + updated = store.update_continuity_object_optional( + continuity_object_id=continuity_object_id, + status="superseded", + is_preserved=current["is_preserved"], + is_searchable=current["is_searchable"], + is_promotable=current["is_promotable"], + title=current["title"], + body=current["body"], + provenance=current["provenance"], + confidence=float(current["confidence"]), + last_confirmed_at=current["last_confirmed_at"], + supersedes_object_id=current["supersedes_object_id"], + superseded_by_object_id=replacement_object["id"], + ) + if updated is None: + raise ContinuityReviewNotFoundError(f"continuity object {continuity_object_id} was not found") + + return { + "continuity_object": _serialize_review_object(updated), + "correction_event": _serialize_correction_event(correction_event), + "replacement_object": _serialize_review_object(replacement_object), + } + + else: + raise ContinuityReviewValidationError(f"unsupported continuity correction action: {action}") + + after_snapshot: JsonObject = { + **before_snapshot, + "status": next_status, + "title": next_title, + "body": next_body, + "provenance": next_provenance, + "confidence": next_confidence, + "last_confirmed_at": isoformat_or_none(next_last_confirmed_at), + "supersedes_object_id": ( + None if next_supersedes_object_id is None else str(next_supersedes_object_id) + ), + "superseded_by_object_id": ( + None if next_superseded_by_object_id is None else str(next_superseded_by_object_id) + ), + } + + correction_event = _create_correction_event( + store, + continuity_object_id=continuity_object_id, + action=action, + reason=reason, + before_snapshot=before_snapshot, + after_snapshot=after_snapshot, + payload=request.as_payload(), + ) + + updated = store.update_continuity_object_optional( + continuity_object_id=continuity_object_id, + status=next_status, + is_preserved=current["is_preserved"], + is_searchable=current["is_searchable"], + is_promotable=current["is_promotable"], + title=next_title, + body=next_body, + provenance=next_provenance, + confidence=next_confidence, + last_confirmed_at=next_last_confirmed_at, + supersedes_object_id=next_supersedes_object_id, + superseded_by_object_id=next_superseded_by_object_id, + ) + if updated is None: + raise ContinuityReviewNotFoundError(f"continuity object {continuity_object_id} was not found") + + return { + "continuity_object": _serialize_review_object(updated), + "correction_event": _serialize_correction_event(correction_event), + "replacement_object": None, + } + + +def build_default_continuity_review_query() -> ContinuityReviewQueueQueryInput: + return ContinuityReviewQueueQueryInput(limit=DEFAULT_CONTINUITY_REVIEW_LIMIT) + + +__all__ = [ + "ContinuityReviewNotFoundError", + "ContinuityReviewValidationError", + "apply_continuity_correction", + "build_default_continuity_review_query", + "get_continuity_review_detail", + "list_continuity_review_queue", +] diff --git a/apps/api/src/alicebot_api/contracts.py b/apps/api/src/alicebot_api/contracts.py new file mode 100644 index 0000000..f72dca7 --- /dev/null +++ b/apps/api/src/alicebot_api/contracts.py @@ -0,0 +1,5554 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Literal, NotRequired, TypeAlias, TypedDict +from uuid import UUID + +from alicebot_api.store import JsonObject, JsonValue + +DecisionKind = Literal["included", "excluded"] +AdmissionAction = Literal["NOOP", "ADD", "UPDATE", "DELETE"] +MemoryStatus = Literal["active", "deleted"] +OpenLoopStatus = Literal["open", "resolved", "dismissed"] +OpenLoopStatusFilter = Literal["open", "resolved", "dismissed", "all"] +MemoryType = Literal[ + "preference", + "identity_fact", + "relationship_fact", + "project_fact", + "decision", + "commitment", + "routine", + "constraint", + "working_style", +] +MemoryConfirmationStatus = Literal["unconfirmed", "confirmed", "contested"] +MemoryTrustClass = Literal[ + "deterministic", + "llm_single_source", + "llm_corroborated", + "human_curated", +] +MemoryPromotionEligibility = Literal["promotable", "not_promotable"] +ContinuityPreservationStatus = Literal["preserved", "not_preserved"] +ContinuitySearchabilityStatus = Literal["searchable", "not_searchable"] +ContinuityPromotionStatus = Literal["promotable", "not_promotable"] +ContinuityRecallFreshnessPosture = Literal["fresh", "aging", "stale", "superseded", "unknown"] +ContinuityRecallProvenancePosture = Literal["strong", "partial", "weak", "missing"] +ContinuityRecallSupersessionPosture = Literal["current", "historical", "superseded", "deleted"] +RetrievalEvaluationStatus = Literal["pass", "fail"] +MemoryReviewStatusFilter = Literal["active", "deleted", "all"] +MemoryReviewLabelValue = Literal["correct", "incorrect", "outdated", "insufficient_evidence"] +MemoryQualityGateStatus = Literal["healthy", "needs_review", "insufficient_sample", "degraded"] +MemoryQualityReviewAction = Literal[ + "adjudicate_minimum_sample", + "review_high_risk_queue", + "review_stale_truth_queue", + "drain_unlabeled_queue", + "investigate_correction_recurrence", + "remediate_freshness_drift", + "monitor_quality_posture", +] +MemoryReviewQueuePriorityMode = Literal[ + "oldest_first", + "recent_first", + "high_risk_first", + "stale_truth_first", +] +EntityType = Literal["person", "merchant", "product", "project", "routine"] +EmbeddingConfigStatus = Literal["active", "deprecated", "disabled"] +ConsentStatus = Literal["granted", "revoked"] +ApprovalStatus = Literal["pending", "approved", "rejected"] +ApprovalResolutionAction = Literal["approve", "reject"] +ApprovalResolutionOutcome = Literal["resolved", "duplicate_rejected", "conflict_rejected"] +TaskStatus = Literal["pending_approval", "approved", "executed", "denied", "blocked"] +TaskRunStatus = Literal[ + "queued", + "running", + "waiting_approval", + "waiting_user", + "paused", + "failed", + "done", + "cancelled", +] +TaskRunStopReason = Literal[ + "waiting_approval", + "waiting_user", + "paused", + "budget_exhausted", + "approval_rejected", + "policy_blocked", + "retry_exhausted", + "fatal_error", + "done", + "cancelled", +] +TaskRunFailureClass = Literal["transient", "policy", "approval", "budget", "fatal"] +TaskRunRetryPosture = Literal[ + "none", + "retryable", + "exhausted", + "terminal", + "paused", + "awaiting_approval", + "awaiting_user", +] +TaskWorkspaceStatus = Literal["active"] +HostedAuthSessionStatus = Literal["active", "revoked", "expired"] +HostedMagicLinkChallengeStatus = Literal["pending", "consumed", "expired"] +HostedDeviceLinkChallengeStatus = Literal["pending", "confirmed", "expired"] +HostedDeviceStatus = Literal["active", "revoked"] +HostedWorkspaceBootstrapStatus = Literal["pending", "ready"] +HostedWorkspaceMemberRole = Literal["owner", "member"] +ChannelTransportType = Literal["telegram"] +ChannelIdentityStatus = Literal["linked", "unlinked"] +ChannelLinkChallengeStatus = Literal["pending", "confirmed", "expired", "cancelled"] +ChannelMessageDirection = Literal["inbound", "outbound"] +ChannelMessageRouteStatus = Literal["resolved", "unresolved"] +ChatIntentKind = Literal[ + "inbound_message", + "capture", + "recall", + "resume", + "correction", + "open_loops", + "open_loop_review", + "approvals", + "approval_approve", + "approval_reject", + "unknown", +] +ChatIntentStatus = Literal["pending", "recorded", "handled", "failed"] +ChannelDeliveryReceiptStatus = Literal["delivered", "failed", "simulated", "suppressed"] +TelegramSchedulerJobKind = Literal["daily_brief", "open_loop_prompt"] +TelegramSchedulerPromptKind = Literal["waiting_for", "stale"] +TelegramSchedulerJobStatus = Literal[ + "scheduled", + "delivered", + "simulated", + "suppressed_quiet_hours", + "suppressed_disabled", + "suppressed_outside_window", + "failed", +] +TaskArtifactStatus = Literal["registered"] +TaskArtifactIngestionStatus = Literal["pending", "ingested"] +TaskArtifactChunkRetrievalScopeKind = Literal["task", "artifact"] +TaskArtifactChunkEmbeddingListScopeKind = Literal["artifact", "chunk"] +TaskLifecycleSource = Literal[ + "approval_request", + "approval_resolution", + "proxy_execution", + "task_step_continuation", + "task_step_sequence", + "task_step_transition", +] +TaskStepKind = Literal["governed_request"] +TaskStepStatus = Literal["created", "approved", "executed", "blocked", "denied"] +ProxyExecutionStatus = Literal["completed", "blocked"] +ExecutionBudgetStatus = Literal["active", "inactive", "superseded"] +ExecutionBudgetDecision = Literal["allow", "block"] +ExecutionBudgetDecisionReason = Literal[ + "no_matching_budget", + "within_budget", + "budget_exceeded", + "invalid_request_context", +] +ExecutionBudgetContextResolution = Literal["resolved", "invalid"] +ExecutionBudgetCountScope = Literal["lifetime", "rolling_window"] +ExecutionBudgetLifecycleAction = Literal["deactivate", "supersede"] +ExecutionBudgetLifecycleOutcome = Literal["deactivated", "superseded", "rejected"] +PolicyEffect = Literal["allow", "deny", "require_approval"] +PolicyEvaluationReasonCode = Literal[ + "matched_policy", + "policy_effect_allow", + "policy_effect_deny", + "policy_effect_require_approval", + "consent_missing", + "consent_revoked", + "no_matching_policy", +] +ToolMetadataVersion = Literal["tool_metadata_v0"] +ToolAllowlistReasonCode = Literal[ + "tool_metadata_matched", + "tool_action_unsupported", + "tool_scope_unsupported", + "tool_domain_mismatch", + "tool_risk_mismatch", + "matched_policy", + "policy_effect_allow", + "policy_effect_deny", + "policy_effect_require_approval", + "consent_missing", + "consent_revoked", + "no_matching_policy", +] +ToolAllowlistDecision = Literal["allowed", "denied", "approval_required"] +ToolRoutingDecision = Literal["ready", "denied", "approval_required"] +PromptSectionName = Literal["system", "developer", "context", "conversation"] +ModelProvider = Literal["openai_responses"] +ModelFinishReason = Literal["completed", "incomplete"] +ExplicitPreferencePattern = Literal[ + "i_like", + "i_dont_like", + "i_prefer", + "remember_that_i_like", + "remember_that_i_dont_like", + "remember_that_i_prefer", +] +ExplicitCommitmentPattern = Literal[ + "remind_me_to", + "i_need_to", + "dont_let_me_forget_to", + "remember_to", +] +ContinuityObjectType = Literal[ + "Note", + "MemoryFact", + "Decision", + "Commitment", + "WaitingFor", + "Blocker", + "NextAction", +] +ContinuityCaptureExplicitSignal = Literal[ + "remember_this", + "task", + "decision", + "commitment", + "waiting_for", + "blocker", + "next_action", + "note", +] +ContinuityCaptureAdmissionPosture = Literal["DERIVED", "TRIAGE"] +ContinuityRecallScopeKind = Literal["thread", "task", "project", "person"] +ContinuityCorrectionAction = Literal["confirm", "edit", "delete", "supersede", "mark_stale"] +ContinuityReviewStatus = Literal["active", "stale", "superseded", "deleted"] +ContinuityReviewStatusFilter = Literal["correction_ready", "active", "stale", "superseded", "deleted", "all"] +ContinuityOpenLoopPosture = Literal["waiting_for", "blocker", "stale", "next_action"] +ContinuityOpenLoopReviewAction = Literal["done", "deferred", "still_blocked"] +ChiefOfStaffPriorityPosture = Literal["urgent", "important", "waiting", "blocked", "stale", "defer"] +ChiefOfStaffRecommendationConfidencePosture = Literal["high", "medium", "low"] +ChiefOfStaffRecommendedActionType = Literal[ + "execute_next_action", + "progress_commitment", + "follow_up_waiting_for", + "unblock_blocker", + "refresh_stale_item", + "review_and_defer", + "capture_new_priority", +] +ChiefOfStaffFollowThroughPosture = Literal["overdue", "stale_waiting_for", "slipped_commitment"] +ChiefOfStaffFollowThroughRecommendationAction = Literal[ + "nudge", + "defer", + "escalate", + "close_loop_candidate", +] +ChiefOfStaffEscalationPosture = Literal["watch", "elevated", "critical"] +ChiefOfStaffResumptionRecommendationAction = Literal[ + "execute_next_action", + "progress_commitment", + "follow_up_waiting_for", + "unblock_blocker", + "refresh_stale_item", + "review_and_defer", + "capture_new_priority", + "nudge", + "defer", + "escalate", + "close_loop_candidate", + "review_scope", +] +ChiefOfStaffRecommendationOutcome = Literal["accept", "defer", "ignore", "rewrite"] +ChiefOfStaffWeeklyReviewGuidanceAction = Literal["close", "defer", "escalate"] +ChiefOfStaffPatternDriftPosture = Literal["improving", "stable", "drifting", "insufficient_signal"] +ChiefOfStaffActionHandoffSourceKind = Literal[ + "recommended_next_action", + "follow_through", + "prep_checklist", + "weekly_review", +] +ChiefOfStaffActionHandoffAction = Literal[ + "execute_next_action", + "progress_commitment", + "follow_up_waiting_for", + "unblock_blocker", + "refresh_stale_item", + "review_and_defer", + "capture_new_priority", + "nudge", + "defer", + "escalate", + "close_loop_candidate", + "review_scope", + "weekly_review_close", + "weekly_review_defer", + "weekly_review_escalate", +] +ChiefOfStaffExecutionPosture = Literal["approval_bounded_artifact_only"] +ChiefOfStaffExecutionReadinessPosture = Literal["approval_required_draft_only"] +ChiefOfStaffExecutionRouteTarget = Literal[ + "task_workflow_draft", + "approval_workflow_draft", + "follow_up_draft_only", +] +ChiefOfStaffExecutionRoutingTransition = Literal["routed", "reaffirmed"] +ChiefOfStaffHandoffQueueLifecycleState = Literal[ + "ready", + "pending_approval", + "executed", + "stale", + "expired", +] +ChiefOfStaffHandoffReviewAction = Literal[ + "mark_ready", + "mark_pending_approval", + "mark_executed", + "mark_stale", + "mark_expired", +] +ChiefOfStaffHandoffOutcomeStatus = Literal[ + "reviewed", + "approved", + "rejected", + "rewritten", + "executed", + "ignored", + "expired", +] +ChiefOfStaffClosureQualityPosture = Literal["insufficient_signal", "healthy", "watch", "critical"] +ExplicitCommitmentOpenLoopDecision = Literal[ + "CREATED", + "NOOP_ACTIVE_EXISTS", + "NOOP_MEMORY_NOT_PERSISTED", +] +MemorySelectionSource = Literal["symbolic", "semantic"] +ArtifactSelectionSource = Literal["lexical", "semantic"] + +DEFAULT_MAX_SESSIONS = 3 +DEFAULT_MAX_EVENTS = 8 +DEFAULT_MAX_MEMORIES = 5 +DEFAULT_MAX_ENTITIES = 5 +DEFAULT_MAX_ENTITY_EDGES = 10 +DEFAULT_MEMORY_REVIEW_LIMIT = 20 +MAX_MEMORY_REVIEW_LIMIT = 100 +DEFAULT_OPEN_LOOP_LIMIT = 20 +MAX_OPEN_LOOP_LIMIT = 100 +DEFAULT_RESUMPTION_BRIEF_EVENT_LIMIT = 8 +MAX_RESUMPTION_BRIEF_EVENT_LIMIT = 50 +DEFAULT_RESUMPTION_BRIEF_OPEN_LOOP_LIMIT = 5 +MAX_RESUMPTION_BRIEF_OPEN_LOOP_LIMIT = 20 +DEFAULT_RESUMPTION_BRIEF_MEMORY_LIMIT = 5 +MAX_RESUMPTION_BRIEF_MEMORY_LIMIT = 20 +DEFAULT_SEMANTIC_MEMORY_RETRIEVAL_LIMIT = 5 +MAX_SEMANTIC_MEMORY_RETRIEVAL_LIMIT = 50 +DEFAULT_ARTIFACT_CHUNK_RETRIEVAL_LIMIT = 5 +MAX_ARTIFACT_CHUNK_RETRIEVAL_LIMIT = 50 +DEFAULT_CONTINUITY_CAPTURE_LIMIT = 20 +MAX_CONTINUITY_CAPTURE_LIMIT = 100 +DEFAULT_CONTINUITY_REVIEW_LIMIT = 20 +MAX_CONTINUITY_REVIEW_LIMIT = 100 +DEFAULT_CONTINUITY_RECALL_LIMIT = 20 +MAX_CONTINUITY_RECALL_LIMIT = 100 +DEFAULT_CONTINUITY_RESUMPTION_RECENT_CHANGES_LIMIT = 5 +MAX_CONTINUITY_RESUMPTION_RECENT_CHANGES_LIMIT = 20 +DEFAULT_CONTINUITY_RESUMPTION_OPEN_LOOP_LIMIT = 5 +MAX_CONTINUITY_RESUMPTION_OPEN_LOOP_LIMIT = 20 +DEFAULT_CONTINUITY_OPEN_LOOP_LIMIT = 20 +MAX_CONTINUITY_OPEN_LOOP_LIMIT = 100 +DEFAULT_CONTINUITY_DAILY_BRIEF_LIMIT = 3 +MAX_CONTINUITY_DAILY_BRIEF_LIMIT = 20 +DEFAULT_CONTINUITY_WEEKLY_REVIEW_LIMIT = 5 +MAX_CONTINUITY_WEEKLY_REVIEW_LIMIT = 50 +DEFAULT_CHIEF_OF_STAFF_PRIORITY_LIMIT = 12 +MAX_CHIEF_OF_STAFF_PRIORITY_LIMIT = 100 +DEFAULT_CALENDAR_EVENT_LIST_LIMIT = 20 +MAX_CALENDAR_EVENT_LIST_LIMIT = 50 +DEFAULT_CHANNEL_MESSAGE_LIMIT = 50 +MAX_CHANNEL_MESSAGE_LIMIT = 200 +COMPILER_VERSION_V0 = "continuity_v0" +PROMPT_ASSEMBLY_VERSION_V0 = "prompt_assembly_v0" +RESPONSE_GENERATION_VERSION_V0 = "response_generation_v0" +TRACE_KIND_CONTEXT_COMPILE = "context.compile" +TRACE_KIND_RESPONSE_GENERATE = "response.generate" +TRACE_REVIEW_LIST_ORDER = ["created_at_desc", "id_desc"] +TRACE_REVIEW_EVENT_LIST_ORDER = ["sequence_no_asc", "id_asc"] +THREAD_LIST_ORDER = ["created_at_desc", "id_desc"] +AGENT_PROFILE_LIST_ORDER = ["id_asc"] +THREAD_SESSION_LIST_ORDER = ["started_at_asc", "created_at_asc", "id_asc"] +THREAD_EVENT_LIST_ORDER = ["sequence_no_asc"] +DEFAULT_AGENT_PROFILE_ID = "assistant_default" +RESUMPTION_BRIEF_ASSEMBLY_VERSION_V0 = "resumption_brief_v0" +CONTINUITY_RESUMPTION_BRIEF_ASSEMBLY_VERSION_V0 = "continuity_resumption_brief_v0" +CONTINUITY_DAILY_BRIEF_ASSEMBLY_VERSION_V0 = "continuity_daily_brief_v0" +CONTINUITY_WEEKLY_REVIEW_ASSEMBLY_VERSION_V0 = "continuity_weekly_review_v0" +CHIEF_OF_STAFF_PRIORITY_BRIEF_ASSEMBLY_VERSION_V0 = "chief_of_staff_priority_brief_v0" +RESUMPTION_BRIEF_CONVERSATION_EVENT_KINDS = ["message.user", "message.assistant"] +RESUMPTION_BRIEF_CONVERSATION_ORDER = ["sequence_no_asc"] +RESUMPTION_BRIEF_MEMORY_ORDER = ["updated_at_asc", "created_at_asc", "id_asc"] +MEMORY_REVIEW_ORDER = ["updated_at_desc", "created_at_desc", "id_desc"] +MEMORY_REVIEW_QUEUE_ORDER = ["updated_at_desc", "created_at_desc", "id_desc"] +DEFAULT_MEMORY_REVIEW_QUEUE_PRIORITY_MODE: MemoryReviewQueuePriorityMode = "recent_first" +MEMORY_REVIEW_QUEUE_PRIORITY_MODES: list[MemoryReviewQueuePriorityMode] = [ + "oldest_first", + "recent_first", + "high_risk_first", + "stale_truth_first", +] +MEMORY_REVIEW_QUEUE_ORDER_BY_PRIORITY_MODE: dict[MemoryReviewQueuePriorityMode, list[str]] = { + "oldest_first": ["updated_at_asc", "created_at_asc", "id_asc"], + "recent_first": ["updated_at_desc", "created_at_desc", "id_desc"], + "high_risk_first": [ + "is_high_risk_desc", + "confidence_asc_nulls_first", + "updated_at_desc", + "created_at_desc", + "id_desc", + ], + "stale_truth_first": [ + "is_stale_truth_desc", + "valid_to_asc_nulls_last", + "updated_at_desc", + "created_at_desc", + "id_desc", + ], +} +MEMORY_QUALITY_PRECISION_TARGET = 0.8 +MEMORY_QUALITY_MIN_ADJUDICATED_SAMPLE = 10 +MEMORY_QUALITY_HIGH_RISK_CONFIDENCE_THRESHOLD = 0.7 +MEMORY_REVISION_REVIEW_ORDER = ["sequence_no_asc"] +MEMORY_REVIEW_LABEL_VALUES = [ + "correct", + "incorrect", + "outdated", + "insufficient_evidence", +] +MEMORY_REVIEW_LABEL_ORDER = ["created_at_asc", "id_asc"] +OPEN_LOOP_REVIEW_ORDER = ["opened_at_desc", "created_at_desc", "id_desc"] +MEMORY_TYPES = [ + "preference", + "identity_fact", + "relationship_fact", + "project_fact", + "decision", + "commitment", + "routine", + "constraint", + "working_style", +] +MEMORY_CONFIRMATION_STATUSES = [ + "unconfirmed", + "confirmed", + "contested", +] +MEMORY_TRUST_CLASSES = [ + "deterministic", + "llm_single_source", + "llm_corroborated", + "human_curated", +] +MEMORY_PROMOTION_ELIGIBILITIES = [ + "promotable", + "not_promotable", +] +OPEN_LOOP_STATUSES = [ + "open", + "resolved", + "dismissed", +] +DEFAULT_MEMORY_TYPE: MemoryType = "preference" +DEFAULT_MEMORY_CONFIRMATION_STATUS: MemoryConfirmationStatus = "unconfirmed" +DEFAULT_MEMORY_TRUST_CLASS: MemoryTrustClass = "deterministic" +DEFAULT_MEMORY_PROMOTION_ELIGIBILITY: MemoryPromotionEligibility = "promotable" +DEFAULT_CONTINUITY_LIFECYCLE_LIMIT = 50 +MAX_CONTINUITY_LIFECYCLE_LIMIT = 200 +ENTITY_TYPES = [ + "person", + "merchant", + "product", + "project", + "routine", +] +ENTITY_LIST_ORDER = ["created_at_asc", "id_asc"] +ENTITY_EDGE_LIST_ORDER = ["created_at_asc", "id_asc"] +EMBEDDING_CONFIG_LIST_ORDER = ["created_at_asc", "id_asc"] +MEMORY_EMBEDDING_LIST_ORDER = ["created_at_asc", "id_asc"] +SEMANTIC_MEMORY_RETRIEVAL_ORDER = ["score_desc", "created_at_asc", "id_asc"] +RETRIEVAL_EVALUATION_FIXTURE_ORDER = ["fixture_id_asc"] +RETRIEVAL_EVALUATION_RESULT_ORDER = ["precision_at_k_desc", "fixture_id_asc"] +EMBEDDING_CONFIG_STATUSES = ["active", "deprecated", "disabled"] +CONSENT_STATUSES = ["granted", "revoked"] +CONSENT_LIST_ORDER = ["consent_key_asc", "created_at_asc", "id_asc"] +POLICY_EFFECTS = ["allow", "deny", "require_approval"] +POLICY_LIST_ORDER = ["priority_asc", "created_at_asc", "id_asc"] +POLICY_EVALUATION_VERSION_V0 = "policy_evaluation_v0" +TRACE_KIND_POLICY_EVALUATE = "policy.evaluate" +TOOL_METADATA_VERSION_V0 = "tool_metadata_v0" +TOOL_LIST_ORDER = ["tool_key_asc", "version_asc", "created_at_asc", "id_asc"] +TOOL_ALLOWLIST_EVALUATION_VERSION_V0 = "tool_allowlist_evaluation_v0" +TRACE_KIND_TOOL_ALLOWLIST_EVALUATE = "tool.allowlist.evaluate" +TOOL_ROUTING_VERSION_V0 = "tool_routing_v0" +TRACE_KIND_TOOL_ROUTE = "tool.route" +APPROVAL_LIST_ORDER = ["created_at_asc", "id_asc"] +TASK_LIST_ORDER = ["created_at_asc", "id_asc"] +TASK_WORKSPACE_LIST_ORDER = ["created_at_asc", "id_asc"] +GMAIL_ACCOUNT_LIST_ORDER = ["created_at_asc", "id_asc"] +CALENDAR_ACCOUNT_LIST_ORDER = ["created_at_asc", "id_asc"] +CALENDAR_EVENT_LIST_ORDER = ["start_time_asc", "provider_event_id_asc"] +CHANNEL_IDENTITY_LIST_ORDER = ["updated_at_desc", "created_at_desc", "id_desc"] +CHANNEL_LINK_CHALLENGE_LIST_ORDER = ["created_at_desc", "id_desc"] +CHANNEL_THREAD_LIST_ORDER = ["last_message_at_desc", "id_desc"] +CHANNEL_MESSAGE_LIST_ORDER = ["created_at_desc", "id_desc"] +CHANNEL_DELIVERY_RECEIPT_LIST_ORDER = ["recorded_at_desc", "id_desc"] +TASK_ARTIFACT_LIST_ORDER = ["created_at_asc", "id_asc"] +TASK_ARTIFACT_CHUNK_LIST_ORDER = ["sequence_no_asc", "id_asc"] +TASK_ARTIFACT_CHUNK_EMBEDDING_LIST_ORDER = [ + "task_artifact_chunk_sequence_no_asc", + "created_at_asc", + "id_asc", +] +TASK_ARTIFACT_CHUNK_RETRIEVAL_ORDER = [ + "matched_query_term_count_desc", + "first_match_char_start_asc", + "relative_path_asc", + "sequence_no_asc", + "id_asc", +] +TASK_ARTIFACT_CHUNK_SEMANTIC_RETRIEVAL_ORDER = [ + "score_desc", + "relative_path_asc", + "sequence_no_asc", + "id_asc", +] +TASK_STEP_LIST_ORDER = ["sequence_no_asc", "created_at_asc", "id_asc"] +TOOL_EXECUTION_LIST_ORDER = ["executed_at_asc", "id_asc"] +EXECUTION_BUDGET_LIST_ORDER = ["created_at_asc", "id_asc"] +EXECUTION_BUDGET_MATCH_ORDER = ["specificity_desc", "created_at_asc", "id_asc"] +EXECUTION_BUDGET_STATUSES = ["active", "inactive", "superseded"] +TASK_STATUSES = ["pending_approval", "approved", "executed", "denied", "blocked"] +TASK_RUN_STATUSES = [ + "queued", + "running", + "waiting_approval", + "waiting_user", + "paused", + "failed", + "done", + "cancelled", +] +TASK_RUN_STOP_REASONS = [ + "waiting_approval", + "waiting_user", + "paused", + "budget_exhausted", + "approval_rejected", + "policy_blocked", + "retry_exhausted", + "fatal_error", + "done", + "cancelled", +] +TASK_RUN_FAILURE_CLASSES = ["transient", "policy", "approval", "budget", "fatal"] +TASK_RUN_RETRY_POSTURES = [ + "none", + "retryable", + "exhausted", + "terminal", + "paused", + "awaiting_approval", + "awaiting_user", +] +TASK_RUN_LIST_ORDER = ["created_at_asc", "id_asc"] +CONTINUITY_CAPTURE_LIST_ORDER = ["created_at_desc", "id_desc"] +CONTINUITY_OBJECT_LIST_ORDER = ["created_at_desc", "id_desc"] +CONTINUITY_REVIEW_QUEUE_ORDER = ["updated_at_desc", "created_at_desc", "id_desc"] +CONTINUITY_CORRECTION_EVENT_ORDER = ["created_at_desc", "id_desc"] +CONTINUITY_RECALL_LIST_ORDER = ["relevance_desc", "created_at_desc", "id_desc"] +CONTINUITY_LIFECYCLE_LIST_ORDER = ["updated_at_desc", "id_desc"] +CONTINUITY_RESUMPTION_RECENT_CHANGE_ORDER = ["created_at_desc", "id_desc"] +CONTINUITY_RESUMPTION_OPEN_LOOP_ORDER = ["created_at_desc", "id_desc"] +CONTINUITY_OPEN_LOOP_POSTURE_ORDER = ["waiting_for", "blocker", "stale", "next_action"] +CONTINUITY_OPEN_LOOP_ITEM_ORDER = ["created_at_desc", "id_desc"] +CHIEF_OF_STAFF_PRIORITY_POSTURE_ORDER = ["urgent", "important", "waiting", "blocked", "stale", "defer"] +CHIEF_OF_STAFF_PRIORITY_ITEM_ORDER = ["score_desc", "created_at_desc", "id_desc"] +CHIEF_OF_STAFF_RECOMMENDATION_CONFIDENCE_ORDER = ["high", "medium", "low"] +CHIEF_OF_STAFF_RECOMMENDED_ACTION_TYPES = [ + "execute_next_action", + "progress_commitment", + "follow_up_waiting_for", + "unblock_blocker", + "refresh_stale_item", + "review_and_defer", + "capture_new_priority", +] +CHIEF_OF_STAFF_FOLLOW_THROUGH_POSTURE_ORDER = [ + "overdue", + "stale_waiting_for", + "slipped_commitment", +] +CHIEF_OF_STAFF_FOLLOW_THROUGH_ITEM_ORDER = [ + "recommendation_action_desc", + "age_hours_desc", + "created_at_desc", + "id_desc", +] +CHIEF_OF_STAFF_FOLLOW_THROUGH_RECOMMENDATION_ACTIONS = [ + "nudge", + "defer", + "escalate", + "close_loop_candidate", +] +CHIEF_OF_STAFF_ESCALATION_POSTURE_ORDER = ["watch", "elevated", "critical"] +CHIEF_OF_STAFF_PREPARATION_ITEM_ORDER = ["rank_asc", "created_at_desc", "id_desc"] +CHIEF_OF_STAFF_RESUMPTION_SUPERVISION_ITEM_ORDER = ["rank_asc"] +CHIEF_OF_STAFF_RESUMPTION_RECOMMENDATION_ACTIONS = [ + "execute_next_action", + "progress_commitment", + "follow_up_waiting_for", + "unblock_blocker", + "refresh_stale_item", + "review_and_defer", + "capture_new_priority", + "nudge", + "defer", + "escalate", + "close_loop_candidate", + "review_scope", +] +CHIEF_OF_STAFF_RECOMMENDATION_OUTCOMES = ["accept", "defer", "ignore", "rewrite"] +CHIEF_OF_STAFF_WEEKLY_REVIEW_GUIDANCE_ACTIONS = ["close", "defer", "escalate"] +CHIEF_OF_STAFF_RECOMMENDATION_OUTCOME_ORDER = ["created_at_desc", "id_desc"] +CHIEF_OF_STAFF_OUTCOME_HOTSPOT_ORDER = ["count_desc", "key_asc"] +CHIEF_OF_STAFF_ACTION_HANDOFF_SOURCE_ORDER = [ + "recommended_next_action", + "follow_through", + "prep_checklist", + "weekly_review", +] +CHIEF_OF_STAFF_ACTION_HANDOFF_ITEM_ORDER = ["score_desc", "source_order_asc", "source_reference_id_asc"] +CHIEF_OF_STAFF_ACTION_HANDOFF_ACTIONS = [ + "execute_next_action", + "progress_commitment", + "follow_up_waiting_for", + "unblock_blocker", + "refresh_stale_item", + "review_and_defer", + "capture_new_priority", + "nudge", + "defer", + "escalate", + "close_loop_candidate", + "review_scope", + "weekly_review_close", + "weekly_review_defer", + "weekly_review_escalate", +] +CHIEF_OF_STAFF_EXECUTION_POSTURE_ORDER = ["approval_bounded_artifact_only"] +CHIEF_OF_STAFF_EXECUTION_READINESS_POSTURE_ORDER = ["approval_required_draft_only"] +CHIEF_OF_STAFF_EXECUTION_ROUTE_TARGET_ORDER = [ + "task_workflow_draft", + "approval_workflow_draft", + "follow_up_draft_only", +] +CHIEF_OF_STAFF_EXECUTION_ROUTED_ITEM_ORDER = ["handoff_rank_asc", "handoff_item_id_asc"] +CHIEF_OF_STAFF_EXECUTION_ROUTING_AUDIT_ORDER = ["created_at_desc", "id_desc"] +CHIEF_OF_STAFF_EXECUTION_ROUTING_TRANSITIONS = ["routed", "reaffirmed"] +CHIEF_OF_STAFF_HANDOFF_QUEUE_STATE_ORDER = [ + "ready", + "pending_approval", + "executed", + "stale", + "expired", +] +CHIEF_OF_STAFF_HANDOFF_QUEUE_ITEM_ORDER = [ + "queue_rank_asc", + "handoff_rank_asc", + "score_desc", + "handoff_item_id_asc", +] +CHIEF_OF_STAFF_HANDOFF_REVIEW_ACTIONS = [ + "mark_ready", + "mark_pending_approval", + "mark_executed", + "mark_stale", + "mark_expired", +] +CHIEF_OF_STAFF_HANDOFF_OUTCOME_STATUSES = [ + "reviewed", + "approved", + "rejected", + "rewritten", + "executed", + "ignored", + "expired", +] +CHIEF_OF_STAFF_HANDOFF_OUTCOME_ORDER = ["created_at_desc", "id_desc"] +TASK_WORKSPACE_STATUSES = ["active"] +TASK_ARTIFACT_STATUSES = ["registered"] +TASK_ARTIFACT_INGESTION_STATUSES = ["pending", "ingested"] +TASK_STEP_KINDS = ["governed_request"] +TASK_STEP_STATUSES = ["created", "approved", "executed", "blocked", "denied"] +APPROVAL_REQUEST_VERSION_V0 = "approval_request_v0" +TRACE_KIND_APPROVAL_REQUEST = "approval.request" +APPROVAL_RESOLUTION_VERSION_V0 = "approval_resolution_v0" +TRACE_KIND_APPROVAL_RESOLUTION = "approval.resolve" +TRACE_KIND_APPROVAL_RESOLVE = TRACE_KIND_APPROVAL_RESOLUTION +PROXY_EXECUTION_VERSION_V0 = "proxy_execution_v0" +TRACE_KIND_PROXY_EXECUTE = "tool.proxy.execute" +GMAIL_PROVIDER = "gmail" +GMAIL_AUTH_KIND_OAUTH_ACCESS_TOKEN = "oauth_access_token" +GMAIL_READONLY_SCOPE = "https://www.googleapis.com/auth/gmail.readonly" +GMAIL_PROTECTED_CREDENTIAL_KIND = "gmail_oauth_access_token_v1" +GMAIL_REFRESHABLE_PROTECTED_CREDENTIAL_KIND = "gmail_oauth_refresh_token_v2" +CALENDAR_PROVIDER = "google_calendar" +CALENDAR_AUTH_KIND_OAUTH_ACCESS_TOKEN = "oauth_access_token" +CALENDAR_READONLY_SCOPE = "https://www.googleapis.com/auth/calendar.readonly" +CALENDAR_PROTECTED_CREDENTIAL_KIND = "calendar_oauth_access_token_v1" +TASK_STEP_SEQUENCE_VERSION_V0 = "task_step_sequence_v0" +TRACE_KIND_TASK_STEP_SEQUENCE = "task.step.sequence" +TASK_STEP_CONTINUATION_VERSION_V0 = "task_step_continuation_v0" +TRACE_KIND_TASK_STEP_CONTINUATION = "task.step.continuation" +TASK_STEP_TRANSITION_VERSION_V0 = "task_step_transition_v0" +TRACE_KIND_TASK_STEP_TRANSITION = "task.step.transition" +EXECUTION_BUDGET_LIFECYCLE_VERSION_V0 = "execution_budget_lifecycle_v0" +TRACE_KIND_EXECUTION_BUDGET_LIFECYCLE = "execution_budget.lifecycle" +CONTINUITY_OBJECT_TYPES = [ + "Note", + "MemoryFact", + "Decision", + "Commitment", + "WaitingFor", + "Blocker", + "NextAction", +] +CONTINUITY_CAPTURE_EXPLICIT_SIGNALS = [ + "remember_this", + "task", + "decision", + "commitment", + "waiting_for", + "blocker", + "next_action", + "note", +] +CONTINUITY_CORRECTION_ACTIONS = [ + "confirm", + "edit", + "delete", + "supersede", + "mark_stale", +] +CONTINUITY_PRESERVATION_STATUSES = [ + "preserved", + "not_preserved", +] +CONTINUITY_SEARCHABILITY_STATUSES = [ + "searchable", + "not_searchable", +] +CONTINUITY_PROMOTION_STATUSES = [ + "promotable", + "not_promotable", +] +CONTINUITY_REVIEW_STATUSES = [ + "active", + "stale", + "superseded", + "deleted", +] +CONTINUITY_OPEN_LOOP_POSTURES = [ + "waiting_for", + "blocker", + "stale", + "next_action", +] +CONTINUITY_OPEN_LOOP_REVIEW_ACTIONS = [ + "done", + "deferred", + "still_blocked", +] + + +@dataclass(frozen=True, slots=True) +class ContextCompilerLimits: + max_sessions: int = DEFAULT_MAX_SESSIONS + max_events: int = DEFAULT_MAX_EVENTS + max_memories: int = DEFAULT_MAX_MEMORIES + max_entities: int = DEFAULT_MAX_ENTITIES + max_entity_edges: int = DEFAULT_MAX_ENTITY_EDGES + + def as_payload(self) -> JsonObject: + return { + "max_sessions": self.max_sessions, + "max_events": self.max_events, + "max_memories": self.max_memories, + "max_entities": self.max_entities, + "max_entity_edges": self.max_entity_edges, + } + + +@dataclass(frozen=True, slots=True) +class CompileContextSemanticRetrievalInput: + embedding_config_id: UUID + query_vector: tuple[float, ...] + limit: int = DEFAULT_SEMANTIC_MEMORY_RETRIEVAL_LIMIT + + def as_payload(self) -> JsonObject: + return { + "embedding_config_id": str(self.embedding_config_id), + "query_vector": [float(value) for value in self.query_vector], + "limit": self.limit, + } + + +@dataclass(frozen=True, slots=True) +class CompileContextTaskScopedArtifactRetrievalInput: + task_id: UUID + query: str + limit: int = DEFAULT_ARTIFACT_CHUNK_RETRIEVAL_LIMIT + + def as_payload(self) -> JsonObject: + return { + "kind": "task", + "task_id": str(self.task_id), + "query": self.query, + "limit": self.limit, + } + + +@dataclass(frozen=True, slots=True) +class CompileContextArtifactScopedArtifactRetrievalInput: + task_artifact_id: UUID + query: str + limit: int = DEFAULT_ARTIFACT_CHUNK_RETRIEVAL_LIMIT + + def as_payload(self) -> JsonObject: + return { + "kind": "artifact", + "task_artifact_id": str(self.task_artifact_id), + "query": self.query, + "limit": self.limit, + } + + +CompileContextArtifactRetrievalInput: TypeAlias = ( + CompileContextTaskScopedArtifactRetrievalInput + | CompileContextArtifactScopedArtifactRetrievalInput +) + + +@dataclass(frozen=True, slots=True) +class CompileContextTaskScopedSemanticArtifactRetrievalInput: + task_id: UUID + embedding_config_id: UUID + query_vector: tuple[float, ...] + limit: int = DEFAULT_ARTIFACT_CHUNK_RETRIEVAL_LIMIT + + def as_payload(self) -> JsonObject: + return { + "kind": "task", + "task_id": str(self.task_id), + "embedding_config_id": str(self.embedding_config_id), + "query_vector": [float(value) for value in self.query_vector], + "limit": self.limit, + } + + +@dataclass(frozen=True, slots=True) +class CompileContextArtifactScopedSemanticArtifactRetrievalInput: + task_artifact_id: UUID + embedding_config_id: UUID + query_vector: tuple[float, ...] + limit: int = DEFAULT_ARTIFACT_CHUNK_RETRIEVAL_LIMIT + + def as_payload(self) -> JsonObject: + return { + "kind": "artifact", + "task_artifact_id": str(self.task_artifact_id), + "embedding_config_id": str(self.embedding_config_id), + "query_vector": [float(value) for value in self.query_vector], + "limit": self.limit, + } + + +CompileContextSemanticArtifactRetrievalInput: TypeAlias = ( + CompileContextTaskScopedSemanticArtifactRetrievalInput + | CompileContextArtifactScopedSemanticArtifactRetrievalInput +) + + +@dataclass(frozen=True, slots=True) +class TraceCreate: + user_id: UUID + thread_id: UUID + kind: str + compiler_version: str + status: str + limits: ContextCompilerLimits + + +@dataclass(frozen=True, slots=True) +class TraceEventRecord: + kind: str + payload: JsonObject + + +class AgentProfileRecord(TypedDict): + id: str + name: str + description: str + model_provider: ModelProvider | None + model_name: str | None + + +class AgentProfileListSummary(TypedDict): + total_count: int + order: list[str] + + +class AgentProfileListResponse(TypedDict): + items: list[AgentProfileRecord] + summary: AgentProfileListSummary + + +@dataclass(frozen=True, slots=True) +class ThreadCreateInput: + title: str + agent_profile_id: str = DEFAULT_AGENT_PROFILE_ID + + +class ThreadRecord(TypedDict): + id: str + title: str + agent_profile_id: str + created_at: str + updated_at: str + + +class ThreadCreateResponse(TypedDict): + thread: ThreadRecord + + +class ThreadListSummary(TypedDict): + total_count: int + order: list[str] + + +class ThreadListResponse(TypedDict): + items: list[ThreadRecord] + summary: ThreadListSummary + + +class ThreadDetailResponse(TypedDict): + thread: ThreadRecord + + +class ThreadSessionRecord(TypedDict): + id: str + thread_id: str + status: str + started_at: str | None + ended_at: str | None + created_at: str + + +class ThreadSessionListSummary(TypedDict): + thread_id: str + total_count: int + order: list[str] + + +class ThreadSessionListResponse(TypedDict): + items: list[ThreadSessionRecord] + summary: ThreadSessionListSummary + + +class ThreadEventRecord(TypedDict): + id: str + thread_id: str + session_id: str | None + sequence_no: int + kind: str + payload: JsonObject + created_at: str + + +class ThreadEventListSummary(TypedDict): + thread_id: str + total_count: int + order: list[str] + + +class ThreadEventListResponse(TypedDict): + items: list[ThreadEventRecord] + summary: ThreadEventListSummary + + +@dataclass(frozen=True, slots=True) +class ResumptionBriefRequestInput: + thread_id: UUID + max_events: int = DEFAULT_RESUMPTION_BRIEF_EVENT_LIMIT + max_open_loops: int = DEFAULT_RESUMPTION_BRIEF_OPEN_LOOP_LIMIT + max_memories: int = DEFAULT_RESUMPTION_BRIEF_MEMORY_LIMIT + + +class TraceReviewSummaryRecord(TypedDict): + id: str + thread_id: str + kind: str + compiler_version: str + status: str + created_at: str + trace_event_count: int + + +class TraceReviewRecord(TraceReviewSummaryRecord): + limits: JsonObject + + +class TraceReviewListSummary(TypedDict): + total_count: int + order: list[str] + + +class TraceReviewListResponse(TypedDict): + items: list[TraceReviewSummaryRecord] + summary: TraceReviewListSummary + + +class TraceReviewDetailResponse(TypedDict): + trace: TraceReviewRecord + + +class TraceReviewEventRecord(TypedDict): + id: str + trace_id: str + sequence_no: int + kind: str + payload: JsonObject + created_at: str + + +class TraceReviewEventListSummary(TypedDict): + trace_id: str + total_count: int + order: list[str] + + +class TraceReviewEventListResponse(TypedDict): + items: list[TraceReviewEventRecord] + summary: TraceReviewEventListSummary + + +@dataclass(frozen=True, slots=True) +class CompilerDecision: + kind: DecisionKind + entity_type: str + entity_id: UUID + reason: str + position: int + metadata: JsonObject | None = None + + def to_trace_event(self) -> TraceEventRecord: + payload: JsonObject = { + "entity_type": self.entity_type, + "entity_id": str(self.entity_id), + "reason": self.reason, + "position": self.position, + } + if self.metadata is not None: + payload.update(self.metadata) + return TraceEventRecord(kind=f"context.{self.kind}", payload=payload) + + +class ContextPackScope(TypedDict): + user_id: str + thread_id: str + + +class ContextPackLimits(TypedDict): + max_sessions: int + max_events: int + max_memories: int + max_entities: int + max_entity_edges: int + + +class ContextPackUser(TypedDict): + id: str + email: str + display_name: str | None + created_at: str + + +class ContextPackThread(TypedDict): + id: str + title: str + created_at: str + updated_at: str + + +class ContextPackSession(TypedDict): + id: str + status: str + started_at: str | None + ended_at: str | None + created_at: str + + +class ContextPackEvent(TypedDict): + id: str + session_id: str | None + sequence_no: int + kind: str + payload: JsonObject + created_at: str + + +class ContextPackMemory(TypedDict): + id: str + memory_key: str + value: JsonValue + status: MemoryStatus + source_event_ids: list[str] + memory_type: NotRequired[MemoryType] + confidence: NotRequired[float | None] + salience: NotRequired[float | None] + confirmation_status: NotRequired[MemoryConfirmationStatus] + trust_class: NotRequired[MemoryTrustClass] + promotion_eligibility: NotRequired[MemoryPromotionEligibility] + evidence_count: NotRequired[int | None] + independent_source_count: NotRequired[int | None] + extracted_by_model: NotRequired[str | None] + trust_reason: NotRequired[str | None] + valid_from: NotRequired[str | None] + valid_to: NotRequired[str | None] + last_confirmed_at: NotRequired[str | None] + created_at: str + updated_at: str + source_provenance: "ContextPackMemorySourceProvenance" + + +class ContextPackMemorySourceProvenance(TypedDict): + sources: list[MemorySelectionSource] + semantic_score: float | None + + +class ContextPackHybridMemorySummary(TypedDict): + requested: bool + embedding_config_id: str | None + query_vector_dimensions: int + semantic_limit: int + symbolic_selected_count: int + semantic_selected_count: int + merged_candidate_count: int + deduplicated_count: int + included_symbolic_only_count: int + included_semantic_only_count: int + included_dual_source_count: int + similarity_metric: Literal["cosine_similarity"] | None + source_precedence: list[MemorySelectionSource] + symbolic_order: list[str] + semantic_order: list[str] + + +class ContextPackArtifactChunk(TypedDict): + id: str + task_id: str + task_artifact_id: str + relative_path: str + media_type: str + sequence_no: int + char_start: int + char_end_exclusive: int + text: str + source_provenance: "ContextPackArtifactChunkSourceProvenance" + + +class ContextPackArtifactChunkSourceProvenance(TypedDict): + sources: list[ArtifactSelectionSource] + lexical_match: "TaskArtifactChunkRetrievalMatch | None" + semantic_score: float | None + + +class ContextPackArtifactChunkSummary(TypedDict): + requested: bool + lexical_requested: bool + semantic_requested: bool + scope: TaskArtifactChunkRetrievalScope | None + query: str | None + query_terms: list[str] + embedding_config_id: str | None + query_vector_dimensions: int + limit: int + lexical_limit: int + semantic_limit: int + searched_artifact_count: int + lexical_candidate_count: int + semantic_candidate_count: int + merged_candidate_count: int + deduplicated_count: int + included_count: int + included_lexical_only_count: int + included_semantic_only_count: int + included_dual_source_count: int + excluded_uningested_artifact_count: int + excluded_limit_count: int + matching_rule: str | None + similarity_metric: Literal["cosine_similarity"] | None + source_precedence: list[ArtifactSelectionSource] + lexical_order: list[str] + semantic_order: list[str] + merged_order: list[str] + + +class ArtifactRetrievalDecisionTracePayload(TypedDict): + scope_kind: TaskArtifactChunkRetrievalScopeKind + task_id: str + task_artifact_id: str + relative_path: str + media_type: str | None + ingestion_status: TaskArtifactIngestionStatus + limit: int + matched_query_terms: NotRequired[list[str]] + matched_query_term_count: NotRequired[int] + first_match_char_start: NotRequired[int] + sequence_no: NotRequired[int] + char_start: NotRequired[int] + char_end_exclusive: NotRequired[int] + + +class HybridArtifactRetrievalDecisionTracePayload(TypedDict): + scope_kind: TaskArtifactChunkRetrievalScopeKind + task_id: str + task_artifact_id: str + relative_path: str + media_type: str | None + ingestion_status: TaskArtifactIngestionStatus + limit: int + selected_sources: list[ArtifactSelectionSource] + embedding_config_id: str | None + query_vector_dimensions: int + matched_query_terms: NotRequired[list[str]] + matched_query_term_count: NotRequired[int] + first_match_char_start: NotRequired[int] + score: NotRequired[float] + similarity_metric: NotRequired[Literal["cosine_similarity"]] + sequence_no: NotRequired[int] + char_start: NotRequired[int] + char_end_exclusive: NotRequired[int] + + +class ContextPackMemorySummary(TypedDict): + candidate_count: int + included_count: int + excluded_deleted_count: int + excluded_limit_count: int + hybrid_retrieval: ContextPackHybridMemorySummary + + +class ContextPackOpenLoop(TypedDict): + id: str + memory_id: str | None + title: str + status: OpenLoopStatus + opened_at: str + due_at: str | None + resolved_at: str | None + resolution_note: str | None + created_at: str + updated_at: str + + +class ContextPackOpenLoopSummary(TypedDict): + candidate_count: int + included_count: int + excluded_limit_count: int + order: list[str] + + +class HybridMemoryDecisionTracePayload(TypedDict): + embedding_config_id: str | None + memory_key: str + status: MemoryStatus + source_event_ids: list[str] + selected_sources: list[MemorySelectionSource] + semantic_score: float | None + trust_class: NotRequired[MemoryTrustClass] + promotion_eligibility: NotRequired[MemoryPromotionEligibility] + + +class ContextPackEntity(TypedDict): + id: str + entity_type: EntityType + name: str + source_memory_ids: list[str] + created_at: str + + +class ContextPackEntitySummary(TypedDict): + candidate_count: int + included_count: int + excluded_limit_count: int + + +class EntityDecisionTracePayload(TypedDict): + entity_type: str + entity_id: str + reason: str + position: int + record_entity_type: EntityType + name: str + source_memory_ids: list[str] + + +class ContextPackEntityEdge(TypedDict): + id: str + from_entity_id: str + to_entity_id: str + relationship_type: str + valid_from: str | None + valid_to: str | None + source_memory_ids: list[str] + created_at: str + + +class ContextPackEntityEdgeSummary(TypedDict): + anchor_entity_count: int + candidate_count: int + included_count: int + excluded_limit_count: int + + +class EntityEdgeDecisionTracePayload(TypedDict): + entity_type: str + entity_id: str + reason: str + position: int + from_entity_id: str + to_entity_id: str + relationship_type: str + valid_from: str | None + valid_to: str | None + source_memory_ids: list[str] + attached_included_entity_ids: list[str] + + +class CompiledContextPack(TypedDict): + compiler_version: str + scope: ContextPackScope + limits: ContextPackLimits + user: ContextPackUser + thread: ContextPackThread + sessions: list[ContextPackSession] + events: list[ContextPackEvent] + memories: list[ContextPackMemory] + memory_summary: ContextPackMemorySummary + open_loops: NotRequired[list[ContextPackOpenLoop]] + open_loop_summary: NotRequired[ContextPackOpenLoopSummary] + artifact_chunks: list[ContextPackArtifactChunk] + artifact_chunk_summary: ContextPackArtifactChunkSummary + entities: list[ContextPackEntity] + entity_summary: ContextPackEntitySummary + entity_edges: list[ContextPackEntityEdge] + entity_edge_summary: ContextPackEntityEdgeSummary + + +@dataclass(frozen=True, slots=True) +class CompilerRunResult: + context_pack: CompiledContextPack + trace_events: list[TraceEventRecord] + + +@dataclass(frozen=True, slots=True) +class PromptAssemblyInput: + context_pack: CompiledContextPack + system_instruction: str + developer_instruction: str + + +@dataclass(frozen=True, slots=True) +class PromptSection: + name: PromptSectionName + content: str + + +class PromptAssemblyTracePayload(TypedDict): + version: str + compile_trace_id: str + compiler_version: str + prompt_sha256: str + prompt_char_count: int + section_order: list[PromptSectionName] + section_characters: dict[PromptSectionName, int] + included_session_count: int + included_event_count: int + included_memory_count: int + included_entity_count: int + included_entity_edge_count: int + + +@dataclass(frozen=True, slots=True) +class PromptAssemblyResult: + sections: tuple[PromptSection, ...] + prompt_text: str + prompt_sha256: str + trace_payload: PromptAssemblyTracePayload + + +class ModelInvocationRequestPayload(TypedDict): + provider: ModelProvider + model: str + tool_choice: Literal["none"] + tools: list[JsonObject] + store: bool + sections: list[PromptSectionName] + prompt: str + + +@dataclass(frozen=True, slots=True) +class ModelInvocationRequest: + provider: ModelProvider + model: str + prompt: PromptAssemblyResult + tool_choice: Literal["none"] = "none" + store: bool = False + + def as_payload(self) -> ModelInvocationRequestPayload: + return { + "provider": self.provider, + "model": self.model, + "tool_choice": self.tool_choice, + "tools": [], + "store": self.store, + "sections": [section.name for section in self.prompt.sections], + "prompt": self.prompt.prompt_text, + } + + +class ModelUsagePayload(TypedDict): + input_tokens: int | None + output_tokens: int | None + total_tokens: int | None + cached_input_tokens: NotRequired[int | None] + + +class ModelInvocationTracePayload(TypedDict): + provider: ModelProvider + model: str + tool_choice: Literal["none"] + tools_enabled: Literal[False] + response_id: str | None + finish_reason: ModelFinishReason + output_text_char_count: int + usage: ModelUsagePayload + error_message: str | None + + +@dataclass(frozen=True, slots=True) +class ModelInvocationResponse: + provider: ModelProvider + model: str + response_id: str | None + finish_reason: ModelFinishReason + output_text: str + usage: ModelUsagePayload + + def to_trace_payload(self, *, error_message: str | None = None) -> ModelInvocationTracePayload: + return { + "provider": self.provider, + "model": self.model, + "tool_choice": "none", + "tools_enabled": False, + "response_id": self.response_id, + "finish_reason": self.finish_reason, + "output_text_char_count": len(self.output_text), + "usage": self.usage, + "error_message": error_message, + } + + +class AssistantResponseModelRecord(TypedDict): + provider: ModelProvider + model: str + response_id: str | None + finish_reason: ModelFinishReason + usage: ModelUsagePayload + + +class AssistantResponsePromptRecord(TypedDict): + assembly_version: str + prompt_sha256: str + section_order: list[PromptSectionName] + + +class AssistantResponseEventPayload(TypedDict): + text: str + model: AssistantResponseModelRecord + prompt: AssistantResponsePromptRecord + + +class GeneratedAssistantRecord(TypedDict): + event_id: str + sequence_no: int + text: str + model_provider: ModelProvider + model: str + + +class ResponseTraceSummary(TypedDict): + compile_trace_id: str + compile_trace_event_count: int + response_trace_id: str + response_trace_event_count: int + + +class GenerateResponseSuccess(TypedDict): + assistant: GeneratedAssistantRecord + trace: ResponseTraceSummary + + +@dataclass(frozen=True, slots=True) +class OpenLoopCandidateInput: + title: str + due_at: datetime | None = None + + def as_payload(self) -> JsonObject: + payload: JsonObject = { + "title": self.title, + } + payload["due_at"] = isoformat_or_none(self.due_at) + return payload + + +@dataclass(frozen=True, slots=True) +class MemoryCandidateInput: + memory_key: str + value: JsonValue | None + source_event_ids: tuple[UUID, ...] + agent_profile_id: str | None = None + delete_requested: bool = False + memory_type: str | None = None + confidence: float | None = None + salience: float | None = None + confirmation_status: str | None = None + trust_class: str | None = None + promotion_eligibility: str | None = None + evidence_count: int | None = None + independent_source_count: int | None = None + extracted_by_model: str | None = None + trust_reason: str | None = None + valid_from: datetime | None = None + valid_to: datetime | None = None + last_confirmed_at: datetime | None = None + open_loop: OpenLoopCandidateInput | None = None + + def as_payload(self) -> JsonObject: + payload: JsonObject = { + "memory_key": self.memory_key, + "source_event_ids": [str(source_event_id) for source_event_id in self.source_event_ids], + "delete_requested": self.delete_requested, + } + if self.agent_profile_id is not None: + payload["agent_profile_id"] = self.agent_profile_id + payload["value"] = self.value + if self.memory_type is not None: + payload["memory_type"] = self.memory_type + if self.confidence is not None: + payload["confidence"] = self.confidence + if self.salience is not None: + payload["salience"] = self.salience + if self.confirmation_status is not None: + payload["confirmation_status"] = self.confirmation_status + if self.trust_class is not None: + payload["trust_class"] = self.trust_class + if self.promotion_eligibility is not None: + payload["promotion_eligibility"] = self.promotion_eligibility + if self.evidence_count is not None: + payload["evidence_count"] = self.evidence_count + if self.independent_source_count is not None: + payload["independent_source_count"] = self.independent_source_count + if self.extracted_by_model is not None: + payload["extracted_by_model"] = self.extracted_by_model + if self.trust_reason is not None: + payload["trust_reason"] = self.trust_reason + if self.valid_from is not None: + payload["valid_from"] = isoformat_or_none(self.valid_from) + if self.valid_to is not None: + payload["valid_to"] = isoformat_or_none(self.valid_to) + if self.last_confirmed_at is not None: + payload["last_confirmed_at"] = isoformat_or_none(self.last_confirmed_at) + if self.open_loop is not None: + payload["open_loop"] = self.open_loop.as_payload() + return payload + + +@dataclass(frozen=True, slots=True) +class ExplicitPreferenceExtractionRequestInput: + source_event_id: UUID + + def as_payload(self) -> JsonObject: + return { + "source_event_id": str(self.source_event_id), + } + + +@dataclass(frozen=True, slots=True) +class ExplicitCommitmentExtractionRequestInput: + source_event_id: UUID + + def as_payload(self) -> JsonObject: + return { + "source_event_id": str(self.source_event_id), + } + + +@dataclass(frozen=True, slots=True) +class ExplicitSignalCaptureRequestInput: + source_event_id: UUID + + def as_payload(self) -> JsonObject: + return { + "source_event_id": str(self.source_event_id), + } + + +@dataclass(frozen=True, slots=True) +class ContinuityCaptureCreateInput: + raw_content: str + explicit_signal: ContinuityCaptureExplicitSignal | None = None + + def as_payload(self) -> JsonObject: + payload: JsonObject = { + "raw_content": self.raw_content, + } + payload["explicit_signal"] = self.explicit_signal + return payload + + +@dataclass(frozen=True, slots=True) +class ContinuityReviewQueueQueryInput: + status: ContinuityReviewStatusFilter = "correction_ready" + limit: int = DEFAULT_CONTINUITY_REVIEW_LIMIT + + def as_payload(self) -> JsonObject: + return { + "status": self.status, + "limit": self.limit, + } + + +@dataclass(frozen=True, slots=True) +class ContinuityCorrectionInput: + action: ContinuityCorrectionAction + reason: str | None = None + title: str | None = None + body: JsonObject | None = None + provenance: JsonObject | None = None + confidence: float | None = None + replacement_title: str | None = None + replacement_body: JsonObject | None = None + replacement_provenance: JsonObject | None = None + replacement_confidence: float | None = None + + def as_payload(self) -> JsonObject: + payload: JsonObject = { + "action": self.action, + "reason": self.reason, + "title": self.title, + "body": self.body, + "provenance": self.provenance, + "confidence": self.confidence, + "replacement_title": self.replacement_title, + "replacement_body": self.replacement_body, + "replacement_provenance": self.replacement_provenance, + "replacement_confidence": self.replacement_confidence, + } + return payload + + +@dataclass(frozen=True, slots=True) +class ContinuityRecallQueryInput: + query: str | None = None + thread_id: UUID | None = None + task_id: UUID | None = None + project: str | None = None + person: str | None = None + since: datetime | None = None + until: datetime | None = None + limit: int = DEFAULT_CONTINUITY_RECALL_LIMIT + + def as_payload(self) -> JsonObject: + payload: JsonObject = { + "query": self.query, + "thread_id": None if self.thread_id is None else str(self.thread_id), + "task_id": None if self.task_id is None else str(self.task_id), + "project": self.project, + "person": self.person, + "limit": self.limit, + } + payload["since"] = isoformat_or_none(self.since) + payload["until"] = isoformat_or_none(self.until) + return payload + + +@dataclass(frozen=True, slots=True) +class ContinuityResumptionBriefRequestInput: + query: str | None = None + thread_id: UUID | None = None + task_id: UUID | None = None + project: str | None = None + person: str | None = None + since: datetime | None = None + until: datetime | None = None + max_recent_changes: int = DEFAULT_CONTINUITY_RESUMPTION_RECENT_CHANGES_LIMIT + max_open_loops: int = DEFAULT_CONTINUITY_RESUMPTION_OPEN_LOOP_LIMIT + include_non_promotable_facts: bool = False + + def as_payload(self) -> JsonObject: + payload: JsonObject = { + "query": self.query, + "thread_id": None if self.thread_id is None else str(self.thread_id), + "task_id": None if self.task_id is None else str(self.task_id), + "project": self.project, + "person": self.person, + "max_recent_changes": self.max_recent_changes, + "max_open_loops": self.max_open_loops, + "include_non_promotable_facts": self.include_non_promotable_facts, + } + payload["since"] = isoformat_or_none(self.since) + payload["until"] = isoformat_or_none(self.until) + return payload + + +@dataclass(frozen=True, slots=True) +class ContinuityLifecycleQueryInput: + limit: int = DEFAULT_CONTINUITY_LIFECYCLE_LIMIT + + def as_payload(self) -> JsonObject: + return { + "limit": self.limit, + } + + +@dataclass(frozen=True, slots=True) +class ContinuityOpenLoopDashboardQueryInput: + query: str | None = None + thread_id: UUID | None = None + task_id: UUID | None = None + project: str | None = None + person: str | None = None + since: datetime | None = None + until: datetime | None = None + limit: int = DEFAULT_CONTINUITY_OPEN_LOOP_LIMIT + + def as_payload(self) -> JsonObject: + payload: JsonObject = { + "query": self.query, + "thread_id": None if self.thread_id is None else str(self.thread_id), + "task_id": None if self.task_id is None else str(self.task_id), + "project": self.project, + "person": self.person, + "limit": self.limit, + } + payload["since"] = isoformat_or_none(self.since) + payload["until"] = isoformat_or_none(self.until) + return payload + + +@dataclass(frozen=True, slots=True) +class ContinuityDailyBriefRequestInput: + query: str | None = None + thread_id: UUID | None = None + task_id: UUID | None = None + project: str | None = None + person: str | None = None + since: datetime | None = None + until: datetime | None = None + limit: int = DEFAULT_CONTINUITY_DAILY_BRIEF_LIMIT + + def as_payload(self) -> JsonObject: + payload: JsonObject = { + "query": self.query, + "thread_id": None if self.thread_id is None else str(self.thread_id), + "task_id": None if self.task_id is None else str(self.task_id), + "project": self.project, + "person": self.person, + "limit": self.limit, + } + payload["since"] = isoformat_or_none(self.since) + payload["until"] = isoformat_or_none(self.until) + return payload + + +@dataclass(frozen=True, slots=True) +class ContinuityWeeklyReviewRequestInput: + query: str | None = None + thread_id: UUID | None = None + task_id: UUID | None = None + project: str | None = None + person: str | None = None + since: datetime | None = None + until: datetime | None = None + limit: int = DEFAULT_CONTINUITY_WEEKLY_REVIEW_LIMIT + + def as_payload(self) -> JsonObject: + payload: JsonObject = { + "query": self.query, + "thread_id": None if self.thread_id is None else str(self.thread_id), + "task_id": None if self.task_id is None else str(self.task_id), + "project": self.project, + "person": self.person, + "limit": self.limit, + } + payload["since"] = isoformat_or_none(self.since) + payload["until"] = isoformat_or_none(self.until) + return payload + + +@dataclass(frozen=True, slots=True) +class ChiefOfStaffPriorityBriefRequestInput: + query: str | None = None + thread_id: UUID | None = None + task_id: UUID | None = None + project: str | None = None + person: str | None = None + since: datetime | None = None + until: datetime | None = None + limit: int = DEFAULT_CHIEF_OF_STAFF_PRIORITY_LIMIT + + def as_payload(self) -> JsonObject: + payload: JsonObject = { + "query": self.query, + "thread_id": None if self.thread_id is None else str(self.thread_id), + "task_id": None if self.task_id is None else str(self.task_id), + "project": self.project, + "person": self.person, + "limit": self.limit, + } + payload["since"] = isoformat_or_none(self.since) + payload["until"] = isoformat_or_none(self.until) + return payload + + +@dataclass(frozen=True, slots=True) +class ChiefOfStaffRecommendationOutcomeCaptureInput: + outcome: ChiefOfStaffRecommendationOutcome + recommendation_action_type: ChiefOfStaffRecommendedActionType + recommendation_title: str + rationale: str | None = None + rewritten_title: str | None = None + target_priority_id: UUID | None = None + thread_id: UUID | None = None + task_id: UUID | None = None + project: str | None = None + person: str | None = None + + def as_payload(self) -> JsonObject: + return { + "outcome": self.outcome, + "recommendation_action_type": self.recommendation_action_type, + "recommendation_title": self.recommendation_title, + "rationale": self.rationale, + "rewritten_title": self.rewritten_title, + "target_priority_id": None if self.target_priority_id is None else str(self.target_priority_id), + "thread_id": None if self.thread_id is None else str(self.thread_id), + "task_id": None if self.task_id is None else str(self.task_id), + "project": self.project, + "person": self.person, + } + + +@dataclass(frozen=True, slots=True) +class ChiefOfStaffHandoffReviewActionInput: + handoff_item_id: str + review_action: ChiefOfStaffHandoffReviewAction + note: str | None = None + thread_id: UUID | None = None + task_id: UUID | None = None + project: str | None = None + person: str | None = None + + def as_payload(self) -> JsonObject: + return { + "handoff_item_id": self.handoff_item_id, + "review_action": self.review_action, + "note": self.note, + "thread_id": None if self.thread_id is None else str(self.thread_id), + "task_id": None if self.task_id is None else str(self.task_id), + "project": self.project, + "person": self.person, + } + + +@dataclass(frozen=True, slots=True) +class ChiefOfStaffExecutionRoutingActionInput: + handoff_item_id: str + route_target: ChiefOfStaffExecutionRouteTarget + note: str | None = None + thread_id: UUID | None = None + task_id: UUID | None = None + project: str | None = None + person: str | None = None + + def as_payload(self) -> JsonObject: + return { + "handoff_item_id": self.handoff_item_id, + "route_target": self.route_target, + "note": self.note, + "thread_id": None if self.thread_id is None else str(self.thread_id), + "task_id": None if self.task_id is None else str(self.task_id), + "project": self.project, + "person": self.person, + } + + +@dataclass(frozen=True, slots=True) +class ChiefOfStaffHandoffOutcomeCaptureInput: + handoff_item_id: str + outcome_status: ChiefOfStaffHandoffOutcomeStatus + note: str | None = None + thread_id: UUID | None = None + task_id: UUID | None = None + project: str | None = None + person: str | None = None + + def as_payload(self) -> JsonObject: + return { + "handoff_item_id": self.handoff_item_id, + "outcome_status": self.outcome_status, + "note": self.note, + "thread_id": None if self.thread_id is None else str(self.thread_id), + "task_id": None if self.task_id is None else str(self.task_id), + "project": self.project, + "person": self.person, + } + + +@dataclass(frozen=True, slots=True) +class ContinuityOpenLoopReviewActionInput: + action: ContinuityOpenLoopReviewAction + note: str | None = None + + def as_payload(self) -> JsonObject: + return { + "action": self.action, + "note": self.note, + } + + +@dataclass(frozen=True, slots=True) +class OpenLoopCreateInput: + title: str + memory_id: UUID | None = None + due_at: datetime | None = None + + def as_payload(self) -> JsonObject: + payload: JsonObject = { + "title": self.title, + "memory_id": None if self.memory_id is None else str(self.memory_id), + } + payload["due_at"] = isoformat_or_none(self.due_at) + return payload + + +@dataclass(frozen=True, slots=True) +class OpenLoopStatusUpdateInput: + status: OpenLoopStatus + resolution_note: str | None = None + + def as_payload(self) -> JsonObject: + payload: JsonObject = { + "status": self.status, + } + payload["resolution_note"] = self.resolution_note + return payload + + +class ExtractedPreferenceCandidateRecord(TypedDict): + memory_key: str + value: JsonValue + source_event_ids: list[str] + delete_requested: bool + pattern: ExplicitPreferencePattern + subject_text: str + + +class ExtractedCommitmentCandidateRecord(TypedDict): + memory_key: str + value: JsonValue + source_event_ids: list[str] + delete_requested: bool + pattern: ExplicitCommitmentPattern + commitment_text: str + open_loop_title: str + + +@dataclass(frozen=True, slots=True) +class EntityCreateInput: + entity_type: EntityType + name: str + source_memory_ids: tuple[UUID, ...] + + def as_payload(self) -> JsonObject: + return { + "entity_type": self.entity_type, + "name": self.name, + "source_memory_ids": [str(source_memory_id) for source_memory_id in self.source_memory_ids], + } + + +@dataclass(frozen=True, slots=True) +class EntityEdgeCreateInput: + from_entity_id: UUID + to_entity_id: UUID + relationship_type: str + valid_from: datetime | None + valid_to: datetime | None + source_memory_ids: tuple[UUID, ...] + + def as_payload(self) -> JsonObject: + payload: JsonObject = { + "from_entity_id": str(self.from_entity_id), + "to_entity_id": str(self.to_entity_id), + "relationship_type": self.relationship_type, + "source_memory_ids": [str(source_memory_id) for source_memory_id in self.source_memory_ids], + } + payload["valid_from"] = isoformat_or_none(self.valid_from) + payload["valid_to"] = isoformat_or_none(self.valid_to) + return payload + + +@dataclass(frozen=True, slots=True) +class EmbeddingConfigCreateInput: + provider: str + model: str + version: str + dimensions: int + status: EmbeddingConfigStatus + metadata: JsonObject + + def as_payload(self) -> JsonObject: + return { + "provider": self.provider, + "model": self.model, + "version": self.version, + "dimensions": self.dimensions, + "status": self.status, + "metadata": self.metadata, + } + + +@dataclass(frozen=True, slots=True) +class MemoryEmbeddingUpsertInput: + memory_id: UUID + embedding_config_id: UUID + vector: tuple[float, ...] + + def as_payload(self) -> JsonObject: + return { + "memory_id": str(self.memory_id), + "embedding_config_id": str(self.embedding_config_id), + "vector": [float(value) for value in self.vector], + } + + +@dataclass(frozen=True, slots=True) +class TaskArtifactChunkEmbeddingUpsertInput: + task_artifact_chunk_id: UUID + embedding_config_id: UUID + vector: tuple[float, ...] + + def as_payload(self) -> JsonObject: + return { + "task_artifact_chunk_id": str(self.task_artifact_chunk_id), + "embedding_config_id": str(self.embedding_config_id), + "vector": [float(value) for value in self.vector], + } + + +@dataclass(frozen=True, slots=True) +class SemanticMemoryRetrievalRequestInput: + embedding_config_id: UUID + query_vector: tuple[float, ...] + limit: int = DEFAULT_SEMANTIC_MEMORY_RETRIEVAL_LIMIT + + def as_payload(self) -> JsonObject: + return { + "embedding_config_id": str(self.embedding_config_id), + "query_vector": [float(value) for value in self.query_vector], + "limit": self.limit, + } + + +@dataclass(frozen=True, slots=True) +class ConsentUpsertInput: + consent_key: str + status: ConsentStatus + metadata: JsonObject + + def as_payload(self) -> JsonObject: + return { + "consent_key": self.consent_key, + "status": self.status, + "metadata": self.metadata, + } + + +@dataclass(frozen=True, slots=True) +class PolicyCreateInput: + name: str + action: str + scope: str + effect: PolicyEffect + priority: int + active: bool + conditions: JsonObject + required_consents: tuple[str, ...] + agent_profile_id: str | None = None + + def as_payload(self) -> JsonObject: + payload: JsonObject = { + "name": self.name, + "action": self.action, + "scope": self.scope, + "effect": self.effect, + "priority": self.priority, + "active": self.active, + "conditions": self.conditions, + "required_consents": list(self.required_consents), + } + if self.agent_profile_id is not None: + payload["agent_profile_id"] = self.agent_profile_id + return payload + + +@dataclass(frozen=True, slots=True) +class PolicyEvaluationRequestInput: + thread_id: UUID + action: str + scope: str + attributes: JsonObject + + def as_payload(self) -> JsonObject: + return { + "thread_id": str(self.thread_id), + "action": self.action, + "scope": self.scope, + "attributes": self.attributes, + } + + +@dataclass(frozen=True, slots=True) +class ToolCreateInput: + tool_key: str + name: str + description: str + version: str + metadata_version: ToolMetadataVersion = TOOL_METADATA_VERSION_V0 + active: bool = True + tags: tuple[str, ...] = field(default_factory=tuple) + action_hints: tuple[str, ...] = field(default_factory=tuple) + scope_hints: tuple[str, ...] = field(default_factory=tuple) + domain_hints: tuple[str, ...] = field(default_factory=tuple) + risk_hints: tuple[str, ...] = field(default_factory=tuple) + metadata: JsonObject = field(default_factory=dict) + + def as_payload(self) -> JsonObject: + return { + "tool_key": self.tool_key, + "name": self.name, + "description": self.description, + "version": self.version, + "metadata_version": self.metadata_version, + "active": self.active, + "tags": list(self.tags), + "action_hints": list(self.action_hints), + "scope_hints": list(self.scope_hints), + "domain_hints": list(self.domain_hints), + "risk_hints": list(self.risk_hints), + "metadata": self.metadata, + } + + +@dataclass(frozen=True, slots=True) +class ToolAllowlistEvaluationRequestInput: + thread_id: UUID + action: str + scope: str + domain_hint: str | None = None + risk_hint: str | None = None + attributes: JsonObject = field(default_factory=dict) + + def as_payload(self) -> JsonObject: + payload: JsonObject = { + "thread_id": str(self.thread_id), + "action": self.action, + "scope": self.scope, + "attributes": self.attributes, + } + payload["domain_hint"] = self.domain_hint + payload["risk_hint"] = self.risk_hint + return payload + + +@dataclass(frozen=True, slots=True) +class ToolRoutingRequestInput: + thread_id: UUID + tool_id: UUID + action: str + scope: str + domain_hint: str | None = None + risk_hint: str | None = None + attributes: JsonObject = field(default_factory=dict) + + def as_payload(self) -> JsonObject: + payload: JsonObject = { + "thread_id": str(self.thread_id), + "tool_id": str(self.tool_id), + "action": self.action, + "scope": self.scope, + "attributes": self.attributes, + } + payload["domain_hint"] = self.domain_hint + payload["risk_hint"] = self.risk_hint + return payload + + +@dataclass(frozen=True, slots=True) +class ApprovalRequestCreateInput: + thread_id: UUID + tool_id: UUID + action: str + scope: str + task_run_id: UUID | None = None + domain_hint: str | None = None + risk_hint: str | None = None + attributes: JsonObject = field(default_factory=dict) + + def as_payload(self) -> JsonObject: + payload: JsonObject = { + "thread_id": str(self.thread_id), + "tool_id": str(self.tool_id), + "action": self.action, + "scope": self.scope, + "attributes": self.attributes, + } + payload["task_run_id"] = None if self.task_run_id is None else str(self.task_run_id) + payload["domain_hint"] = self.domain_hint + payload["risk_hint"] = self.risk_hint + return payload + + +@dataclass(frozen=True, slots=True) +class ApprovalApproveInput: + approval_id: UUID + + def as_payload(self) -> JsonObject: + return { + "approval_id": str(self.approval_id), + "requested_action": "approve", + } + + +@dataclass(frozen=True, slots=True) +class ApprovalRejectInput: + approval_id: UUID + + def as_payload(self) -> JsonObject: + return { + "approval_id": str(self.approval_id), + "requested_action": "reject", + } + + +@dataclass(frozen=True, slots=True) +class ProxyExecutionRequestInput: + approval_id: UUID + task_run_id: UUID | None = None + + def as_payload(self) -> JsonObject: + payload: JsonObject = { + "approval_id": str(self.approval_id), + } + payload["task_run_id"] = None if self.task_run_id is None else str(self.task_run_id) + return payload + + +@dataclass(frozen=True, slots=True) +class ExecutionBudgetCreateInput: + max_completed_executions: int + tool_key: str | None = None + domain_hint: str | None = None + rolling_window_seconds: int | None = None + agent_profile_id: str | None = None + + def as_payload(self) -> JsonObject: + payload: JsonObject = { + "max_completed_executions": self.max_completed_executions, + } + payload["tool_key"] = self.tool_key + payload["domain_hint"] = self.domain_hint + payload["rolling_window_seconds"] = self.rolling_window_seconds + payload["agent_profile_id"] = self.agent_profile_id + return payload + + +@dataclass(frozen=True, slots=True) +class ExecutionBudgetDeactivateInput: + thread_id: UUID + execution_budget_id: UUID + + def as_payload(self) -> JsonObject: + return { + "thread_id": str(self.thread_id), + "execution_budget_id": str(self.execution_budget_id), + "requested_action": "deactivate", + } + + +@dataclass(frozen=True, slots=True) +class ExecutionBudgetSupersedeInput: + thread_id: UUID + execution_budget_id: UUID + max_completed_executions: int + + def as_payload(self) -> JsonObject: + return { + "thread_id": str(self.thread_id), + "execution_budget_id": str(self.execution_budget_id), + "requested_action": "supersede", + "max_completed_executions": self.max_completed_executions, + } + + +class PersistedMemoryRecord(TypedDict): + id: str + user_id: str + memory_key: str + value: JsonValue + status: MemoryStatus + source_event_ids: list[str] + memory_type: NotRequired[MemoryType] + confidence: NotRequired[float | None] + salience: NotRequired[float | None] + confirmation_status: NotRequired[MemoryConfirmationStatus] + trust_class: NotRequired[MemoryTrustClass] + promotion_eligibility: NotRequired[MemoryPromotionEligibility] + evidence_count: NotRequired[int | None] + independent_source_count: NotRequired[int | None] + extracted_by_model: NotRequired[str | None] + trust_reason: NotRequired[str | None] + valid_from: NotRequired[str | None] + valid_to: NotRequired[str | None] + last_confirmed_at: NotRequired[str | None] + created_at: str + updated_at: str + deleted_at: str | None + + +class PersistedMemoryRevisionRecord(TypedDict): + id: str + user_id: str + memory_id: str + sequence_no: int + action: AdmissionAction + memory_key: str + previous_value: JsonValue | None + new_value: JsonValue | None + source_event_ids: list[str] + candidate: JsonObject + created_at: str + + +@dataclass(frozen=True, slots=True) +class AdmissionDecisionOutput: + action: AdmissionAction + reason: str + memory: PersistedMemoryRecord | None + revision: PersistedMemoryRevisionRecord | None + open_loop: OpenLoopRecord | None = None + + +class ExplicitPreferenceAdmissionRecord(TypedDict): + decision: AdmissionAction + reason: str + memory: PersistedMemoryRecord | None + revision: PersistedMemoryRevisionRecord | None + + +class ExplicitPreferenceExtractionSummary(TypedDict): + source_event_id: str + source_event_kind: str + candidate_count: int + admission_count: int + persisted_change_count: int + noop_count: int + + +class ExplicitPreferenceExtractionResponse(TypedDict): + candidates: list[ExtractedPreferenceCandidateRecord] + admissions: list[ExplicitPreferenceAdmissionRecord] + summary: ExplicitPreferenceExtractionSummary + + +class ExplicitCommitmentOpenLoopOutcome(TypedDict): + decision: ExplicitCommitmentOpenLoopDecision + reason: str + open_loop: OpenLoopRecord | None + + +class ExplicitCommitmentAdmissionRecord(TypedDict): + decision: AdmissionAction + reason: str + memory: PersistedMemoryRecord | None + revision: PersistedMemoryRevisionRecord | None + open_loop: ExplicitCommitmentOpenLoopOutcome + + +class ExplicitCommitmentExtractionSummary(TypedDict): + source_event_id: str + source_event_kind: str + candidate_count: int + admission_count: int + persisted_change_count: int + noop_count: int + open_loop_created_count: int + open_loop_noop_count: int + + +class ExplicitCommitmentExtractionResponse(TypedDict): + candidates: list[ExtractedCommitmentCandidateRecord] + admissions: list[ExplicitCommitmentAdmissionRecord] + summary: ExplicitCommitmentExtractionSummary + + +class ExplicitSignalCaptureSummary(TypedDict): + source_event_id: str + source_event_kind: str + candidate_count: int + admission_count: int + persisted_change_count: int + noop_count: int + open_loop_created_count: int + open_loop_noop_count: int + preference_candidate_count: int + preference_admission_count: int + commitment_candidate_count: int + commitment_admission_count: int + + +class ExplicitSignalCaptureResponse(TypedDict): + preferences: ExplicitPreferenceExtractionResponse + commitments: ExplicitCommitmentExtractionResponse + summary: ExplicitSignalCaptureSummary + + +class ContinuityCaptureEventRecord(TypedDict): + id: str + raw_content: str + explicit_signal: ContinuityCaptureExplicitSignal | None + admission_posture: ContinuityCaptureAdmissionPosture + admission_reason: str + created_at: str + + +class ContinuityLifecycleStateRecord(TypedDict): + is_preserved: bool + preservation_status: ContinuityPreservationStatus + is_searchable: bool + searchability_status: ContinuitySearchabilityStatus + is_promotable: bool + promotion_status: ContinuityPromotionStatus + + +class ContinuityObjectRecord(TypedDict): + id: str + capture_event_id: str + object_type: ContinuityObjectType + status: str + lifecycle: ContinuityLifecycleStateRecord + title: str + body: JsonObject + provenance: JsonObject + confidence: float + created_at: str + updated_at: str + + +class ContinuityReviewObjectRecord(TypedDict): + id: str + capture_event_id: str + object_type: ContinuityObjectType + status: str + lifecycle: ContinuityLifecycleStateRecord + title: str + body: JsonObject + provenance: JsonObject + confidence: float + last_confirmed_at: str | None + supersedes_object_id: str | None + superseded_by_object_id: str | None + created_at: str + updated_at: str + + +class ContinuityCorrectionEventRecord(TypedDict): + id: str + continuity_object_id: str + action: ContinuityCorrectionAction + reason: str | None + before_snapshot: JsonObject + after_snapshot: JsonObject + payload: JsonObject + created_at: str + + +class ContinuityCaptureInboxItem(TypedDict): + capture_event: ContinuityCaptureEventRecord + derived_object: ContinuityObjectRecord | None + + +class ContinuityCaptureInboxSummary(TypedDict): + limit: int + returned_count: int + total_count: int + derived_count: int + triage_count: int + order: list[str] + + +class ContinuityCaptureCreateResponse(TypedDict): + capture: ContinuityCaptureInboxItem + + +class ContinuityCaptureInboxResponse(TypedDict): + items: list[ContinuityCaptureInboxItem] + summary: ContinuityCaptureInboxSummary + + +class ContinuityCaptureDetailResponse(TypedDict): + capture: ContinuityCaptureInboxItem + + +class ContinuityReviewQueueSummary(TypedDict): + status: ContinuityReviewStatusFilter + limit: int + returned_count: int + total_count: int + order: list[str] + + +class ContinuityReviewQueueResponse(TypedDict): + items: list[ContinuityReviewObjectRecord] + summary: ContinuityReviewQueueSummary + + +class ContinuitySupersessionChain(TypedDict): + supersedes: ContinuityReviewObjectRecord | None + superseded_by: ContinuityReviewObjectRecord | None + + +class ContinuityReviewDetail(TypedDict): + continuity_object: ContinuityReviewObjectRecord + correction_events: list[ContinuityCorrectionEventRecord] + supersession_chain: ContinuitySupersessionChain + + +class ContinuityReviewDetailResponse(TypedDict): + review: ContinuityReviewDetail + + +class ContinuityRecallScopeFilters(TypedDict): + thread_id: NotRequired[str] + task_id: NotRequired[str] + project: NotRequired[str] + person: NotRequired[str] + since: str | None + until: str | None + + +class ContinuityRecallScopeMatch(TypedDict): + kind: ContinuityRecallScopeKind + value: str + + +class ContinuityRecallProvenanceReference(TypedDict): + source_kind: str + source_id: str + + +class ContinuityRecallOrderingMetadata(TypedDict): + scope_match_count: int + query_term_match_count: int + confirmation_rank: int + freshness_posture: ContinuityRecallFreshnessPosture + freshness_rank: int + provenance_posture: ContinuityRecallProvenancePosture + provenance_rank: int + supersession_posture: ContinuityRecallSupersessionPosture + supersession_rank: int + posture_rank: int + lifecycle_rank: int + confidence: float + + +class ContinuityRecallResultRecord(TypedDict): + id: str + capture_event_id: str + object_type: ContinuityObjectType + status: str + lifecycle: ContinuityLifecycleStateRecord + title: str + body: JsonObject + provenance: JsonObject + confirmation_status: MemoryConfirmationStatus + admission_posture: ContinuityCaptureAdmissionPosture + confidence: float + relevance: float + last_confirmed_at: str | None + supersedes_object_id: str | None + superseded_by_object_id: str | None + scope_matches: list[ContinuityRecallScopeMatch] + provenance_references: list[ContinuityRecallProvenanceReference] + ordering: ContinuityRecallOrderingMetadata + created_at: str + updated_at: str + + +class ContinuityRecallSummary(TypedDict): + query: str | None + filters: ContinuityRecallScopeFilters + limit: int + returned_count: int + total_count: int + order: list[str] + + +class ContinuityRecallResponse(TypedDict): + items: list[ContinuityRecallResultRecord] + summary: ContinuityRecallSummary + + +class ContinuityLifecycleCounts(TypedDict): + preserved_count: int + searchable_count: int + promotable_count: int + not_searchable_count: int + not_promotable_count: int + + +class ContinuityLifecycleListSummary(TypedDict): + limit: int + returned_count: int + total_count: int + counts: ContinuityLifecycleCounts + order: list[str] + + +class ContinuityLifecycleListResponse(TypedDict): + items: list[ContinuityReviewObjectRecord] + summary: ContinuityLifecycleListSummary + + +class ContinuityLifecycleDetailResponse(TypedDict): + continuity_object: ContinuityReviewObjectRecord + + +class ContinuityResumptionEmptyState(TypedDict): + is_empty: bool + message: str + + +class ContinuityResumptionSingleSection(TypedDict): + item: ContinuityRecallResultRecord | None + empty_state: ContinuityResumptionEmptyState + + +class ContinuityResumptionListSection(TypedDict): + items: list[ContinuityRecallResultRecord] + summary: ResumptionBriefSectionSummary + empty_state: ContinuityResumptionEmptyState + + +class ContinuityResumptionBriefRecord(TypedDict): + assembly_version: str + scope: ContinuityRecallScopeFilters + last_decision: ContinuityResumptionSingleSection + open_loops: ContinuityResumptionListSection + recent_changes: ContinuityResumptionListSection + next_action: ContinuityResumptionSingleSection + sources: list[str] + + +class ContinuityResumptionBriefResponse(TypedDict): + brief: ContinuityResumptionBriefRecord + + +class ContinuityOpenLoopSectionSummary(TypedDict): + limit: int + returned_count: int + total_count: int + order: list[str] + + +class ContinuityOpenLoopSection(TypedDict): + items: list[ContinuityRecallResultRecord] + summary: ContinuityOpenLoopSectionSummary + empty_state: ContinuityResumptionEmptyState + + +class ContinuityOpenLoopDashboardSummary(TypedDict): + limit: int + total_count: int + posture_order: list[ContinuityOpenLoopPosture] + item_order: list[str] + + +class ContinuityOpenLoopDashboardRecord(TypedDict): + scope: ContinuityRecallScopeFilters + waiting_for: ContinuityOpenLoopSection + blocker: ContinuityOpenLoopSection + stale: ContinuityOpenLoopSection + next_action: ContinuityOpenLoopSection + summary: ContinuityOpenLoopDashboardSummary + sources: list[str] + + +class ContinuityOpenLoopDashboardResponse(TypedDict): + dashboard: ContinuityOpenLoopDashboardRecord + + +class ContinuityDailyBriefRecord(TypedDict): + assembly_version: str + scope: ContinuityRecallScopeFilters + waiting_for_highlights: ContinuityOpenLoopSection + blocker_highlights: ContinuityOpenLoopSection + stale_items: ContinuityOpenLoopSection + next_suggested_action: ContinuityResumptionSingleSection + sources: list[str] + + +class ContinuityDailyBriefResponse(TypedDict): + brief: ContinuityDailyBriefRecord + + +class ContinuityWeeklyReviewRollup(TypedDict): + total_count: int + waiting_for_count: int + blocker_count: int + stale_count: int + correction_recurrence_count: int + freshness_drift_count: int + next_action_count: int + posture_order: list[ContinuityOpenLoopPosture] + + +class ContinuityWeeklyReviewRecord(TypedDict): + assembly_version: str + scope: ContinuityRecallScopeFilters + rollup: ContinuityWeeklyReviewRollup + waiting_for: ContinuityOpenLoopSection + blocker: ContinuityOpenLoopSection + stale: ContinuityOpenLoopSection + next_action: ContinuityOpenLoopSection + sources: list[str] + + +class ContinuityWeeklyReviewResponse(TypedDict): + review: ContinuityWeeklyReviewRecord + + +class ChiefOfStaffPriorityRankingInputs(TypedDict): + posture: ChiefOfStaffPriorityPosture + open_loop_posture: ContinuityOpenLoopPosture | None + recency_rank: int | None + age_hours_relative_to_latest: float + recall_relevance: float + scope_match_count: int + query_term_match_count: int + freshness_posture: ContinuityRecallFreshnessPosture + provenance_posture: ContinuityRecallProvenancePosture + supersession_posture: ContinuityRecallSupersessionPosture + + +class ChiefOfStaffPriorityTrustSignals(TypedDict): + quality_gate_status: MemoryQualityGateStatus + retrieval_status: RetrievalEvaluationStatus + trust_confidence_cap: ChiefOfStaffRecommendationConfidencePosture + downgraded_by_trust: bool + reason: str + + +class ChiefOfStaffPriorityRationale(TypedDict): + reasons: list[str] + ranking_inputs: ChiefOfStaffPriorityRankingInputs + provenance_references: list[ContinuityRecallProvenanceReference] + trust_signals: ChiefOfStaffPriorityTrustSignals + + +class ChiefOfStaffPriorityItem(TypedDict): + rank: int + id: str + capture_event_id: str + object_type: ContinuityObjectType + status: str + title: str + priority_posture: ChiefOfStaffPriorityPosture + confidence_posture: ChiefOfStaffRecommendationConfidencePosture + confidence: float + score: float + provenance: JsonObject + created_at: str + updated_at: str + rationale: ChiefOfStaffPriorityRationale + + +class ChiefOfStaffFollowThroughItem(TypedDict): + rank: int + id: str + capture_event_id: str + object_type: ContinuityObjectType + status: str + title: str + current_priority_posture: ChiefOfStaffPriorityPosture + follow_through_posture: ChiefOfStaffFollowThroughPosture + recommendation_action: ChiefOfStaffFollowThroughRecommendationAction + reason: str + age_hours: float + provenance_references: list[ContinuityRecallProvenanceReference] + created_at: str + updated_at: str + + +class ChiefOfStaffEscalationPostureRecord(TypedDict): + posture: ChiefOfStaffEscalationPosture + reason: str + total_follow_through_count: int + nudge_count: int + defer_count: int + escalate_count: int + close_loop_candidate_count: int + + +class ChiefOfStaffDraftFollowUpTargetMetadata(TypedDict): + continuity_object_id: str | None + capture_event_id: str | None + object_type: ContinuityObjectType | None + priority_posture: ChiefOfStaffPriorityPosture | None + follow_through_posture: ChiefOfStaffFollowThroughPosture | None + recommendation_action: ChiefOfStaffFollowThroughRecommendationAction | None + thread_id: str | None + + +class ChiefOfStaffDraftFollowUpContent(TypedDict): + subject: str + body: str + + +class ChiefOfStaffDraftFollowUpRecord(TypedDict): + status: Literal["drafted", "none"] + mode: Literal["draft_only"] + approval_required: bool + auto_send: bool + reason: str + target_metadata: ChiefOfStaffDraftFollowUpTargetMetadata + content: ChiefOfStaffDraftFollowUpContent + + +class ChiefOfStaffRecommendedNextAction(TypedDict): + action_type: ChiefOfStaffRecommendedActionType + title: str + target_priority_id: str | None + priority_posture: ChiefOfStaffPriorityPosture | None + confidence_posture: ChiefOfStaffRecommendationConfidencePosture + reason: str + provenance_references: list[ContinuityRecallProvenanceReference] + deterministic_rank_key: str + + +class ChiefOfStaffActionHandoffRequestTarget(TypedDict): + thread_id: str | None + task_id: str | None + project: str | None + person: str | None + + +class ChiefOfStaffActionHandoffRequestDraft(TypedDict): + action: str + scope: str + domain_hint: str | None + risk_hint: str | None + attributes: JsonObject + + +class ChiefOfStaffActionHandoffTaskDraftRecord(TypedDict): + status: Literal["draft"] + mode: Literal["governed_request_draft"] + approval_required: bool + auto_execute: bool + source_handoff_item_id: str + title: str + summary: str + target: ChiefOfStaffActionHandoffRequestTarget + request: ChiefOfStaffActionHandoffRequestDraft + rationale: str + provenance_references: list[ContinuityRecallProvenanceReference] + + +class ChiefOfStaffActionHandoffApprovalDraftRecord(TypedDict): + status: Literal["draft_only"] + mode: Literal["approval_request_draft"] + decision: ToolRoutingDecision + approval_required: bool + auto_submit: bool + source_handoff_item_id: str + request: ChiefOfStaffActionHandoffRequestDraft + reason: str + required_checks: list[str] + provenance_references: list[ContinuityRecallProvenanceReference] + + +class ChiefOfStaffActionHandoffItem(TypedDict): + rank: int + handoff_item_id: str + source_kind: ChiefOfStaffActionHandoffSourceKind + source_reference_id: str | None + title: str + recommendation_action: ChiefOfStaffActionHandoffAction + priority_posture: ChiefOfStaffPriorityPosture | None + confidence_posture: ChiefOfStaffRecommendationConfidencePosture + rationale: str + provenance_references: list[ContinuityRecallProvenanceReference] + score: float + task_draft: ChiefOfStaffActionHandoffTaskDraftRecord + approval_draft: ChiefOfStaffActionHandoffApprovalDraftRecord + + +class ChiefOfStaffActionHandoffBriefRecord(TypedDict): + summary: str + confidence_posture: ChiefOfStaffRecommendationConfidencePosture + non_autonomous_guarantee: str + order: list[str] + source_order: list[ChiefOfStaffActionHandoffSourceKind] + provenance_references: list[ContinuityRecallProvenanceReference] + + +class ChiefOfStaffExecutionPostureRecord(TypedDict): + posture: ChiefOfStaffExecutionPosture + approval_required: bool + autonomous_execution: bool + external_side_effects_allowed: bool + default_routing_decision: ToolRoutingDecision + required_operator_actions: list[str] + non_autonomous_guarantee: str + reason: str + + +class ChiefOfStaffExecutionReadinessPostureRecord(TypedDict): + posture: ChiefOfStaffExecutionReadinessPosture + approval_required: bool + autonomous_execution: bool + external_side_effects_allowed: bool + approval_path_visible: bool + route_target_order: list[ChiefOfStaffExecutionRouteTarget] + required_route_targets: list[ChiefOfStaffExecutionRouteTarget] + transition_order: list[ChiefOfStaffExecutionRoutingTransition] + non_autonomous_guarantee: str + reason: str + + +class ChiefOfStaffExecutionRoutingAuditRecord(TypedDict): + id: str + capture_event_id: str + handoff_item_id: str + route_target: ChiefOfStaffExecutionRouteTarget + transition: ChiefOfStaffExecutionRoutingTransition + previously_routed: bool + route_state: bool + reason: str + note: str | None + provenance_references: list[ContinuityRecallProvenanceReference] + created_at: str + updated_at: str + + +class ChiefOfStaffRoutedHandoffItemRecord(TypedDict): + handoff_rank: int + handoff_item_id: str + title: str + source_kind: ChiefOfStaffActionHandoffSourceKind + recommendation_action: ChiefOfStaffActionHandoffAction + route_target_order: list[ChiefOfStaffExecutionRouteTarget] + available_route_targets: list[ChiefOfStaffExecutionRouteTarget] + routed_targets: list[ChiefOfStaffExecutionRouteTarget] + is_routed: bool + task_workflow_draft_routed: bool + approval_workflow_draft_routed: bool + follow_up_draft_only_routed: bool + follow_up_draft_only_applicable: bool + task_draft: ChiefOfStaffActionHandoffTaskDraftRecord + approval_draft: ChiefOfStaffActionHandoffApprovalDraftRecord + follow_up_draft: NotRequired[ChiefOfStaffDraftFollowUpRecord] + last_routing_transition: ChiefOfStaffExecutionRoutingAuditRecord | None + + +class ChiefOfStaffExecutionRoutingSummary(TypedDict): + total_handoff_count: int + routed_handoff_count: int + unrouted_handoff_count: int + task_workflow_draft_count: int + approval_workflow_draft_count: int + follow_up_draft_only_count: int + route_target_order: list[ChiefOfStaffExecutionRouteTarget] + routed_item_order: list[str] + audit_order: list[str] + transition_order: list[ChiefOfStaffExecutionRoutingTransition] + approval_required: bool + non_autonomous_guarantee: str + reason: str + + +class ChiefOfStaffHandoffReviewActionRecord(TypedDict): + id: str + capture_event_id: str + handoff_item_id: str + review_action: ChiefOfStaffHandoffReviewAction + previous_lifecycle_state: ChiefOfStaffHandoffQueueLifecycleState | None + next_lifecycle_state: ChiefOfStaffHandoffQueueLifecycleState + reason: str + note: str | None + provenance_references: list[ContinuityRecallProvenanceReference] + created_at: str + updated_at: str + + +class ChiefOfStaffHandoffOutcomeRecord(TypedDict): + id: str + capture_event_id: str + handoff_item_id: str + outcome_status: ChiefOfStaffHandoffOutcomeStatus + previous_outcome_status: ChiefOfStaffHandoffOutcomeStatus | None + is_latest_outcome: bool + reason: str + note: str | None + provenance_references: list[ContinuityRecallProvenanceReference] + created_at: str + updated_at: str + + +class ChiefOfStaffHandoffOutcomeSummary(TypedDict): + returned_count: int + total_count: int + latest_total_count: int + status_counts: dict[ChiefOfStaffHandoffOutcomeStatus, int] + latest_status_counts: dict[ChiefOfStaffHandoffOutcomeStatus, int] + status_order: list[ChiefOfStaffHandoffOutcomeStatus] + order: list[str] + + +class ChiefOfStaffClosureQualitySummaryRecord(TypedDict): + posture: ChiefOfStaffClosureQualityPosture + reason: str + closed_loop_count: int + unresolved_count: int + rejected_count: int + ignored_count: int + expired_count: int + closure_rate: float + explanation: str + + +class ChiefOfStaffConversionSignalSummaryRecord(TypedDict): + total_handoff_count: int + latest_outcome_count: int + executed_count: int + approved_count: int + reviewed_count: int + rewritten_count: int + rejected_count: int + ignored_count: int + expired_count: int + recommendation_to_execution_conversion_rate: float + recommendation_to_closure_conversion_rate: float + capture_coverage_rate: float + explanation: str + + +class ChiefOfStaffStaleIgnoredEscalationPostureRecord(TypedDict): + posture: ChiefOfStaffEscalationPosture + reason: str + stale_queue_count: int + ignored_count: int + expired_count: int + trigger_count: int + guidance_posture_explanation: str + supporting_signals: list[str] + + +class ChiefOfStaffHandoffQueueItem(TypedDict): + queue_rank: int + handoff_rank: int + handoff_item_id: str + lifecycle_state: ChiefOfStaffHandoffQueueLifecycleState + state_reason: str + source_kind: ChiefOfStaffActionHandoffSourceKind + source_reference_id: str | None + title: str + recommendation_action: ChiefOfStaffActionHandoffAction + priority_posture: ChiefOfStaffPriorityPosture | None + confidence_posture: ChiefOfStaffRecommendationConfidencePosture + score: float + age_hours_relative_to_latest: float | None + review_action_order: list[ChiefOfStaffHandoffReviewAction] + available_review_actions: list[ChiefOfStaffHandoffReviewAction] + last_review_action: ChiefOfStaffHandoffReviewActionRecord | None + provenance_references: list[ContinuityRecallProvenanceReference] + + +class ChiefOfStaffHandoffQueueGroupSummary(TypedDict): + lifecycle_state: ChiefOfStaffHandoffQueueLifecycleState + returned_count: int + total_count: int + order: list[str] + + +class ChiefOfStaffHandoffQueueGroupEmptyState(TypedDict): + is_empty: bool + message: str + + +class ChiefOfStaffHandoffQueueGroup(TypedDict): + items: list[ChiefOfStaffHandoffQueueItem] + summary: ChiefOfStaffHandoffQueueGroupSummary + empty_state: ChiefOfStaffHandoffQueueGroupEmptyState + + +class ChiefOfStaffHandoffQueueGroups(TypedDict): + ready: ChiefOfStaffHandoffQueueGroup + pending_approval: ChiefOfStaffHandoffQueueGroup + executed: ChiefOfStaffHandoffQueueGroup + stale: ChiefOfStaffHandoffQueueGroup + expired: ChiefOfStaffHandoffQueueGroup + + +class ChiefOfStaffHandoffQueueSummary(TypedDict): + total_count: int + ready_count: int + pending_approval_count: int + executed_count: int + stale_count: int + expired_count: int + state_order: list[ChiefOfStaffHandoffQueueLifecycleState] + group_order: list[ChiefOfStaffHandoffQueueLifecycleState] + item_order: list[str] + review_action_order: list[ChiefOfStaffHandoffReviewAction] + + +class ChiefOfStaffPrioritySummary(TypedDict): + limit: int + returned_count: int + total_count: int + posture_order: list[ChiefOfStaffPriorityPosture] + order: list[str] + follow_through_posture_order: list[ChiefOfStaffFollowThroughPosture] + follow_through_item_order: list[str] + follow_through_total_count: int + overdue_count: int + stale_waiting_for_count: int + slipped_commitment_count: int + trust_confidence_posture: ChiefOfStaffRecommendationConfidencePosture + trust_confidence_reason: str + quality_gate_status: MemoryQualityGateStatus + retrieval_status: RetrievalEvaluationStatus + handoff_item_count: int + handoff_item_order: list[str] + execution_posture_order: list[ChiefOfStaffExecutionPosture] + handoff_queue_total_count: int + handoff_queue_ready_count: int + handoff_queue_pending_approval_count: int + handoff_queue_executed_count: int + handoff_queue_stale_count: int + handoff_queue_expired_count: int + handoff_queue_state_order: list[ChiefOfStaffHandoffQueueLifecycleState] + handoff_queue_group_order: list[ChiefOfStaffHandoffQueueLifecycleState] + handoff_queue_item_order: list[str] + handoff_outcome_total_count: int + handoff_outcome_latest_count: int + handoff_outcome_executed_count: int + handoff_outcome_ignored_count: int + closure_quality_posture: ChiefOfStaffClosureQualityPosture + stale_ignored_escalation_posture: ChiefOfStaffEscalationPosture + + +class ChiefOfStaffPreparationArtifactItem(TypedDict): + rank: int + id: str + capture_event_id: str + object_type: ContinuityObjectType + status: str + title: str + reason: str + confidence_posture: ChiefOfStaffRecommendationConfidencePosture + provenance_references: list[ContinuityRecallProvenanceReference] + created_at: str + + +class ChiefOfStaffPreparationSectionSummary(TypedDict): + limit: int + returned_count: int + total_count: int + order: list[str] + + +class ChiefOfStaffPreparationBriefRecord(TypedDict): + scope: ContinuityRecallScopeFilters + context_items: list[ChiefOfStaffPreparationArtifactItem] + last_decision: ChiefOfStaffPreparationArtifactItem | None + open_loops: list[ChiefOfStaffPreparationArtifactItem] + next_action: ChiefOfStaffPreparationArtifactItem | None + confidence_posture: ChiefOfStaffRecommendationConfidencePosture + confidence_reason: str + summary: ChiefOfStaffPreparationSectionSummary + + +class ChiefOfStaffWhatChangedSummaryRecord(TypedDict): + items: list[ChiefOfStaffPreparationArtifactItem] + confidence_posture: ChiefOfStaffRecommendationConfidencePosture + confidence_reason: str + summary: ChiefOfStaffPreparationSectionSummary + + +class ChiefOfStaffPrepChecklistRecord(TypedDict): + items: list[ChiefOfStaffPreparationArtifactItem] + confidence_posture: ChiefOfStaffRecommendationConfidencePosture + confidence_reason: str + summary: ChiefOfStaffPreparationSectionSummary + + +class ChiefOfStaffSuggestedTalkingPointsRecord(TypedDict): + items: list[ChiefOfStaffPreparationArtifactItem] + confidence_posture: ChiefOfStaffRecommendationConfidencePosture + confidence_reason: str + summary: ChiefOfStaffPreparationSectionSummary + + +class ChiefOfStaffResumptionSupervisionRecommendation(TypedDict): + rank: int + action: ChiefOfStaffResumptionRecommendationAction + title: str + reason: str + confidence_posture: ChiefOfStaffRecommendationConfidencePosture + target_priority_id: str | None + provenance_references: list[ContinuityRecallProvenanceReference] + + +class ChiefOfStaffResumptionSupervisionRecord(TypedDict): + recommendations: list[ChiefOfStaffResumptionSupervisionRecommendation] + confidence_posture: ChiefOfStaffRecommendationConfidencePosture + confidence_reason: str + summary: ChiefOfStaffPreparationSectionSummary + + +class ChiefOfStaffWeeklyReviewGuidanceItem(TypedDict): + rank: int + action: ChiefOfStaffWeeklyReviewGuidanceAction + signal_count: int + rationale: str + + +class ChiefOfStaffWeeklyReviewBriefSummary(TypedDict): + guidance_order: list[ChiefOfStaffWeeklyReviewGuidanceAction] + guidance_item_order: list[str] + + +class ChiefOfStaffWeeklyReviewBriefRecord(TypedDict): + scope: ContinuityRecallScopeFilters + rollup: ContinuityWeeklyReviewRollup + guidance: list[ChiefOfStaffWeeklyReviewGuidanceItem] + summary: ChiefOfStaffWeeklyReviewBriefSummary + + +class ChiefOfStaffRecommendationOutcomeRecord(TypedDict): + id: str + capture_event_id: str + outcome: ChiefOfStaffRecommendationOutcome + recommendation_action_type: ChiefOfStaffRecommendedActionType + recommendation_title: str + rewritten_title: str | None + target_priority_id: str | None + rationale: str | None + provenance_references: list[ContinuityRecallProvenanceReference] + created_at: str + updated_at: str + + +class ChiefOfStaffRecommendationOutcomeSummary(TypedDict): + returned_count: int + total_count: int + outcome_counts: dict[ChiefOfStaffRecommendationOutcome, int] + order: list[str] + + +class ChiefOfStaffRecommendationOutcomeSection(TypedDict): + items: list[ChiefOfStaffRecommendationOutcomeRecord] + summary: ChiefOfStaffRecommendationOutcomeSummary + + +class ChiefOfStaffOutcomeHotspotRecord(TypedDict): + key: str + count: int + + +class ChiefOfStaffPriorityLearningSummaryRecord(TypedDict): + total_count: int + accept_count: int + defer_count: int + ignore_count: int + rewrite_count: int + acceptance_rate: float + override_rate: float + defer_hotspots: list[ChiefOfStaffOutcomeHotspotRecord] + ignore_hotspots: list[ChiefOfStaffOutcomeHotspotRecord] + priority_shift_explanation: str + hotspot_order: list[str] + + +class ChiefOfStaffPatternDriftSummaryRecord(TypedDict): + posture: ChiefOfStaffPatternDriftPosture + reason: str + supporting_signals: list[str] + + +class ChiefOfStaffPriorityBriefRecord(TypedDict): + assembly_version: str + scope: ContinuityRecallScopeFilters + ranked_items: list[ChiefOfStaffPriorityItem] + overdue_items: list[ChiefOfStaffFollowThroughItem] + stale_waiting_for_items: list[ChiefOfStaffFollowThroughItem] + slipped_commitments: list[ChiefOfStaffFollowThroughItem] + escalation_posture: ChiefOfStaffEscalationPostureRecord + draft_follow_up: ChiefOfStaffDraftFollowUpRecord + recommended_next_action: ChiefOfStaffRecommendedNextAction + preparation_brief: ChiefOfStaffPreparationBriefRecord + what_changed_summary: ChiefOfStaffWhatChangedSummaryRecord + prep_checklist: ChiefOfStaffPrepChecklistRecord + suggested_talking_points: ChiefOfStaffSuggestedTalkingPointsRecord + resumption_supervision: ChiefOfStaffResumptionSupervisionRecord + weekly_review_brief: ChiefOfStaffWeeklyReviewBriefRecord + recommendation_outcomes: ChiefOfStaffRecommendationOutcomeSection + priority_learning_summary: ChiefOfStaffPriorityLearningSummaryRecord + pattern_drift_summary: ChiefOfStaffPatternDriftSummaryRecord + action_handoff_brief: ChiefOfStaffActionHandoffBriefRecord + handoff_items: list[ChiefOfStaffActionHandoffItem] + handoff_queue_summary: ChiefOfStaffHandoffQueueSummary + handoff_queue_groups: ChiefOfStaffHandoffQueueGroups + handoff_review_actions: list[ChiefOfStaffHandoffReviewActionRecord] + handoff_outcome_summary: ChiefOfStaffHandoffOutcomeSummary + handoff_outcomes: list[ChiefOfStaffHandoffOutcomeRecord] + closure_quality_summary: ChiefOfStaffClosureQualitySummaryRecord + conversion_signal_summary: ChiefOfStaffConversionSignalSummaryRecord + stale_ignored_escalation_posture: ChiefOfStaffStaleIgnoredEscalationPostureRecord + execution_routing_summary: ChiefOfStaffExecutionRoutingSummary + routed_handoff_items: list[ChiefOfStaffRoutedHandoffItemRecord] + routing_audit_trail: list[ChiefOfStaffExecutionRoutingAuditRecord] + execution_readiness_posture: ChiefOfStaffExecutionReadinessPostureRecord + task_draft: ChiefOfStaffActionHandoffTaskDraftRecord + approval_draft: ChiefOfStaffActionHandoffApprovalDraftRecord + execution_posture: ChiefOfStaffExecutionPostureRecord + summary: ChiefOfStaffPrioritySummary + sources: list[str] + + +class ChiefOfStaffPriorityBriefResponse(TypedDict): + brief: ChiefOfStaffPriorityBriefRecord + + +class ChiefOfStaffRecommendationOutcomeCaptureResponse(TypedDict): + outcome: ChiefOfStaffRecommendationOutcomeRecord + recommendation_outcomes: ChiefOfStaffRecommendationOutcomeSection + priority_learning_summary: ChiefOfStaffPriorityLearningSummaryRecord + pattern_drift_summary: ChiefOfStaffPatternDriftSummaryRecord + + +class ChiefOfStaffHandoffReviewActionCaptureResponse(TypedDict): + review_action: ChiefOfStaffHandoffReviewActionRecord + handoff_queue_summary: ChiefOfStaffHandoffQueueSummary + handoff_queue_groups: ChiefOfStaffHandoffQueueGroups + handoff_review_actions: list[ChiefOfStaffHandoffReviewActionRecord] + + +class ChiefOfStaffExecutionRoutingActionCaptureResponse(TypedDict): + routing_action: ChiefOfStaffExecutionRoutingAuditRecord + execution_routing_summary: ChiefOfStaffExecutionRoutingSummary + routed_handoff_items: list[ChiefOfStaffRoutedHandoffItemRecord] + routing_audit_trail: list[ChiefOfStaffExecutionRoutingAuditRecord] + execution_readiness_posture: ChiefOfStaffExecutionReadinessPostureRecord + + +class ChiefOfStaffHandoffOutcomeCaptureResponse(TypedDict): + handoff_outcome: ChiefOfStaffHandoffOutcomeRecord + handoff_outcome_summary: ChiefOfStaffHandoffOutcomeSummary + handoff_outcomes: list[ChiefOfStaffHandoffOutcomeRecord] + closure_quality_summary: ChiefOfStaffClosureQualitySummaryRecord + conversion_signal_summary: ChiefOfStaffConversionSignalSummaryRecord + stale_ignored_escalation_posture: ChiefOfStaffStaleIgnoredEscalationPostureRecord + + +class ContinuityOpenLoopReviewActionResponse(TypedDict): + continuity_object: ContinuityReviewObjectRecord + correction_event: ContinuityCorrectionEventRecord + review_action: ContinuityOpenLoopReviewAction + lifecycle_outcome: str + + +class ContinuityCorrectionApplyResponse(TypedDict): + continuity_object: ContinuityReviewObjectRecord + correction_event: ContinuityCorrectionEventRecord + replacement_object: ContinuityReviewObjectRecord | None + + +class MemoryReviewRecord(TypedDict): + id: str + memory_key: str + value: JsonValue + status: MemoryStatus + source_event_ids: list[str] + memory_type: NotRequired[MemoryType] + confidence: NotRequired[float | None] + salience: NotRequired[float | None] + confirmation_status: NotRequired[MemoryConfirmationStatus] + trust_class: NotRequired[MemoryTrustClass] + promotion_eligibility: NotRequired[MemoryPromotionEligibility] + evidence_count: NotRequired[int | None] + independent_source_count: NotRequired[int | None] + extracted_by_model: NotRequired[str | None] + trust_reason: NotRequired[str | None] + valid_from: NotRequired[str | None] + valid_to: NotRequired[str | None] + last_confirmed_at: NotRequired[str | None] + created_at: str + updated_at: str + deleted_at: str | None + + +class MemoryReviewListSummary(TypedDict): + status: MemoryReviewStatusFilter + limit: int + returned_count: int + total_count: int + has_more: bool + order: list[str] + + +class MemoryReviewListResponse(TypedDict): + items: list[MemoryReviewRecord] + summary: MemoryReviewListSummary + + +class MemoryReviewDetailResponse(TypedDict): + memory: MemoryReviewRecord + + +class OpenLoopRecord(TypedDict): + id: str + memory_id: str | None + title: str + status: OpenLoopStatus + opened_at: str + due_at: str | None + resolved_at: str | None + resolution_note: str | None + created_at: str + updated_at: str + + +class OpenLoopListSummary(TypedDict): + status: OpenLoopStatusFilter + limit: int + returned_count: int + total_count: int + has_more: bool + order: list[str] + + +class OpenLoopListResponse(TypedDict): + items: list[OpenLoopRecord] + summary: OpenLoopListSummary + + +class OpenLoopDetailResponse(TypedDict): + open_loop: OpenLoopRecord + + +class OpenLoopCreateResponse(TypedDict): + open_loop: OpenLoopRecord + + +class OpenLoopStatusUpdateResponse(TypedDict): + open_loop: OpenLoopRecord + + +class MemoryRevisionReviewRecord(TypedDict): + id: str + memory_id: str + sequence_no: int + action: AdmissionAction + memory_key: str + previous_value: JsonValue | None + new_value: JsonValue | None + source_event_ids: list[str] + created_at: str + + +class MemoryRevisionReviewListSummary(TypedDict): + memory_id: str + limit: int + returned_count: int + total_count: int + has_more: bool + order: list[str] + + +class MemoryRevisionReviewListResponse(TypedDict): + items: list[MemoryRevisionReviewRecord] + summary: MemoryRevisionReviewListSummary + + +class MemoryReviewLabelCounts(TypedDict): + correct: int + incorrect: int + outdated: int + insufficient_evidence: int + + +class MemoryReviewLabelRecord(TypedDict): + id: str + memory_id: str + reviewer_user_id: str + label: MemoryReviewLabelValue + note: str | None + created_at: str + + +class MemoryReviewLabelSummary(TypedDict): + memory_id: str + total_count: int + counts_by_label: MemoryReviewLabelCounts + order: list[str] + + +class MemoryReviewLabelCreateResponse(TypedDict): + label: MemoryReviewLabelRecord + summary: MemoryReviewLabelSummary + + +class MemoryReviewLabelListResponse(TypedDict): + items: list[MemoryReviewLabelRecord] + summary: MemoryReviewLabelSummary + + +class MemoryReviewQueueItem(TypedDict): + id: str + memory_key: str + value: JsonValue + status: Literal["active"] + source_event_ids: list[str] + memory_type: NotRequired[MemoryType] + confidence: NotRequired[float | None] + salience: NotRequired[float | None] + confirmation_status: NotRequired[MemoryConfirmationStatus] + trust_class: NotRequired[MemoryTrustClass] + promotion_eligibility: NotRequired[MemoryPromotionEligibility] + evidence_count: NotRequired[int | None] + independent_source_count: NotRequired[int | None] + extracted_by_model: NotRequired[str | None] + trust_reason: NotRequired[str | None] + valid_from: NotRequired[str | None] + valid_to: NotRequired[str | None] + last_confirmed_at: NotRequired[str | None] + is_high_risk: bool + is_stale_truth: bool + is_promotable: bool + queue_priority_mode: MemoryReviewQueuePriorityMode + priority_reason: str + created_at: str + updated_at: str + + +class MemoryReviewQueueSummary(TypedDict): + memory_status: Literal["active"] + review_state: Literal["unlabeled"] + priority_mode: MemoryReviewQueuePriorityMode + available_priority_modes: list[MemoryReviewQueuePriorityMode] + limit: int + returned_count: int + total_count: int + has_more: bool + order: list[str] + + +class MemoryReviewQueueResponse(TypedDict): + items: list[MemoryReviewQueueItem] + summary: MemoryReviewQueueSummary + + +class MemoryQualityGateComputationCounts(TypedDict): + active_memory_count: int + labeled_active_memory_count: int + adjudicated_correct_count: int + adjudicated_incorrect_count: int + outdated_label_count: int + insufficient_evidence_label_count: int + + +class MemoryQualityGateSummary(TypedDict): + status: MemoryQualityGateStatus + precision: float | None + precision_target: float + adjudicated_sample_count: int + minimum_adjudicated_sample: int + remaining_to_minimum_sample: int + unlabeled_memory_count: int + high_risk_memory_count: int + stale_truth_count: int + superseded_active_conflict_count: int + counts: MemoryQualityGateComputationCounts + + +class MemoryQualityGateResponse(TypedDict): + summary: MemoryQualityGateSummary + + +class MemoryTrustQueueAgingSummary(TypedDict): + anchor_updated_at: str | None + newest_updated_at: str | None + oldest_updated_at: str | None + backlog_span_hours: float + fresh_within_24h_count: int + aging_24h_to_72h_count: int + stale_over_72h_count: int + + +class MemoryTrustQueuePostureSummary(TypedDict): + priority_mode: MemoryReviewQueuePriorityMode + total_count: int + high_risk_count: int + stale_truth_count: int + priority_reason_counts: dict[str, int] + order: list[str] + aging: MemoryTrustQueueAgingSummary + + +class MemoryTrustCorrectionFreshnessSummary(TypedDict): + total_open_loop_count: int + stale_open_loop_count: int + correction_recurrence_count: int + freshness_drift_count: int + + +class MemoryTrustRecommendedReview(TypedDict): + priority_mode: MemoryReviewQueuePriorityMode + action: MemoryQualityReviewAction + reason: str + + +class MemoryTrustDashboardSummary(TypedDict): + quality_gate: MemoryQualityGateSummary + queue_posture: MemoryTrustQueuePostureSummary + retrieval_quality: RetrievalEvaluationSummary + correction_freshness: MemoryTrustCorrectionFreshnessSummary + recommended_review: MemoryTrustRecommendedReview + sources: list[str] + + +class MemoryTrustDashboardResponse(TypedDict): + dashboard: MemoryTrustDashboardSummary + + +class MemoryEvaluationSummary(TypedDict): + total_memory_count: int + active_memory_count: int + deleted_memory_count: int + labeled_memory_count: int + unlabeled_memory_count: int + total_label_row_count: int + label_row_counts_by_value: MemoryReviewLabelCounts + label_value_order: list[MemoryReviewLabelValue] + + +class MemoryEvaluationSummaryResponse(TypedDict): + summary: MemoryEvaluationSummary + + +class EntityRecord(TypedDict): + id: str + entity_type: EntityType + name: str + source_memory_ids: list[str] + created_at: str + + +class EntityCreateResponse(TypedDict): + entity: EntityRecord + + +class EntityListSummary(TypedDict): + total_count: int + order: list[str] + + +class EntityListResponse(TypedDict): + items: list[EntityRecord] + summary: EntityListSummary + + +class EntityDetailResponse(TypedDict): + entity: EntityRecord + + +class EntityEdgeRecord(ContextPackEntityEdge): + pass + + +class EntityEdgeCreateResponse(TypedDict): + edge: EntityEdgeRecord + + +class EntityEdgeListSummary(TypedDict): + entity_id: str + total_count: int + order: list[str] + + +class EntityEdgeListResponse(TypedDict): + items: list[EntityEdgeRecord] + summary: EntityEdgeListSummary + + +class EmbeddingConfigRecord(TypedDict): + id: str + provider: str + model: str + version: str + dimensions: int + status: EmbeddingConfigStatus + metadata: JsonObject + created_at: str + + +class EmbeddingConfigCreateResponse(TypedDict): + embedding_config: EmbeddingConfigRecord + + +class EmbeddingConfigListSummary(TypedDict): + total_count: int + order: list[str] + + +class EmbeddingConfigListResponse(TypedDict): + items: list[EmbeddingConfigRecord] + summary: EmbeddingConfigListSummary + + +class MemoryEmbeddingRecord(TypedDict): + id: str + memory_id: str + embedding_config_id: str + dimensions: int + vector: list[float] + created_at: str + updated_at: str + + +class MemoryEmbeddingUpsertResponse(TypedDict): + embedding: MemoryEmbeddingRecord + write_mode: Literal["created", "updated"] + + +class MemoryEmbeddingDetailResponse(TypedDict): + embedding: MemoryEmbeddingRecord + + +class MemoryEmbeddingListSummary(TypedDict): + memory_id: str + total_count: int + order: list[str] + + +class MemoryEmbeddingListResponse(TypedDict): + items: list[MemoryEmbeddingRecord] + summary: MemoryEmbeddingListSummary + + +class SemanticMemoryRetrievalResultItem(TypedDict): + memory_id: str + memory_key: str + value: JsonValue + source_event_ids: list[str] + memory_type: NotRequired[MemoryType] + confidence: NotRequired[float | None] + salience: NotRequired[float | None] + confirmation_status: NotRequired[MemoryConfirmationStatus] + trust_class: NotRequired[MemoryTrustClass] + promotion_eligibility: NotRequired[MemoryPromotionEligibility] + evidence_count: NotRequired[int | None] + independent_source_count: NotRequired[int | None] + extracted_by_model: NotRequired[str | None] + trust_reason: NotRequired[str | None] + valid_from: NotRequired[str | None] + valid_to: NotRequired[str | None] + last_confirmed_at: NotRequired[str | None] + created_at: str + updated_at: str + score: float + + +class SemanticMemoryRetrievalSummary(TypedDict): + embedding_config_id: str + limit: int + returned_count: int + similarity_metric: Literal["cosine_similarity"] + order: list[str] + + +class SemanticMemoryRetrievalResponse(TypedDict): + items: list[SemanticMemoryRetrievalResultItem] + summary: SemanticMemoryRetrievalSummary + + +class RetrievalEvaluationFixtureResult(TypedDict): + fixture_id: str + title: str + query: str + top_k: int + expected_relevant_ids: list[str] + returned_ids: list[str] + hit_count: int + precision_at_k: float + top_result_id: str | None + top_result_ordering: ContinuityRecallOrderingMetadata | None + + +class RetrievalEvaluationSummary(TypedDict): + fixture_count: int + evaluated_fixture_count: int + passing_fixture_count: int + precision_at_k_mean: float + precision_at_1_mean: float + precision_target: float + status: RetrievalEvaluationStatus + fixture_order: list[str] + result_order: list[str] + + +class RetrievalEvaluationResponse(TypedDict): + fixtures: list[RetrievalEvaluationFixtureResult] + summary: RetrievalEvaluationSummary + + +class ConsentRecord(TypedDict): + id: str + consent_key: str + status: ConsentStatus + metadata: JsonObject + created_at: str + updated_at: str + + +class ConsentUpsertResponse(TypedDict): + consent: ConsentRecord + write_mode: Literal["created", "updated"] + + +class ConsentListSummary(TypedDict): + total_count: int + order: list[str] + + +class ConsentListResponse(TypedDict): + items: list[ConsentRecord] + summary: ConsentListSummary + + +class PolicyRecord(TypedDict): + id: str + agent_profile_id: str | None + name: str + action: str + scope: str + effect: PolicyEffect + priority: int + active: bool + conditions: JsonObject + required_consents: list[str] + created_at: str + updated_at: str + + +class PolicyCreateResponse(TypedDict): + policy: PolicyRecord + + +class PolicyListSummary(TypedDict): + total_count: int + order: list[str] + + +class PolicyListResponse(TypedDict): + items: list[PolicyRecord] + summary: PolicyListSummary + + +class PolicyDetailResponse(TypedDict): + policy: PolicyRecord + + +class PolicyEvaluationReason(TypedDict): + code: PolicyEvaluationReasonCode + source: Literal["policy", "consent", "system"] + message: str + policy_id: str | None + consent_key: str | None + + +class PolicyEvaluationSummary(TypedDict): + action: str + scope: str + evaluated_policy_count: int + matched_policy_id: str | None + order: list[str] + + +class PolicyEvaluationTraceSummary(TypedDict): + trace_id: str + trace_event_count: int + + +class PolicyEvaluationResponse(TypedDict): + decision: PolicyEffect + matched_policy: PolicyRecord | None + reasons: list[PolicyEvaluationReason] + evaluation: PolicyEvaluationSummary + trace: PolicyEvaluationTraceSummary + + +class ToolRecord(TypedDict): + id: str + tool_key: str + name: str + description: str + version: str + metadata_version: ToolMetadataVersion + active: bool + tags: list[str] + action_hints: list[str] + scope_hints: list[str] + domain_hints: list[str] + risk_hints: list[str] + metadata: JsonObject + created_at: str + + +class ToolCreateResponse(TypedDict): + tool: ToolRecord + + +class ToolListSummary(TypedDict): + total_count: int + order: list[str] + + +class ToolListResponse(TypedDict): + items: list[ToolRecord] + summary: ToolListSummary + + +class ToolDetailResponse(TypedDict): + tool: ToolRecord + + +class ToolAllowlistReason(TypedDict): + code: ToolAllowlistReasonCode + source: Literal["tool", "policy", "consent", "system"] + message: str + tool_id: str | None + policy_id: str | None + consent_key: str | None + + +class ToolAllowlistDecisionRecord(TypedDict): + decision: ToolAllowlistDecision + tool: ToolRecord + reasons: list[ToolAllowlistReason] + + +class ToolAllowlistEvaluationSummary(TypedDict): + action: str + scope: str + domain_hint: str | None + risk_hint: str | None + evaluated_tool_count: int + allowed_count: int + denied_count: int + approval_required_count: int + order: list[str] + + +class ToolAllowlistTraceSummary(TypedDict): + trace_id: str + trace_event_count: int + + +class ToolAllowlistEvaluationResponse(TypedDict): + allowed: list[ToolAllowlistDecisionRecord] + denied: list[ToolAllowlistDecisionRecord] + approval_required: list[ToolAllowlistDecisionRecord] + summary: ToolAllowlistEvaluationSummary + trace: ToolAllowlistTraceSummary + + +class ToolRoutingRequestRecord(TypedDict): + thread_id: str + tool_id: str + action: str + scope: str + domain_hint: str | None + risk_hint: str | None + attributes: JsonObject + + +class ToolRoutingRequestTracePayload(TypedDict): + thread_id: str + tool_id: str + action: str + scope: str + domain_hint: str | None + risk_hint: str | None + attributes: JsonObject + + +class ToolRoutingDecisionTracePayload(TypedDict): + tool_id: str + tool_key: str + tool_version: str + allowlist_decision: ToolAllowlistDecision + routing_decision: ToolRoutingDecision + matched_policy_id: str | None + reasons: list[ToolAllowlistReason] + + +class ToolRoutingSummaryTracePayload(TypedDict): + decision: ToolRoutingDecision + evaluated_tool_count: int + active_policy_count: int + consent_count: int + + +class ToolRoutingSummary(TypedDict): + thread_id: str + tool_id: str + action: str + scope: str + domain_hint: str | None + risk_hint: str | None + decision: ToolRoutingDecision + evaluated_tool_count: int + active_policy_count: int + consent_count: int + order: list[str] + + +class ToolRoutingTraceSummary(TypedDict): + trace_id: str + trace_event_count: int + + +class ToolRoutingResponse(TypedDict): + request: ToolRoutingRequestRecord + decision: ToolRoutingDecision + tool: ToolRecord + reasons: list[ToolAllowlistReason] + summary: ToolRoutingSummary + trace: ToolRoutingTraceSummary + + +class ApprovalRoutingRecord(TypedDict): + decision: ToolRoutingDecision + reasons: list[ToolAllowlistReason] + trace: ToolRoutingTraceSummary + + +class ApprovalResolutionRecord(TypedDict): + resolved_at: str + resolved_by_user_id: str + + +class ApprovalRecord(TypedDict): + id: str + thread_id: str + task_run_id: NotRequired[str | None] + task_step_id: str | None + status: ApprovalStatus + request: ToolRoutingRequestRecord + tool: ToolRecord + routing: ApprovalRoutingRecord + created_at: str + resolution: ApprovalResolutionRecord | None + + +class ApprovalRequestTraceSummary(TypedDict): + trace_id: str + trace_event_count: int + + +class ApprovalResolutionTraceSummary(TypedDict): + trace_id: str + trace_event_count: int + + +class ApprovalResolutionRequestTracePayload(TypedDict): + approval_id: str + task_step_id: str | None + requested_action: ApprovalResolutionAction + + +class ApprovalResolutionStateTracePayload(TypedDict): + approval_id: str + task_step_id: str | None + requested_action: ApprovalResolutionAction + previous_status: ApprovalStatus + outcome: ApprovalResolutionOutcome + current_status: ApprovalStatus + resolved_at: str | None + resolved_by_user_id: str | None + + +class ApprovalResolutionSummaryTracePayload(TypedDict): + approval_id: str + task_step_id: str | None + requested_action: ApprovalResolutionAction + outcome: ApprovalResolutionOutcome + final_status: ApprovalStatus + + +@dataclass(frozen=True, slots=True) +class TaskCreateInput: + thread_id: UUID + tool_id: UUID + status: TaskStatus + request: ToolRoutingRequestRecord + tool: ToolRecord + latest_approval_id: UUID | None = None + latest_execution_id: UUID | None = None + + +class TaskRecord(TypedDict): + id: str + thread_id: str + tool_id: str + status: TaskStatus + request: ToolRoutingRequestRecord + tool: ToolRecord + latest_approval_id: str | None + latest_execution_id: str | None + created_at: str + updated_at: str + + +class TaskCreateResponse(TypedDict): + task: TaskRecord + + +@dataclass(frozen=True, slots=True) +class TaskStepCreateInput: + task_id: UUID + sequence_no: int + kind: TaskStepKind + status: TaskStepStatus + request: ToolRoutingRequestRecord + outcome: "TaskStepOutcomeSnapshot" + trace_id: UUID + trace_kind: str + + +@dataclass(frozen=True, slots=True) +class TaskStepNextCreateInput: + task_id: UUID + kind: TaskStepKind + status: TaskStepStatus + request: ToolRoutingRequestRecord + outcome: "TaskStepOutcomeSnapshot" + lineage: "TaskStepLineageInput" + + +@dataclass(frozen=True, slots=True) +class TaskStepTransitionInput: + task_step_id: UUID + status: TaskStepStatus + outcome: "TaskStepOutcomeSnapshot" + + +@dataclass(frozen=True, slots=True) +class TaskStepLineageInput: + parent_step_id: UUID + source_approval_id: UUID | None = None + source_execution_id: UUID | None = None + + +class TaskListSummary(TypedDict): + total_count: int + order: list[str] + + +class TaskListResponse(TypedDict): + items: list[TaskRecord] + summary: TaskListSummary + + +class TaskDetailResponse(TypedDict): + task: TaskRecord + + +@dataclass(frozen=True, slots=True) +class TaskRunCreateInput: + task_id: UUID + checkpoint: JsonObject = field(default_factory=dict) + max_ticks: int = 1 + retry_cap: int | None = None + + +@dataclass(frozen=True, slots=True) +class TaskRunTickInput: + task_run_id: UUID + + +@dataclass(frozen=True, slots=True) +class TaskRunPauseInput: + task_run_id: UUID + + +@dataclass(frozen=True, slots=True) +class TaskRunResumeInput: + task_run_id: UUID + + +@dataclass(frozen=True, slots=True) +class TaskRunCancelInput: + task_run_id: UUID + + +class TaskRunRecord(TypedDict): + id: str + task_id: str + status: TaskRunStatus + checkpoint: JsonObject + tick_count: int + step_count: int + max_ticks: int + retry_count: int + retry_cap: int + retry_posture: TaskRunRetryPosture + failure_class: TaskRunFailureClass | None + stop_reason: TaskRunStopReason | None + last_transitioned_at: str + created_at: str + updated_at: str + + +class TaskRunCreateResponse(TypedDict): + task_run: TaskRunRecord + + +class TaskRunListSummary(TypedDict): + task_id: str + total_count: int + order: list[str] + + +class TaskRunListResponse(TypedDict): + items: list[TaskRunRecord] + summary: TaskRunListSummary + + +class TaskRunDetailResponse(TypedDict): + task_run: TaskRunRecord + + +class TaskRunMutationResponse(TypedDict): + task_run: TaskRunRecord + previous_status: TaskRunStatus + + +@dataclass(frozen=True, slots=True) +class GmailAccountConnectInput: + provider_account_id: str + email_address: str + display_name: str | None + scope: str + access_token: str + refresh_token: str | None = None + client_id: str | None = None + client_secret: str | None = None + access_token_expires_at: datetime | None = None + + +@dataclass(frozen=True, slots=True) +class GmailMessageIngestInput: + gmail_account_id: UUID + task_workspace_id: UUID + provider_message_id: str + + +class GmailAccountRecord(TypedDict): + id: str + provider: str + auth_kind: str + provider_account_id: str + email_address: str + display_name: str | None + scope: str + created_at: str + updated_at: str + + +class GmailAccountConnectResponse(TypedDict): + account: GmailAccountRecord + + +class GmailAccountListSummary(TypedDict): + total_count: int + order: list[str] + + +class GmailAccountListResponse(TypedDict): + items: list[GmailAccountRecord] + summary: GmailAccountListSummary + + +class GmailAccountDetailResponse(TypedDict): + account: GmailAccountRecord + + +class GmailMessageIngestionRecord(TypedDict): + provider_message_id: str + artifact_relative_path: str + media_type: str + + +class GmailMessageIngestionResponse(TypedDict): + account: GmailAccountRecord + message: GmailMessageIngestionRecord + artifact: TaskArtifactRecord + summary: TaskArtifactChunkListSummary + + +@dataclass(frozen=True, slots=True) +class CalendarAccountConnectInput: + provider_account_id: str + email_address: str + display_name: str | None + scope: str + access_token: str + + +@dataclass(frozen=True, slots=True) +class CalendarEventIngestInput: + calendar_account_id: UUID + task_workspace_id: UUID + provider_event_id: str + + +@dataclass(frozen=True, slots=True) +class CalendarEventListInput: + calendar_account_id: UUID + limit: int = DEFAULT_CALENDAR_EVENT_LIST_LIMIT + time_min: datetime | None = None + time_max: datetime | None = None + + +class CalendarAccountRecord(TypedDict): + id: str + provider: str + auth_kind: str + provider_account_id: str + email_address: str + display_name: str | None + scope: str + created_at: str + updated_at: str + + +class CalendarAccountConnectResponse(TypedDict): + account: CalendarAccountRecord + + +class CalendarAccountListSummary(TypedDict): + total_count: int + order: list[str] + + +class CalendarAccountListResponse(TypedDict): + items: list[CalendarAccountRecord] + summary: CalendarAccountListSummary + + +class CalendarAccountDetailResponse(TypedDict): + account: CalendarAccountRecord + + +class CalendarEventIngestionRecord(TypedDict): + provider_event_id: str + artifact_relative_path: str + media_type: str + + +class CalendarEventIngestionResponse(TypedDict): + account: CalendarAccountRecord + event: CalendarEventIngestionRecord + artifact: TaskArtifactRecord + summary: TaskArtifactChunkListSummary + + +class CalendarEventSummaryRecord(TypedDict): + provider_event_id: str + status: str | None + summary: str | None + start_time: str | None + end_time: str | None + html_link: str | None + updated_at: str | None + + +class CalendarEventListSummary(TypedDict): + total_count: int + limit: int + order: list[str] + time_min: str | None + time_max: str | None + + +class CalendarEventListResponse(TypedDict): + account: CalendarAccountRecord + items: list[CalendarEventSummaryRecord] + summary: CalendarEventListSummary + + +@dataclass(frozen=True, slots=True) +class TaskWorkspaceCreateInput: + task_id: UUID + status: TaskWorkspaceStatus + + +class TaskWorkspaceRecord(TypedDict): + id: str + task_id: str + status: TaskWorkspaceStatus + local_path: str + created_at: str + updated_at: str + + +class TaskWorkspaceCreateResponse(TypedDict): + workspace: TaskWorkspaceRecord + + +class TaskWorkspaceListSummary(TypedDict): + total_count: int + order: list[str] + + +class TaskWorkspaceListResponse(TypedDict): + items: list[TaskWorkspaceRecord] + summary: TaskWorkspaceListSummary + + +class TaskWorkspaceDetailResponse(TypedDict): + workspace: TaskWorkspaceRecord + + +@dataclass(frozen=True, slots=True) +class TaskArtifactRegisterInput: + task_workspace_id: UUID + local_path: str + media_type_hint: str | None = None + + +@dataclass(frozen=True, slots=True) +class TaskArtifactIngestInput: + task_artifact_id: UUID + + +@dataclass(frozen=True, slots=True) +class TaskScopedArtifactChunkRetrievalInput: + task_id: UUID + query: str + + +@dataclass(frozen=True, slots=True) +class ArtifactScopedArtifactChunkRetrievalInput: + task_artifact_id: UUID + query: str + + +@dataclass(frozen=True, slots=True) +class TaskScopedSemanticArtifactChunkRetrievalInput: + task_id: UUID + embedding_config_id: UUID + query_vector: tuple[float, ...] + limit: int = DEFAULT_ARTIFACT_CHUNK_RETRIEVAL_LIMIT + + def as_payload(self) -> JsonObject: + return { + "task_id": str(self.task_id), + "embedding_config_id": str(self.embedding_config_id), + "query_vector": [float(value) for value in self.query_vector], + "limit": self.limit, + } + + +@dataclass(frozen=True, slots=True) +class ArtifactScopedSemanticArtifactChunkRetrievalInput: + task_artifact_id: UUID + embedding_config_id: UUID + query_vector: tuple[float, ...] + limit: int = DEFAULT_ARTIFACT_CHUNK_RETRIEVAL_LIMIT + + def as_payload(self) -> JsonObject: + return { + "task_artifact_id": str(self.task_artifact_id), + "embedding_config_id": str(self.embedding_config_id), + "query_vector": [float(value) for value in self.query_vector], + "limit": self.limit, + } + + +class TaskArtifactRecord(TypedDict): + id: str + task_id: str + task_workspace_id: str + status: TaskArtifactStatus + ingestion_status: TaskArtifactIngestionStatus + relative_path: str + media_type_hint: str | None + created_at: str + updated_at: str + + +class TaskArtifactCreateResponse(TypedDict): + artifact: TaskArtifactRecord + + +class TaskArtifactListSummary(TypedDict): + total_count: int + order: list[str] + + +class TaskArtifactListResponse(TypedDict): + items: list[TaskArtifactRecord] + summary: TaskArtifactListSummary + + +class TaskArtifactDetailResponse(TypedDict): + artifact: TaskArtifactRecord + + +class TaskArtifactChunkRecord(TypedDict): + id: str + task_artifact_id: str + sequence_no: int + char_start: int + char_end_exclusive: int + text: str + created_at: str + updated_at: str + + +class TaskArtifactChunkListSummary(TypedDict): + total_count: int + total_characters: int + media_type: str + chunking_rule: str + order: list[str] + + +class TaskArtifactChunkListResponse(TypedDict): + items: list[TaskArtifactChunkRecord] + summary: TaskArtifactChunkListSummary + + +class TaskArtifactChunkEmbeddingRecord(TypedDict): + id: str + task_artifact_id: str + task_artifact_chunk_id: str + task_artifact_chunk_sequence_no: int + embedding_config_id: str + dimensions: int + vector: list[float] + created_at: str + updated_at: str + + +class TaskArtifactChunkEmbeddingWriteResponse(TypedDict): + embedding: TaskArtifactChunkEmbeddingRecord + write_mode: Literal["created", "updated"] + + +class TaskArtifactChunkEmbeddingDetailResponse(TypedDict): + embedding: TaskArtifactChunkEmbeddingRecord + + +class TaskArtifactChunkEmbeddingListScope(TypedDict): + kind: TaskArtifactChunkEmbeddingListScopeKind + task_artifact_id: str + task_artifact_chunk_id: NotRequired[str] + + +class TaskArtifactChunkEmbeddingListSummary(TypedDict): + total_count: int + order: list[str] + scope: TaskArtifactChunkEmbeddingListScope + + +class TaskArtifactChunkEmbeddingListResponse(TypedDict): + items: list[TaskArtifactChunkEmbeddingRecord] + summary: TaskArtifactChunkEmbeddingListSummary + + +class TaskArtifactIngestionResponse(TypedDict): + artifact: TaskArtifactRecord + summary: TaskArtifactChunkListSummary + + +class TaskArtifactChunkRetrievalMatch(TypedDict): + matched_query_terms: list[str] + matched_query_term_count: int + first_match_char_start: int + + +class TaskArtifactChunkRetrievalItem(TypedDict): + id: str + task_id: str + task_artifact_id: str + relative_path: str + media_type: str + sequence_no: int + char_start: int + char_end_exclusive: int + text: str + match: TaskArtifactChunkRetrievalMatch + + +class TaskArtifactChunkRetrievalScope(TypedDict): + kind: TaskArtifactChunkRetrievalScopeKind + task_id: str + task_artifact_id: NotRequired[str] + + +class TaskArtifactChunkRetrievalSummary(TypedDict): + total_count: int + searched_artifact_count: int + query: str + query_terms: list[str] + matching_rule: str + order: list[str] + scope: TaskArtifactChunkRetrievalScope + + +class TaskArtifactChunkRetrievalResponse(TypedDict): + items: list[TaskArtifactChunkRetrievalItem] + summary: TaskArtifactChunkRetrievalSummary + + +class TaskArtifactChunkSemanticRetrievalItem(TypedDict): + id: str + task_id: str + task_artifact_id: str + relative_path: str + media_type: str + sequence_no: int + char_start: int + char_end_exclusive: int + text: str + score: float + + +class TaskArtifactChunkSemanticRetrievalSummary(TypedDict): + embedding_config_id: str + query_vector_dimensions: int + limit: int + returned_count: int + searched_artifact_count: int + similarity_metric: Literal["cosine_similarity"] + order: list[str] + scope: TaskArtifactChunkRetrievalScope + + +class TaskArtifactChunkSemanticRetrievalResponse(TypedDict): + items: list[TaskArtifactChunkSemanticRetrievalItem] + summary: TaskArtifactChunkSemanticRetrievalSummary + + +class TaskStepTraceLink(TypedDict): + trace_id: str + trace_kind: str + + +class TaskStepOutcomeSnapshot(TypedDict): + routing_decision: ToolRoutingDecision + approval_id: str | None + approval_status: ApprovalStatus | None + execution_id: str | None + execution_status: ProxyExecutionStatus | None + blocked_reason: str | None + + +class TaskStepLineageRecord(TypedDict): + parent_step_id: str | None + source_approval_id: str | None + source_execution_id: str | None + + +class TaskStepRecord(TypedDict): + id: str + task_id: str + sequence_no: int + kind: TaskStepKind + status: TaskStepStatus + request: ToolRoutingRequestRecord + outcome: TaskStepOutcomeSnapshot + lineage: TaskStepLineageRecord + trace: TaskStepTraceLink + created_at: str + updated_at: str + + +class TaskStepCreateResponse(TypedDict): + task_step: TaskStepRecord + + +class TaskStepSequencingSummary(TypedDict): + task_id: str + total_count: int + latest_sequence_no: int | None + latest_status: TaskStepStatus | None + next_sequence_no: int + append_allowed: bool + order: list[str] + + +class TaskStepListSummary(TaskStepSequencingSummary): + pass + + +class TaskStepListResponse(TypedDict): + items: list[TaskStepRecord] + summary: TaskStepListSummary + + +class TaskStepDetailResponse(TypedDict): + task_step: TaskStepRecord + + +class TaskStepMutationTraceSummary(TypedDict): + trace_id: str + trace_event_count: int + + +class TaskStepNextCreateResponse(TypedDict): + task: TaskRecord + task_step: TaskStepRecord + sequencing: TaskStepSequencingSummary + trace: TaskStepMutationTraceSummary + + +class TaskStepTransitionResponse(TypedDict): + task: TaskRecord + task_step: TaskStepRecord + sequencing: TaskStepSequencingSummary + trace: TaskStepMutationTraceSummary + + +class ResumptionBriefSectionSummary(TypedDict): + limit: int + returned_count: int + total_count: int + order: list[str] + + +class ResumptionBriefConversationSummary(ResumptionBriefSectionSummary): + kinds: list[str] + + +class ResumptionBriefConversationSection(TypedDict): + items: list[ThreadEventRecord] + summary: ResumptionBriefConversationSummary + + +class ResumptionBriefOpenLoopSection(TypedDict): + items: list[OpenLoopRecord] + summary: ResumptionBriefSectionSummary + + +class ResumptionBriefMemoryHighlightSection(TypedDict): + items: list[ContextPackMemory] + summary: ResumptionBriefSectionSummary + + +class ResumptionBriefWorkflowSummary(TypedDict): + present: bool + task_order: list[str] + task_step_order: list[str] + + +class ResumptionBriefWorkflowPosture(TypedDict): + task: TaskRecord + latest_task_step: TaskStepRecord | None + summary: ResumptionBriefWorkflowSummary + + +class ResumptionBriefRecord(TypedDict): + assembly_version: str + thread: ThreadRecord + conversation: ResumptionBriefConversationSection + open_loops: ResumptionBriefOpenLoopSection + memory_highlights: ResumptionBriefMemoryHighlightSection + workflow: ResumptionBriefWorkflowPosture | None + sources: list[str] + + +class ResumptionBriefResponse(TypedDict): + brief: ResumptionBriefRecord + + +class TaskLifecycleStateTracePayload(TypedDict): + task_id: str + source: TaskLifecycleSource + previous_status: TaskStatus | None + current_status: TaskStatus + latest_approval_id: str | None + latest_execution_id: str | None + + +class TaskLifecycleSummaryTracePayload(TypedDict): + task_id: str + source: TaskLifecycleSource + final_status: TaskStatus + latest_approval_id: str | None + latest_execution_id: str | None + + +class TaskStepLifecycleStateTracePayload(TypedDict): + task_id: str + task_step_id: str + source: TaskLifecycleSource + sequence_no: int + kind: TaskStepKind + previous_status: TaskStepStatus | None + current_status: TaskStepStatus + trace: TaskStepTraceLink + + +class TaskStepLifecycleSummaryTracePayload(TypedDict): + task_id: str + task_step_id: str + source: TaskLifecycleSource + sequence_no: int + kind: TaskStepKind + final_status: TaskStepStatus + trace: TaskStepTraceLink + + +class TaskStepSequenceRequestTracePayload(TypedDict): + task_id: str + previous_task_step_id: str + previous_sequence_no: int + previous_status: TaskStepStatus + requested_kind: TaskStepKind + requested_status: TaskStepStatus + + +class TaskStepSequenceStateTracePayload(TypedDict): + task_id: str + previous_task_step_id: str + previous_sequence_no: int + previous_status: TaskStepStatus + task_step_id: str + assigned_sequence_no: int + kind: TaskStepKind + current_status: TaskStepStatus + + +class TaskStepSequenceSummaryTracePayload(TypedDict): + task_id: str + task_step_id: str + latest_sequence_no: int + next_sequence_no: int + append_allowed: bool + + +class TaskStepContinuationRequestTracePayload(TypedDict): + task_id: str + parent_task_step_id: str + parent_sequence_no: int + parent_status: TaskStepStatus + requested_kind: TaskStepKind + requested_status: TaskStepStatus + requested_source_approval_id: str | None + requested_source_execution_id: str | None + + +class TaskStepContinuationLineageTracePayload(TypedDict): + task_id: str + parent_task_step_id: str + parent_sequence_no: int + parent_status: TaskStepStatus + source_approval_id: str | None + source_execution_id: str | None + + +class TaskStepContinuationSummaryTracePayload(TypedDict): + task_id: str + task_step_id: str + latest_sequence_no: int + next_sequence_no: int + append_allowed: bool + lineage: TaskStepLineageRecord + + +class TaskStepTransitionRequestTracePayload(TypedDict): + task_id: str + task_step_id: str + sequence_no: int + previous_status: TaskStepStatus + requested_status: TaskStepStatus + + +class TaskStepTransitionStateTracePayload(TypedDict): + task_id: str + task_step_id: str + sequence_no: int + previous_status: TaskStepStatus + current_status: TaskStepStatus + allowed_next_statuses: list[TaskStepStatus] + trace: TaskStepTraceLink + + +class TaskStepTransitionSummaryTracePayload(TypedDict): + task_id: str + task_step_id: str + sequence_no: int + final_status: TaskStepStatus + parent_task_status: TaskStatus + trace: TaskStepTraceLink + + +class ApprovalRequestCreateResponse(TypedDict): + request: ToolRoutingRequestRecord + decision: ToolRoutingDecision + tool: ToolRecord + reasons: list[ToolAllowlistReason] + task: TaskRecord + approval: ApprovalRecord | None + routing_trace: ToolRoutingTraceSummary + trace: ApprovalRequestTraceSummary + + +class ApprovalListSummary(TypedDict): + total_count: int + order: list[str] + + +class ApprovalListResponse(TypedDict): + items: list[ApprovalRecord] + summary: ApprovalListSummary + + +class ApprovalDetailResponse(TypedDict): + approval: ApprovalRecord + + +class ApprovalResolutionResponse(TypedDict): + approval: ApprovalRecord + trace: ApprovalResolutionTraceSummary + + +class ExecutionBudgetRecord(TypedDict): + id: str + agent_profile_id: str | None + tool_key: str | None + domain_hint: str | None + max_completed_executions: int + rolling_window_seconds: int | None + status: ExecutionBudgetStatus + deactivated_at: str | None + superseded_by_budget_id: str | None + supersedes_budget_id: str | None + created_at: str + + +class ExecutionBudgetCreateResponse(TypedDict): + execution_budget: ExecutionBudgetRecord + + +class ExecutionBudgetListSummary(TypedDict): + total_count: int + order: list[str] + + +class ExecutionBudgetListResponse(TypedDict): + items: list[ExecutionBudgetRecord] + summary: ExecutionBudgetListSummary + + +class ExecutionBudgetDetailResponse(TypedDict): + execution_budget: ExecutionBudgetRecord + + +class ExecutionBudgetLifecycleTraceSummary(TypedDict): + trace_id: str + trace_event_count: int + + +class ExecutionBudgetDeactivateResponse(TypedDict): + execution_budget: ExecutionBudgetRecord + trace: ExecutionBudgetLifecycleTraceSummary + + +class ExecutionBudgetSupersedeResponse(TypedDict): + superseded_budget: ExecutionBudgetRecord + replacement_budget: ExecutionBudgetRecord + trace: ExecutionBudgetLifecycleTraceSummary + + +class ExecutionBudgetDecisionRecord(TypedDict): + matched_budget_id: str | None + tool_key: str + domain_hint: str | None + budget_tool_key: str | None + budget_domain_hint: str | None + max_completed_executions: int | None + rolling_window_seconds: int | None + count_scope: ExecutionBudgetCountScope + window_started_at: str | None + completed_execution_count: int + projected_completed_execution_count: int + decision: ExecutionBudgetDecision + reason: ExecutionBudgetDecisionReason + order: list[str] + history_order: list[str] + request_thread_id: NotRequired[str | None] + context_resolution: NotRequired[ExecutionBudgetContextResolution] + context_reason: NotRequired[str | None] + + +class ExecutionBudgetLifecycleRequestTracePayload(TypedDict): + thread_id: str + execution_budget_id: str + requested_action: ExecutionBudgetLifecycleAction + replacement_max_completed_executions: int | None + + +class ExecutionBudgetLifecycleStateTracePayload(TypedDict): + execution_budget_id: str + requested_action: ExecutionBudgetLifecycleAction + previous_status: ExecutionBudgetStatus + current_status: ExecutionBudgetStatus + tool_key: str | None + domain_hint: str | None + max_completed_executions: int + rolling_window_seconds: int | None + deactivated_at: str | None + superseded_by_budget_id: str | None + supersedes_budget_id: str | None + replacement_budget_id: str | None + replacement_status: ExecutionBudgetStatus | None + replacement_max_completed_executions: int | None + replacement_rolling_window_seconds: int | None + rejection_reason: str | None + + +class ExecutionBudgetLifecycleSummaryTracePayload(TypedDict): + execution_budget_id: str + requested_action: ExecutionBudgetLifecycleAction + outcome: ExecutionBudgetLifecycleOutcome + replacement_budget_id: str | None + active_budget_id: str | None + + +@dataclass(frozen=True, slots=True) +class ToolExecutionCreateInput: + approval_id: UUID + task_step_id: UUID + thread_id: UUID + tool_id: UUID + trace_id: UUID + request_event_id: UUID | None + result_event_id: UUID | None + status: ProxyExecutionStatus + handler_key: str | None + request: ToolRoutingRequestRecord + tool: ToolRecord + result: "ToolExecutionResultRecord" + task_run_id: UUID | None = None + idempotency_key: str | None = None + + +class ToolExecutionRecord(TypedDict): + id: str + approval_id: str + task_run_id: NotRequired[str | None] + task_step_id: str + thread_id: str + tool_id: str + trace_id: str + request_event_id: str | None + result_event_id: str | None + status: ProxyExecutionStatus + handler_key: str | None + idempotency_key: NotRequired[str | None] + request: ToolRoutingRequestRecord + tool: ToolRecord + result: "ToolExecutionResultRecord" + executed_at: str + + +class ToolExecutionListSummary(TypedDict): + total_count: int + order: list[str] + + +class ToolExecutionListResponse(TypedDict): + items: list[ToolExecutionRecord] + summary: ToolExecutionListSummary + + +class ToolExecutionDetailResponse(TypedDict): + execution: ToolExecutionRecord + + +class ProxyExecutionRequestRecord(TypedDict): + approval_id: str + task_run_id: NotRequired[str | None] + task_step_id: str + + +class ProxyExecutionRequestEventPayload(TypedDict): + approval_id: str + task_run_id: NotRequired[str | None] + task_step_id: str + tool_id: str + tool_key: str + request: ToolRoutingRequestRecord + + +class ProxyExecutionResultRecord(TypedDict): + handler_key: str + status: ProxyExecutionStatus + output: JsonObject | None + + +class ProxyExecutionResultEventPayload(TypedDict): + approval_id: str + task_step_id: str + tool_id: str + tool_key: str + handler_key: str + status: Literal["completed"] + output: JsonObject + + +class ToolExecutionResultRecord(TypedDict): + handler_key: str | None + status: ProxyExecutionStatus + output: JsonObject | None + reason: str | None + budget_decision: NotRequired[ExecutionBudgetDecisionRecord] + + +class ProxyExecutionEventSummary(TypedDict): + request_event_id: str + request_sequence_no: int + result_event_id: str + result_sequence_no: int + + +class ProxyExecutionTraceSummary(TypedDict): + trace_id: str + trace_event_count: int + + +class ProxyExecutionBudgetPrecheckTracePayload(ExecutionBudgetDecisionRecord): + pass + + +class ProxyExecutionApprovalTracePayload(TypedDict): + approval_id: str + task_step_id: str + approval_status: ApprovalStatus + eligible_for_execution: bool + + +class ProxyExecutionBudgetContextTracePayload(TypedDict): + request_thread_id: str | None + context_resolution: ExecutionBudgetContextResolution + context_reason: str | None + + +class ProxyExecutionDispatchTracePayload(TypedDict): + approval_id: str + task_step_id: str + tool_id: str + tool_key: str + handler_key: str | None + dispatch_status: Literal["executed", "blocked"] + reason: str | None + result_status: ProxyExecutionStatus | None + output: JsonObject | None + budget_context: NotRequired[ProxyExecutionBudgetContextTracePayload] + + +class ProxyExecutionSummaryTracePayload(TypedDict): + approval_id: str + task_step_id: str + tool_id: str + tool_key: str + approval_status: ApprovalStatus + execution_status: Literal["completed", "blocked"] + handler_key: str | None + request_event_id: str | None + result_event_id: str | None + + +class ProxyExecutionResponse(TypedDict): + request: ProxyExecutionRequestRecord + approval: ApprovalRecord + tool: ToolRecord + result: ProxyExecutionResultRecord | ToolExecutionResultRecord + events: ProxyExecutionEventSummary | None + trace: ProxyExecutionTraceSummary + + +class ProxyExecutionBudgetBlockedResponse(TypedDict): + request: ProxyExecutionRequestRecord + approval: ApprovalRecord + tool: ToolRecord + result: ToolExecutionResultRecord + events: None + trace: ProxyExecutionTraceSummary + + +def isoformat_or_none(value: datetime | None) -> str | None: + if value is None: + return None + return value.isoformat() + + +class HostedUserAccountRecord(TypedDict): + id: str + email: str + display_name: str | None + beta_cohort_key: str | None + created_at: str + + +class HostedAuthSessionRecord(TypedDict): + id: str + user_account_id: str + workspace_id: str | None + device_id: str | None + status: HostedAuthSessionStatus + expires_at: str + revoked_at: str | None + last_seen_at: str | None + created_at: str + + +class HostedMagicLinkChallengeRecord(TypedDict): + id: str + email: str + challenge_token_hash: str + status: HostedMagicLinkChallengeStatus + expires_at: str + consumed_at: str | None + created_at: str + + +class HostedWorkspaceRecord(TypedDict): + id: str + owner_user_account_id: str + slug: str + name: str + bootstrap_status: HostedWorkspaceBootstrapStatus + bootstrapped_at: str | None + support_status: Literal["healthy", "needs_attention", "blocked"] + support_notes: JsonObject + onboarding_last_error_code: str | None + onboarding_last_error_detail: str | None + onboarding_last_error_at: str | None + onboarding_error_count: int + rollout_evidence: JsonObject + rate_limit_evidence: JsonObject + incident_evidence: JsonObject + created_at: str + updated_at: str + + +class HostedBootstrapStatusRecord(TypedDict): + workspace_id: str + status: HostedWorkspaceBootstrapStatus + bootstrapped_at: str | None + ready_for_next_phase_telegram_linkage: bool + telegram_state: Literal["available_in_p10_s2_transport"] + + +class HostedDeviceRecord(TypedDict): + id: str + user_account_id: str + workspace_id: str | None + device_key: str + device_label: str + status: HostedDeviceStatus + last_seen_at: str | None + revoked_at: str | None + created_at: str + updated_at: str + + +class HostedDeviceLinkChallengeRecord(TypedDict): + id: str + user_account_id: str + workspace_id: str | None + device_key: str + device_label: str + challenge_token_hash: str + status: HostedDeviceLinkChallengeStatus + expires_at: str + confirmed_at: str | None + device_id: str | None + created_at: str + + +class HostedUserPreferencesRecord(TypedDict): + id: str + user_account_id: str + timezone: str + brief_preferences: JsonObject + quiet_hours: JsonObject + created_at: str + updated_at: str + + +class NotificationSubscriptionRecord(TypedDict): + id: str + workspace_id: str + channel_type: ChannelTransportType + channel_identity_id: str + notifications_enabled: bool + daily_brief_enabled: bool + daily_brief_window_start: str + open_loop_prompts_enabled: bool + waiting_for_prompts_enabled: bool + stale_prompts_enabled: bool + timezone: str + quiet_hours_enabled: bool + quiet_hours_start: str + quiet_hours_end: str + created_at: str + updated_at: str + + +class ChannelIdentityRecord(TypedDict): + id: str + user_account_id: str + workspace_id: str + channel_type: ChannelTransportType + external_user_id: str + external_chat_id: str + external_username: str | None + status: ChannelIdentityStatus + linked_at: str + unlinked_at: str | None + created_at: str + updated_at: str + + +class ChannelLinkChallengeRecord(TypedDict): + id: str + user_account_id: str + workspace_id: str + channel_type: ChannelTransportType + link_code: str + status: ChannelLinkChallengeStatus + expires_at: str + confirmed_at: str | None + channel_identity_id: str | None + created_at: str + challenge_token: NotRequired[str] + + +class ChannelThreadRecord(TypedDict): + id: str + workspace_id: str + channel_type: ChannelTransportType + external_thread_key: str + channel_identity_id: str | None + last_message_at: str | None + created_at: str + updated_at: str + + +class ChannelMessageRecord(TypedDict): + id: str + workspace_id: str | None + channel_thread_id: str | None + channel_identity_id: str | None + channel_type: ChannelTransportType + direction: ChannelMessageDirection + provider_update_id: str | None + provider_message_id: str | None + external_chat_id: str | None + external_user_id: str | None + message_text: str | None + normalized_payload: JsonObject + route_status: ChannelMessageRouteStatus + idempotency_key: str + created_at: str + received_at: str + + +class ChatIntentRecord(TypedDict): + id: str + workspace_id: str + channel_message_id: str + channel_thread_id: str | None + intent_kind: ChatIntentKind + status: ChatIntentStatus + intent_payload: JsonObject + result_payload: JsonObject + handled_at: str | None + created_at: str + + +class ChannelDeliveryReceiptRecord(TypedDict): + id: str + workspace_id: str + channel_message_id: str + channel_type: ChannelTransportType + status: ChannelDeliveryReceiptStatus + provider_receipt_id: str | None + failure_code: str | None + failure_detail: str | None + scheduled_job_id: str | None + scheduler_job_kind: TelegramSchedulerJobKind | None + scheduled_for: str | None + schedule_slot: str | None + notification_policy: JsonObject + rollout_flag_state: Literal["enabled", "blocked"] + support_evidence: JsonObject + rate_limit_evidence: JsonObject + incident_evidence: JsonObject + recorded_at: str + created_at: str + + +class TelegramContinuityBriefRecord(TypedDict): + id: str + workspace_id: str + channel_type: ChannelTransportType + channel_identity_id: str + brief_kind: Literal["daily_brief"] + assembly_version: str + summary: JsonObject + brief_payload: JsonObject + message_text: str + compiled_at: str + created_at: str + + +class TelegramDailyBriefJobRecord(TypedDict): + id: str + workspace_id: str + channel_type: ChannelTransportType + channel_identity_id: str + job_kind: TelegramSchedulerJobKind + prompt_kind: TelegramSchedulerPromptKind | None + prompt_id: str | None + continuity_object_id: str | None + continuity_brief_id: str | None + schedule_slot: str + idempotency_key: str + due_at: str + status: TelegramSchedulerJobStatus + suppression_reason: str | None + attempt_count: int + delivery_receipt_id: str | None + payload: JsonObject + result_payload: JsonObject + rollout_flag_state: Literal["enabled", "blocked"] + support_evidence: JsonObject + rate_limit_evidence: JsonObject + incident_evidence: JsonObject + attempted_at: str | None + completed_at: str | None + created_at: str + updated_at: str + + +class ChatTelemetryRecord(TypedDict): + id: str + user_account_id: str + workspace_id: str | None + channel_message_id: str | None + daily_brief_job_id: str | None + delivery_receipt_id: str | None + flow_kind: Literal["chat_handle", "scheduler_daily_brief", "scheduler_open_loop_prompt"] + event_kind: Literal["attempt", "result", "rollout_block", "rate_limited", "abuse_block", "incident"] + status: Literal[ + "ok", + "failed", + "blocked_rollout", + "rate_limited", + "abuse_blocked", + "suppressed", + "simulated", + "delivered", + ] + route_path: str + rollout_flag_key: str | None + rollout_flag_state: str | None + rate_limit_key: str | None + rate_limit_window_seconds: int | None + rate_limit_max_requests: int | None + retry_after_seconds: int | None + abuse_signal: str | None + evidence: JsonObject + created_at: str + + +class ApprovalChallengeRecord(TypedDict): + id: str + workspace_id: str + approval_id: str + channel_message_id: str | None + status: Literal["pending", "approved", "rejected", "dismissed"] + challenge_prompt: str + challenge_payload: JsonObject + resolved_at: str | None + created_at: str + updated_at: str + + +class OpenLoopReviewRecord(TypedDict): + id: str + workspace_id: str + continuity_object_id: str + channel_message_id: str | None + correction_event_id: str | None + review_action: ContinuityOpenLoopReviewAction + note: str | None + created_at: str diff --git a/apps/api/src/alicebot_api/db.py b/apps/api/src/alicebot_api/db.py new file mode 100644 index 0000000..cc6e87b --- /dev/null +++ b/apps/api/src/alicebot_api/db.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager +from uuid import UUID + +import psycopg +from psycopg.rows import dict_row + +PING_DATABASE_SQL = "SELECT 1" +SET_CURRENT_USER_SQL = "SELECT set_config('app.current_user_id', %s, true)" +ConnectionRow = dict[str, object] +UserConnection = psycopg.Connection[ConnectionRow] + + +def ping_database(database_url: str, timeout_seconds: int) -> bool: + try: + with psycopg.connect(database_url, connect_timeout=timeout_seconds) as conn: + with conn.cursor() as cur: + cur.execute(PING_DATABASE_SQL) + cur.fetchone() + return True + except psycopg.Error: + return False + + +def set_current_user(conn: psycopg.Connection, user_id: UUID) -> None: + with conn.cursor() as cur: + cur.execute(SET_CURRENT_USER_SQL, (str(user_id),)) + + +@contextmanager +def user_connection(database_url: str, user_id: UUID) -> Iterator[UserConnection]: + with psycopg.connect(database_url, row_factory=dict_row) as conn: + with conn.transaction(): + set_current_user(conn, user_id) + yield conn diff --git a/apps/api/src/alicebot_api/embedding.py b/apps/api/src/alicebot_api/embedding.py new file mode 100644 index 0000000..320d5fb --- /dev/null +++ b/apps/api/src/alicebot_api/embedding.py @@ -0,0 +1,440 @@ +from __future__ import annotations + +import math +from uuid import UUID + +import psycopg + +from alicebot_api.artifacts import TaskArtifactNotFoundError +from alicebot_api.contracts import ( + EMBEDDING_CONFIG_LIST_ORDER, + MEMORY_EMBEDDING_LIST_ORDER, + TASK_ARTIFACT_CHUNK_EMBEDDING_LIST_ORDER, + EmbeddingConfigCreateInput, + EmbeddingConfigCreateResponse, + EmbeddingConfigListResponse, + EmbeddingConfigListSummary, + EmbeddingConfigRecord, + MemoryEmbeddingDetailResponse, + MemoryEmbeddingListResponse, + MemoryEmbeddingListSummary, + MemoryEmbeddingRecord, + MemoryEmbeddingUpsertInput, + MemoryEmbeddingUpsertResponse, + TaskArtifactChunkEmbeddingDetailResponse, + TaskArtifactChunkEmbeddingListResponse, + TaskArtifactChunkEmbeddingListScope, + TaskArtifactChunkEmbeddingListScopeKind, + TaskArtifactChunkEmbeddingListSummary, + TaskArtifactChunkEmbeddingRecord, + TaskArtifactChunkEmbeddingUpsertInput, + TaskArtifactChunkEmbeddingWriteResponse, +) +from alicebot_api.store import ( + ContinuityStore, + EmbeddingConfigRow, + MemoryEmbeddingRow, + TaskArtifactChunkEmbeddingRow, +) + + +class EmbeddingConfigValidationError(ValueError): + """Raised when an embedding-config request fails explicit validation.""" + + +class MemoryEmbeddingValidationError(ValueError): + """Raised when a memory-embedding request fails explicit validation.""" + + +class MemoryEmbeddingNotFoundError(LookupError): + """Raised when a requested memory embedding is not visible inside the current user scope.""" + + +class TaskArtifactChunkEmbeddingValidationError(ValueError): + """Raised when an artifact-chunk embedding request fails explicit validation.""" + + +class TaskArtifactChunkEmbeddingNotFoundError(LookupError): + """Raised when an artifact-chunk embedding read target is not visible inside the current user scope.""" + + +def _duplicate_embedding_config_message( + *, + provider: str, + model: str, + version: str, +) -> str: + return ( + "embedding config already exists for provider/model/version under the user scope: " + f"{provider}/{model}/{version}" + ) + + +def _serialize_embedding_config(config: EmbeddingConfigRow) -> EmbeddingConfigRecord: + return { + "id": str(config["id"]), + "provider": config["provider"], + "model": config["model"], + "version": config["version"], + "dimensions": config["dimensions"], + "status": config["status"], + "metadata": config["metadata"], + "created_at": config["created_at"].isoformat(), + } + + +def _serialize_memory_embedding(embedding: MemoryEmbeddingRow) -> MemoryEmbeddingRecord: + return { + "id": str(embedding["id"]), + "memory_id": str(embedding["memory_id"]), + "embedding_config_id": str(embedding["embedding_config_id"]), + "dimensions": embedding["dimensions"], + "vector": [float(value) for value in embedding["vector"]], + "created_at": embedding["created_at"].isoformat(), + "updated_at": embedding["updated_at"].isoformat(), + } + + +def _serialize_task_artifact_chunk_embedding( + embedding: TaskArtifactChunkEmbeddingRow, +) -> TaskArtifactChunkEmbeddingRecord: + return { + "id": str(embedding["id"]), + "task_artifact_id": str(embedding["task_artifact_id"]), + "task_artifact_chunk_id": str(embedding["task_artifact_chunk_id"]), + "task_artifact_chunk_sequence_no": embedding["task_artifact_chunk_sequence_no"], + "embedding_config_id": str(embedding["embedding_config_id"]), + "dimensions": embedding["dimensions"], + "vector": [float(value) for value in embedding["vector"]], + "created_at": embedding["created_at"].isoformat(), + "updated_at": embedding["updated_at"].isoformat(), + } + + +def _validate_vector( + vector: tuple[float, ...], + *, + error_type: type[ValueError], +) -> list[float]: + if not vector: + raise error_type("vector must include at least one numeric value") + + normalized: list[float] = [] + for value in vector: + normalized_value = float(value) + if not math.isfinite(normalized_value): + raise error_type("vector must contain only finite numeric values") + normalized.append(normalized_value) + + return normalized + + +def _build_task_artifact_chunk_embedding_scope( + *, + kind: TaskArtifactChunkEmbeddingListScopeKind, + task_artifact_id: UUID, + task_artifact_chunk_id: UUID | None = None, +) -> TaskArtifactChunkEmbeddingListScope: + scope: TaskArtifactChunkEmbeddingListScope = { + "kind": kind, + "task_artifact_id": str(task_artifact_id), + } + if task_artifact_chunk_id is not None: + scope["task_artifact_chunk_id"] = str(task_artifact_chunk_id) + return scope + + +def _build_task_artifact_chunk_embedding_summary( + *, + items: list[TaskArtifactChunkEmbeddingRecord], + scope: TaskArtifactChunkEmbeddingListScope, +) -> TaskArtifactChunkEmbeddingListSummary: + return { + "total_count": len(items), + "order": list(TASK_ARTIFACT_CHUNK_EMBEDDING_LIST_ORDER), + "scope": scope, + } + + +def create_embedding_config_record( + store: ContinuityStore, + *, + user_id: UUID, + config: EmbeddingConfigCreateInput, +) -> EmbeddingConfigCreateResponse: + del user_id + + existing = store.get_embedding_config_by_identity_optional( + provider=config.provider, + model=config.model, + version=config.version, + ) + if existing is not None: + raise EmbeddingConfigValidationError( + _duplicate_embedding_config_message( + provider=config.provider, + model=config.model, + version=config.version, + ) + ) + + try: + created = store.create_embedding_config( + provider=config.provider, + model=config.model, + version=config.version, + dimensions=config.dimensions, + status=config.status, + metadata=config.metadata, + ) + except psycopg.errors.UniqueViolation as exc: + raise EmbeddingConfigValidationError( + _duplicate_embedding_config_message( + provider=config.provider, + model=config.model, + version=config.version, + ) + ) from exc + return {"embedding_config": _serialize_embedding_config(created)} + + +def list_embedding_config_records( + store: ContinuityStore, + *, + user_id: UUID, +) -> EmbeddingConfigListResponse: + del user_id + + configs = store.list_embedding_configs() + items = [_serialize_embedding_config(config) for config in configs] + summary: EmbeddingConfigListSummary = { + "total_count": len(items), + "order": list(EMBEDDING_CONFIG_LIST_ORDER), + } + return { + "items": items, + "summary": summary, + } + + +def upsert_memory_embedding_record( + store: ContinuityStore, + *, + user_id: UUID, + request: MemoryEmbeddingUpsertInput, +) -> MemoryEmbeddingUpsertResponse: + del user_id + + memory = store.get_memory_optional(request.memory_id) + if memory is None: + raise MemoryEmbeddingValidationError( + f"memory_id must reference an existing memory owned by the user: {request.memory_id}" + ) + + config = store.get_embedding_config_optional(request.embedding_config_id) + if config is None: + raise MemoryEmbeddingValidationError( + "embedding_config_id must reference an existing embedding config owned by the user: " + f"{request.embedding_config_id}" + ) + + vector = _validate_vector(request.vector, error_type=MemoryEmbeddingValidationError) + if len(vector) != config["dimensions"]: + raise MemoryEmbeddingValidationError( + "vector length must match embedding config dimensions " + f"({config['dimensions']}): {len(vector)}" + ) + + existing = store.get_memory_embedding_by_memory_and_config_optional( + memory_id=request.memory_id, + embedding_config_id=request.embedding_config_id, + ) + if existing is None: + created = store.create_memory_embedding( + memory_id=request.memory_id, + embedding_config_id=request.embedding_config_id, + dimensions=config["dimensions"], + vector=vector, + ) + return { + "embedding": _serialize_memory_embedding(created), + "write_mode": "created", + } + + updated = store.update_memory_embedding( + memory_embedding_id=existing["id"], + dimensions=config["dimensions"], + vector=vector, + ) + return { + "embedding": _serialize_memory_embedding(updated), + "write_mode": "updated", + } + + +def get_memory_embedding_record( + store: ContinuityStore, + *, + user_id: UUID, + memory_embedding_id: UUID, +) -> MemoryEmbeddingDetailResponse: + del user_id + + embedding = store.get_memory_embedding_optional(memory_embedding_id) + if embedding is None: + raise MemoryEmbeddingNotFoundError(f"memory embedding {memory_embedding_id} was not found") + + return {"embedding": _serialize_memory_embedding(embedding)} + + +def list_memory_embedding_records( + store: ContinuityStore, + *, + user_id: UUID, + memory_id: UUID, +) -> MemoryEmbeddingListResponse: + del user_id + + memory = store.get_memory_optional(memory_id) + if memory is None: + raise MemoryEmbeddingNotFoundError(f"memory {memory_id} was not found") + + embeddings = store.list_memory_embeddings_for_memory(memory_id) + items = [_serialize_memory_embedding(embedding) for embedding in embeddings] + summary: MemoryEmbeddingListSummary = { + "memory_id": str(memory_id), + "total_count": len(items), + "order": list(MEMORY_EMBEDDING_LIST_ORDER), + } + return { + "items": items, + "summary": summary, + } + + +def upsert_task_artifact_chunk_embedding_record( + store: ContinuityStore, + *, + user_id: UUID, + request: TaskArtifactChunkEmbeddingUpsertInput, +) -> TaskArtifactChunkEmbeddingWriteResponse: + del user_id + + chunk = store.get_task_artifact_chunk_optional(request.task_artifact_chunk_id) + if chunk is None: + raise TaskArtifactChunkEmbeddingValidationError( + "task_artifact_chunk_id must reference an existing task artifact chunk owned by the " + f"user: {request.task_artifact_chunk_id}" + ) + + config = store.get_embedding_config_optional(request.embedding_config_id) + if config is None: + raise TaskArtifactChunkEmbeddingValidationError( + "embedding_config_id must reference an existing embedding config owned by the user: " + f"{request.embedding_config_id}" + ) + + vector = _validate_vector(request.vector, error_type=TaskArtifactChunkEmbeddingValidationError) + if len(vector) != config["dimensions"]: + raise TaskArtifactChunkEmbeddingValidationError( + "vector length must match embedding config dimensions " + f"({config['dimensions']}): {len(vector)}" + ) + + existing = store.get_task_artifact_chunk_embedding_by_chunk_and_config_optional( + task_artifact_chunk_id=request.task_artifact_chunk_id, + embedding_config_id=request.embedding_config_id, + ) + if existing is None: + created = store.create_task_artifact_chunk_embedding( + task_artifact_chunk_id=request.task_artifact_chunk_id, + embedding_config_id=request.embedding_config_id, + dimensions=config["dimensions"], + vector=vector, + ) + return { + "embedding": _serialize_task_artifact_chunk_embedding(created), + "write_mode": "created", + } + + updated = store.update_task_artifact_chunk_embedding( + task_artifact_chunk_embedding_id=existing["id"], + dimensions=config["dimensions"], + vector=vector, + ) + return { + "embedding": _serialize_task_artifact_chunk_embedding(updated), + "write_mode": "updated", + } + + +def get_task_artifact_chunk_embedding_record( + store: ContinuityStore, + *, + user_id: UUID, + task_artifact_chunk_embedding_id: UUID, +) -> TaskArtifactChunkEmbeddingDetailResponse: + del user_id + + embedding = store.get_task_artifact_chunk_embedding_optional(task_artifact_chunk_embedding_id) + if embedding is None: + raise TaskArtifactChunkEmbeddingNotFoundError( + f"task artifact chunk embedding {task_artifact_chunk_embedding_id} was not found" + ) + + return {"embedding": _serialize_task_artifact_chunk_embedding(embedding)} + + +def list_task_artifact_chunk_embedding_records_for_artifact( + store: ContinuityStore, + *, + user_id: UUID, + task_artifact_id: UUID, +) -> TaskArtifactChunkEmbeddingListResponse: + del user_id + + artifact = store.get_task_artifact_optional(task_artifact_id) + if artifact is None: + raise TaskArtifactNotFoundError(f"task artifact {task_artifact_id} was not found") + + items = [ + _serialize_task_artifact_chunk_embedding(embedding) + for embedding in store.list_task_artifact_chunk_embeddings_for_artifact(task_artifact_id) + ] + scope = _build_task_artifact_chunk_embedding_scope( + kind="artifact", + task_artifact_id=task_artifact_id, + ) + return { + "items": items, + "summary": _build_task_artifact_chunk_embedding_summary(items=items, scope=scope), + } + + +def list_task_artifact_chunk_embedding_records_for_chunk( + store: ContinuityStore, + *, + user_id: UUID, + task_artifact_chunk_id: UUID, +) -> TaskArtifactChunkEmbeddingListResponse: + del user_id + + chunk = store.get_task_artifact_chunk_optional(task_artifact_chunk_id) + if chunk is None: + raise TaskArtifactChunkEmbeddingNotFoundError( + f"task artifact chunk {task_artifact_chunk_id} was not found" + ) + + items = [ + _serialize_task_artifact_chunk_embedding(embedding) + for embedding in store.list_task_artifact_chunk_embeddings_for_chunk(task_artifact_chunk_id) + ] + scope = _build_task_artifact_chunk_embedding_scope( + kind="chunk", + task_artifact_id=chunk["task_artifact_id"], + task_artifact_chunk_id=task_artifact_chunk_id, + ) + return { + "items": items, + "summary": _build_task_artifact_chunk_embedding_summary(items=items, scope=scope), + } diff --git a/apps/api/src/alicebot_api/entity.py b/apps/api/src/alicebot_api/entity.py new file mode 100644 index 0000000..8e811eb --- /dev/null +++ b/apps/api/src/alicebot_api/entity.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +from uuid import UUID + +from alicebot_api.contracts import ( + ENTITY_LIST_ORDER, + EntityCreateInput, + EntityCreateResponse, + EntityDetailResponse, + EntityListResponse, + EntityListSummary, + EntityRecord, +) +from alicebot_api.store import ContinuityStore, EntityRow + + +class EntityValidationError(ValueError): + """Raised when an entity create request fails explicit validation.""" + + +class EntityNotFoundError(LookupError): + """Raised when a requested entity is not visible inside the current user scope.""" + + +def _serialize_entity(entity: EntityRow) -> EntityRecord: + return { + "id": str(entity["id"]), + "entity_type": entity["entity_type"], + "name": entity["name"], + "source_memory_ids": entity["source_memory_ids"], + "created_at": entity["created_at"].isoformat(), + } + + +def _dedupe_source_memory_ids(source_memory_ids: tuple[UUID, ...]) -> tuple[UUID, ...]: + deduped: list[UUID] = [] + seen: set[UUID] = set() + for source_memory_id in source_memory_ids: + if source_memory_id in seen: + continue + seen.add(source_memory_id) + deduped.append(source_memory_id) + return tuple(deduped) + + +def _validate_source_memories(store: ContinuityStore, source_memory_ids: tuple[UUID, ...]) -> list[str]: + normalized_memory_ids = _dedupe_source_memory_ids(source_memory_ids) + if not normalized_memory_ids: + raise EntityValidationError( + "source_memory_ids must include at least one existing memory owned by the user" + ) + + source_memories = store.list_memories_by_ids(list(normalized_memory_ids)) + found_memory_ids = {memory["id"] for memory in source_memories} + missing_memory_ids = [ + str(source_memory_id) + for source_memory_id in normalized_memory_ids + if source_memory_id not in found_memory_ids + ] + if missing_memory_ids: + raise EntityValidationError( + "source_memory_ids must all reference existing memories owned by the user: " + + ", ".join(missing_memory_ids) + ) + + return [str(source_memory_id) for source_memory_id in normalized_memory_ids] + + +def create_entity_record( + store: ContinuityStore, + *, + user_id: UUID, + entity: EntityCreateInput, +) -> EntityCreateResponse: + del user_id + + source_memory_ids = _validate_source_memories(store, entity.source_memory_ids) + created = store.create_entity( + entity_type=entity.entity_type, + name=entity.name, + source_memory_ids=source_memory_ids, + ) + return {"entity": _serialize_entity(created)} + + +def list_entity_records( + store: ContinuityStore, + *, + user_id: UUID, +) -> EntityListResponse: + del user_id + + entities = store.list_entities() + items = [_serialize_entity(entity) for entity in entities] + summary: EntityListSummary = { + "total_count": len(items), + "order": list(ENTITY_LIST_ORDER), + } + return { + "items": items, + "summary": summary, + } + + +def get_entity_record( + store: ContinuityStore, + *, + user_id: UUID, + entity_id: UUID, +) -> EntityDetailResponse: + del user_id + + entity = store.get_entity_optional(entity_id) + if entity is None: + raise EntityNotFoundError(f"entity {entity_id} was not found") + + return {"entity": _serialize_entity(entity)} diff --git a/apps/api/src/alicebot_api/entity_edge.py b/apps/api/src/alicebot_api/entity_edge.py new file mode 100644 index 0000000..84731a2 --- /dev/null +++ b/apps/api/src/alicebot_api/entity_edge.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from alicebot_api.contracts import ( + ENTITY_EDGE_LIST_ORDER, + EntityEdgeCreateInput, + EntityEdgeCreateResponse, + EntityEdgeListResponse, + EntityEdgeListSummary, + EntityEdgeRecord, + isoformat_or_none, +) +from alicebot_api.entity import EntityNotFoundError +from alicebot_api.store import ContinuityStore, EntityEdgeRow + + +class EntityEdgeValidationError(ValueError): + """Raised when an entity-edge request fails explicit validation.""" + + +def _serialize_entity_edge(edge: EntityEdgeRow) -> EntityEdgeRecord: + return { + "id": str(edge["id"]), + "from_entity_id": str(edge["from_entity_id"]), + "to_entity_id": str(edge["to_entity_id"]), + "relationship_type": edge["relationship_type"], + "valid_from": isoformat_or_none(edge["valid_from"]), + "valid_to": isoformat_or_none(edge["valid_to"]), + "source_memory_ids": edge["source_memory_ids"], + "created_at": edge["created_at"].isoformat(), + } + + +def _dedupe_source_memory_ids(source_memory_ids: tuple[UUID, ...]) -> tuple[UUID, ...]: + deduped: list[UUID] = [] + seen: set[UUID] = set() + for source_memory_id in source_memory_ids: + if source_memory_id in seen: + continue + seen.add(source_memory_id) + deduped.append(source_memory_id) + return tuple(deduped) + + +def _validate_source_memories(store: ContinuityStore, source_memory_ids: tuple[UUID, ...]) -> list[str]: + normalized_memory_ids = _dedupe_source_memory_ids(source_memory_ids) + if not normalized_memory_ids: + raise EntityEdgeValidationError( + "source_memory_ids must include at least one existing memory owned by the user" + ) + + source_memories = store.list_memories_by_ids(list(normalized_memory_ids)) + found_memory_ids = {memory["id"] for memory in source_memories} + missing_memory_ids = [ + str(source_memory_id) + for source_memory_id in normalized_memory_ids + if source_memory_id not in found_memory_ids + ] + if missing_memory_ids: + raise EntityEdgeValidationError( + "source_memory_ids must all reference existing memories owned by the user: " + + ", ".join(missing_memory_ids) + ) + + return [str(source_memory_id) for source_memory_id in normalized_memory_ids] + + +def _validate_entity_exists( + store: ContinuityStore, + *, + field_name: str, + entity_id: UUID, +) -> None: + entity = store.get_entity_optional(entity_id) + if entity is None: + raise EntityEdgeValidationError( + f"{field_name} must reference an existing entity owned by the user: {entity_id}" + ) + + +def _validate_temporal_range(valid_from: datetime | None, valid_to: datetime | None) -> None: + if valid_from is not None and valid_to is not None and valid_to < valid_from: + raise EntityEdgeValidationError("valid_to must be greater than or equal to valid_from") + + +def create_entity_edge_record( + store: ContinuityStore, + *, + user_id: UUID, + edge: EntityEdgeCreateInput, +) -> EntityEdgeCreateResponse: + del user_id + + _validate_entity_exists(store, field_name="from_entity_id", entity_id=edge.from_entity_id) + _validate_entity_exists(store, field_name="to_entity_id", entity_id=edge.to_entity_id) + _validate_temporal_range(edge.valid_from, edge.valid_to) + source_memory_ids = _validate_source_memories(store, edge.source_memory_ids) + + created = store.create_entity_edge( + from_entity_id=edge.from_entity_id, + to_entity_id=edge.to_entity_id, + relationship_type=edge.relationship_type, + valid_from=edge.valid_from, + valid_to=edge.valid_to, + source_memory_ids=source_memory_ids, + ) + return {"edge": _serialize_entity_edge(created)} + + +def list_entity_edge_records( + store: ContinuityStore, + *, + user_id: UUID, + entity_id: UUID, +) -> EntityEdgeListResponse: + del user_id + + entity = store.get_entity_optional(entity_id) + if entity is None: + raise EntityNotFoundError(f"entity {entity_id} was not found") + + edges = store.list_entity_edges_for_entity(entity_id) + items = [_serialize_entity_edge(edge) for edge in edges] + summary: EntityEdgeListSummary = { + "entity_id": str(entity["id"]), + "total_count": len(items), + "order": list(ENTITY_EDGE_LIST_ORDER), + } + return { + "items": items, + "summary": summary, + } diff --git a/apps/api/src/alicebot_api/execution_budgets.py b/apps/api/src/alicebot_api/execution_budgets.py new file mode 100644 index 0000000..6b966b4 --- /dev/null +++ b/apps/api/src/alicebot_api/execution_budgets.py @@ -0,0 +1,998 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from typing import cast +from uuid import UUID, uuid4 + +import psycopg + +from alicebot_api.contracts import ( + DEFAULT_AGENT_PROFILE_ID, + EXECUTION_BUDGET_LIFECYCLE_VERSION_V0, + EXECUTION_BUDGET_LIST_ORDER, + EXECUTION_BUDGET_MATCH_ORDER, + EXECUTION_BUDGET_STATUSES, + TOOL_EXECUTION_LIST_ORDER, + TRACE_KIND_EXECUTION_BUDGET_LIFECYCLE, + ExecutionBudgetCreateInput, + ExecutionBudgetCreateResponse, + ExecutionBudgetDeactivateInput, + ExecutionBudgetDeactivateResponse, + ExecutionBudgetDecisionRecord, + ExecutionBudgetDetailResponse, + ExecutionBudgetLifecycleAction, + ExecutionBudgetLifecycleOutcome, + ExecutionBudgetLifecycleRequestTracePayload, + ExecutionBudgetLifecycleStateTracePayload, + ExecutionBudgetLifecycleSummaryTracePayload, + ExecutionBudgetListResponse, + ExecutionBudgetListSummary, + ExecutionBudgetRecord, + ExecutionBudgetSupersedeInput, + ExecutionBudgetSupersedeResponse, + ToolExecutionResultRecord, + ToolRecord, + ToolRoutingRequestRecord, +) +from alicebot_api.store import ContinuityStore, ExecutionBudgetRow, ToolExecutionRow + + +class ExecutionBudgetValidationError(ValueError): + """Raised when an execution-budget request fails explicit validation.""" + + +class ExecutionBudgetNotFoundError(LookupError): + """Raised when an execution budget is not visible inside the current user scope.""" + + +class ExecutionBudgetLifecycleError(RuntimeError): + """Raised when an execution budget lifecycle transition is invalid.""" + + +@dataclass(frozen=True, slots=True) +class ExecutionBudgetDecision: + record: ExecutionBudgetDecisionRecord + blocked_result: ToolExecutionResultRecord | None + + +@dataclass(frozen=True, slots=True) +class _RequestContextResolution: + request_thread_id: str | None + active_thread_profile_id: str | None + context_resolution: str + context_reason: str | None + + +def serialize_execution_budget_row(row: ExecutionBudgetRow) -> ExecutionBudgetRecord: + return { + "id": str(row["id"]), + "agent_profile_id": cast(str | None, row["agent_profile_id"]), + "tool_key": row["tool_key"], + "domain_hint": row["domain_hint"], + "max_completed_executions": row["max_completed_executions"], + "rolling_window_seconds": row["rolling_window_seconds"], + "status": cast(str, row["status"]), + "deactivated_at": None if row["deactivated_at"] is None else row["deactivated_at"].isoformat(), + "superseded_by_budget_id": ( + None if row["superseded_by_budget_id"] is None else str(row["superseded_by_budget_id"]) + ), + "supersedes_budget_id": ( + None if row["supersedes_budget_id"] is None else str(row["supersedes_budget_id"]) + ), + "created_at": row["created_at"].isoformat(), + } + + +def _validate_budget_scope(*, tool_key: str | None, domain_hint: str | None) -> None: + if tool_key is None and domain_hint is None: + raise ExecutionBudgetValidationError( + "execution budget requires at least one selector: tool_key or domain_hint" + ) + + +def _validate_rolling_window_seconds(rolling_window_seconds: int | None) -> None: + if rolling_window_seconds is not None and rolling_window_seconds <= 0: + raise ExecutionBudgetValidationError( + "rolling_window_seconds must be greater than 0 when provided" + ) + + +def _validate_agent_profile_id( + store: ContinuityStore, + *, + agent_profile_id: str | None, +) -> None: + if agent_profile_id is None: + return + if store.get_agent_profile_optional(agent_profile_id) is None: + raise ExecutionBudgetValidationError( + "agent_profile_id must reference an existing profile in the registry" + ) + + +def _validate_lifecycle_thread(store: ContinuityStore, *, thread_id: UUID) -> dict[str, object]: + thread = store.get_thread_optional(thread_id) + if thread is None: + raise ExecutionBudgetValidationError( + "thread_id must reference an existing thread owned by the user" + ) + return cast(dict[str, object], thread) + + +def _append_trace_events( + store: ContinuityStore, + *, + trace_id: UUID, + trace_events: list[tuple[str, dict[str, object]]], +) -> None: + for sequence_no, (kind, payload) in enumerate(trace_events, start=1): + store.append_trace_event( + trace_id=trace_id, + sequence_no=sequence_no, + kind=kind, + payload=payload, + ) + + +def _trace_summary(trace_id: UUID, trace_events: list[tuple[str, dict[str, object]]]) -> dict[str, object]: + return { + "trace_id": str(trace_id), + "trace_event_count": len(trace_events), + } + + +def _active_budget_rows_for_scope( + store: ContinuityStore, + *, + agent_profile_id: str | None, + tool_key: str | None, + domain_hint: str | None, +) -> list[ExecutionBudgetRow]: + rows = [ + row + for row in store.list_execution_budgets() + if cast(str | None, row["agent_profile_id"]) == agent_profile_id + and row["tool_key"] == tool_key + and row["domain_hint"] == domain_hint + and cast(str, row["status"]) == "active" + ] + return sorted(rows, key=lambda row: (row["created_at"], row["id"])) + + +def _scope_label( + *, + agent_profile_id: str | None, + tool_key: str | None, + domain_hint: str | None, +) -> str: + return ( + f"agent_profile_id={agent_profile_id!r}, " + f"tool_key={tool_key!r}, " + f"domain_hint={domain_hint!r}" + ) + + +def _duplicate_active_scope_message( + *, + agent_profile_id: str | None, + tool_key: str | None, + domain_hint: str | None, +) -> str: + return ( + "active execution budget already exists for selector scope " + f"{_scope_label(agent_profile_id=agent_profile_id, tool_key=tool_key, domain_hint=domain_hint)}" + ) + + +def _is_active_scope_uniqueness_error(exc: psycopg.Error) -> bool: + diag = getattr(exc, "diag", None) + return getattr(diag, "constraint_name", None) == "execution_budgets_one_active_scope_idx" + + +def _invalid_transition_error( + *, + row: ExecutionBudgetRow, + requested_action: ExecutionBudgetLifecycleAction, +) -> ExecutionBudgetLifecycleError: + return ExecutionBudgetLifecycleError( + f"execution budget {row['id']} is {row['status']} and cannot be {requested_action}d" + ) + + +def _record_lifecycle_trace( + store: ContinuityStore, + *, + thread: dict[str, object], + request_payload: ExecutionBudgetLifecycleRequestTracePayload, + state_payload: ExecutionBudgetLifecycleStateTracePayload, + summary_payload: ExecutionBudgetLifecycleSummaryTracePayload, + requested_action: ExecutionBudgetLifecycleAction, + outcome: ExecutionBudgetLifecycleOutcome, +) -> dict[str, object]: + trace = store.create_trace( + user_id=cast(UUID, thread["user_id"]), + thread_id=cast(UUID, thread["id"]), + kind=TRACE_KIND_EXECUTION_BUDGET_LIFECYCLE, + compiler_version=EXECUTION_BUDGET_LIFECYCLE_VERSION_V0, + status="completed", + limits={ + "order": list(EXECUTION_BUDGET_LIST_ORDER), + "match_order": list(EXECUTION_BUDGET_MATCH_ORDER), + "statuses": list(EXECUTION_BUDGET_STATUSES), + "requested_action": requested_action, + "outcome": outcome, + }, + ) + trace_events: list[tuple[str, dict[str, object]]] = [ + ("execution_budget.lifecycle.request", cast(dict[str, object], request_payload)), + ("execution_budget.lifecycle.state", cast(dict[str, object], state_payload)), + ("execution_budget.lifecycle.summary", cast(dict[str, object], summary_payload)), + ] + _append_trace_events(store, trace_id=trace["id"], trace_events=trace_events) + return _trace_summary(trace["id"], trace_events) + + +def create_execution_budget_record( + store: ContinuityStore, + *, + user_id: UUID, + request: ExecutionBudgetCreateInput, +) -> ExecutionBudgetCreateResponse: + del user_id + + _validate_budget_scope(tool_key=request.tool_key, domain_hint=request.domain_hint) + _validate_rolling_window_seconds(request.rolling_window_seconds) + _validate_agent_profile_id(store, agent_profile_id=request.agent_profile_id) + if _active_budget_rows_for_scope( + store, + agent_profile_id=request.agent_profile_id, + tool_key=request.tool_key, + domain_hint=request.domain_hint, + ): + raise ExecutionBudgetValidationError( + _duplicate_active_scope_message( + agent_profile_id=request.agent_profile_id, + tool_key=request.tool_key, + domain_hint=request.domain_hint, + ) + ) + try: + row = store.create_execution_budget( + agent_profile_id=request.agent_profile_id, + tool_key=request.tool_key, + domain_hint=request.domain_hint, + max_completed_executions=request.max_completed_executions, + rolling_window_seconds=request.rolling_window_seconds, + ) + except psycopg.IntegrityError as exc: + if _is_active_scope_uniqueness_error(exc): + raise ExecutionBudgetValidationError( + _duplicate_active_scope_message( + agent_profile_id=request.agent_profile_id, + tool_key=request.tool_key, + domain_hint=request.domain_hint, + ) + ) from exc + raise + return {"execution_budget": serialize_execution_budget_row(row)} + + +def list_execution_budget_records( + store: ContinuityStore, + *, + user_id: UUID, +) -> ExecutionBudgetListResponse: + del user_id + + items = [serialize_execution_budget_row(row) for row in store.list_execution_budgets()] + summary: ExecutionBudgetListSummary = { + "total_count": len(items), + "order": list(EXECUTION_BUDGET_LIST_ORDER), + } + return { + "items": items, + "summary": summary, + } + + +def get_execution_budget_record( + store: ContinuityStore, + *, + user_id: UUID, + execution_budget_id: UUID, +) -> ExecutionBudgetDetailResponse: + del user_id + + row = store.get_execution_budget_optional(execution_budget_id) + if row is None: + raise ExecutionBudgetNotFoundError(f"execution budget {execution_budget_id} was not found") + return {"execution_budget": serialize_execution_budget_row(row)} + + +def deactivate_execution_budget_record( + store: ContinuityStore, + *, + user_id: UUID, + request: ExecutionBudgetDeactivateInput, +) -> ExecutionBudgetDeactivateResponse: + del user_id + + thread = _validate_lifecycle_thread(store, thread_id=request.thread_id) + row = store.get_execution_budget_optional(request.execution_budget_id) + if row is None: + raise ExecutionBudgetNotFoundError( + f"execution budget {request.execution_budget_id} was not found" + ) + + request_payload: ExecutionBudgetLifecycleRequestTracePayload = { + "thread_id": str(request.thread_id), + "execution_budget_id": str(request.execution_budget_id), + "requested_action": "deactivate", + "replacement_max_completed_executions": None, + } + + if cast(str, row["status"]) != "active": + error = _invalid_transition_error(row=row, requested_action="deactivate") + trace = _record_lifecycle_trace( + store, + thread=thread, + request_payload=request_payload, + state_payload={ + "execution_budget_id": str(row["id"]), + "requested_action": "deactivate", + "previous_status": cast(str, row["status"]), + "current_status": cast(str, row["status"]), + "tool_key": row["tool_key"], + "domain_hint": row["domain_hint"], + "max_completed_executions": row["max_completed_executions"], + "rolling_window_seconds": row["rolling_window_seconds"], + "deactivated_at": ( + None if row["deactivated_at"] is None else row["deactivated_at"].isoformat() + ), + "superseded_by_budget_id": ( + None if row["superseded_by_budget_id"] is None else str(row["superseded_by_budget_id"]) + ), + "supersedes_budget_id": ( + None if row["supersedes_budget_id"] is None else str(row["supersedes_budget_id"]) + ), + "replacement_budget_id": None, + "replacement_status": None, + "replacement_max_completed_executions": None, + "replacement_rolling_window_seconds": None, + "rejection_reason": str(error), + }, + summary_payload={ + "execution_budget_id": str(row["id"]), + "requested_action": "deactivate", + "outcome": "rejected", + "replacement_budget_id": None, + "active_budget_id": None, + }, + requested_action="deactivate", + outcome="rejected", + ) + del trace + raise error + + updated = store.deactivate_execution_budget_optional(request.execution_budget_id) + if updated is None: + raise ExecutionBudgetLifecycleError( + f"execution budget {request.execution_budget_id} could not be deactivated" + ) + + trace = _record_lifecycle_trace( + store, + thread=thread, + request_payload=request_payload, + state_payload={ + "execution_budget_id": str(updated["id"]), + "requested_action": "deactivate", + "previous_status": "active", + "current_status": cast(str, updated["status"]), + "tool_key": updated["tool_key"], + "domain_hint": updated["domain_hint"], + "max_completed_executions": updated["max_completed_executions"], + "rolling_window_seconds": updated["rolling_window_seconds"], + "deactivated_at": ( + None if updated["deactivated_at"] is None else updated["deactivated_at"].isoformat() + ), + "superseded_by_budget_id": ( + None if updated["superseded_by_budget_id"] is None else str(updated["superseded_by_budget_id"]) + ), + "supersedes_budget_id": ( + None if updated["supersedes_budget_id"] is None else str(updated["supersedes_budget_id"]) + ), + "replacement_budget_id": None, + "replacement_status": None, + "replacement_max_completed_executions": None, + "replacement_rolling_window_seconds": None, + "rejection_reason": None, + }, + summary_payload={ + "execution_budget_id": str(updated["id"]), + "requested_action": "deactivate", + "outcome": "deactivated", + "replacement_budget_id": None, + "active_budget_id": None, + }, + requested_action="deactivate", + outcome="deactivated", + ) + return { + "execution_budget": serialize_execution_budget_row(updated), + "trace": cast(dict[str, object], trace), + } + + +def supersede_execution_budget_record( + store: ContinuityStore, + *, + user_id: UUID, + request: ExecutionBudgetSupersedeInput, +) -> ExecutionBudgetSupersedeResponse: + del user_id + + thread = _validate_lifecycle_thread(store, thread_id=request.thread_id) + current = store.get_execution_budget_optional(request.execution_budget_id) + if current is None: + raise ExecutionBudgetNotFoundError( + f"execution budget {request.execution_budget_id} was not found" + ) + + request_payload: ExecutionBudgetLifecycleRequestTracePayload = { + "thread_id": str(request.thread_id), + "execution_budget_id": str(request.execution_budget_id), + "requested_action": "supersede", + "replacement_max_completed_executions": request.max_completed_executions, + } + + if cast(str, current["status"]) != "active": + error = _invalid_transition_error(row=current, requested_action="supersede") + trace = _record_lifecycle_trace( + store, + thread=thread, + request_payload=request_payload, + state_payload={ + "execution_budget_id": str(current["id"]), + "requested_action": "supersede", + "previous_status": cast(str, current["status"]), + "current_status": cast(str, current["status"]), + "tool_key": current["tool_key"], + "domain_hint": current["domain_hint"], + "max_completed_executions": current["max_completed_executions"], + "rolling_window_seconds": current["rolling_window_seconds"], + "deactivated_at": ( + None if current["deactivated_at"] is None else current["deactivated_at"].isoformat() + ), + "superseded_by_budget_id": ( + None if current["superseded_by_budget_id"] is None else str(current["superseded_by_budget_id"]) + ), + "supersedes_budget_id": ( + None if current["supersedes_budget_id"] is None else str(current["supersedes_budget_id"]) + ), + "replacement_budget_id": None, + "replacement_status": None, + "replacement_max_completed_executions": request.max_completed_executions, + "replacement_rolling_window_seconds": current["rolling_window_seconds"], + "rejection_reason": str(error), + }, + summary_payload={ + "execution_budget_id": str(current["id"]), + "requested_action": "supersede", + "outcome": "rejected", + "replacement_budget_id": None, + "active_budget_id": str(current["id"]) if cast(str, current["status"]) == "active" else None, + }, + requested_action="supersede", + outcome="rejected", + ) + del trace + raise error + + active_scope_rows = _active_budget_rows_for_scope( + store, + agent_profile_id=cast(str | None, current["agent_profile_id"]), + tool_key=current["tool_key"], + domain_hint=current["domain_hint"], + ) + if [row["id"] for row in active_scope_rows] != [current["id"]]: + error = ExecutionBudgetLifecycleError( + "execution budget selector scope must have exactly one active budget to supersede: " + f"{_scope_label(agent_profile_id=cast(str | None, current['agent_profile_id']), tool_key=current['tool_key'], domain_hint=current['domain_hint'])}" + ) + trace = _record_lifecycle_trace( + store, + thread=thread, + request_payload=request_payload, + state_payload={ + "execution_budget_id": str(current["id"]), + "requested_action": "supersede", + "previous_status": "active", + "current_status": "active", + "tool_key": current["tool_key"], + "domain_hint": current["domain_hint"], + "max_completed_executions": current["max_completed_executions"], + "rolling_window_seconds": current["rolling_window_seconds"], + "deactivated_at": None, + "superseded_by_budget_id": None, + "supersedes_budget_id": ( + None if current["supersedes_budget_id"] is None else str(current["supersedes_budget_id"]) + ), + "replacement_budget_id": None, + "replacement_status": None, + "replacement_max_completed_executions": request.max_completed_executions, + "replacement_rolling_window_seconds": current["rolling_window_seconds"], + "rejection_reason": str(error), + }, + summary_payload={ + "execution_budget_id": str(current["id"]), + "requested_action": "supersede", + "outcome": "rejected", + "replacement_budget_id": None, + "active_budget_id": str(current["id"]), + }, + requested_action="supersede", + outcome="rejected", + ) + del trace + raise error + + replacement_budget_id = uuid4() + try: + with store.conn.transaction(): + superseded = store.supersede_execution_budget_optional( + execution_budget_id=request.execution_budget_id, + superseded_by_budget_id=replacement_budget_id, + ) + if superseded is None: + raise ExecutionBudgetLifecycleError( + f"execution budget {request.execution_budget_id} could not be superseded" + ) + replacement = store.create_execution_budget( + budget_id=replacement_budget_id, + agent_profile_id=cast(str | None, current["agent_profile_id"]), + tool_key=current["tool_key"], + domain_hint=current["domain_hint"], + max_completed_executions=request.max_completed_executions, + rolling_window_seconds=current["rolling_window_seconds"], + supersedes_budget_id=current["id"], + ) + except psycopg.IntegrityError as exc: + if _is_active_scope_uniqueness_error(exc): + error = ExecutionBudgetLifecycleError( + _duplicate_active_scope_message( + agent_profile_id=cast(str | None, current["agent_profile_id"]), + tool_key=current["tool_key"], + domain_hint=current["domain_hint"], + ) + ) + else: + raise + except ExecutionBudgetLifecycleError as exc: + error = exc + else: + error = None + + if error is not None: + current_state = store.get_execution_budget_optional(request.execution_budget_id) + if current_state is None: + raise ExecutionBudgetNotFoundError( + f"execution budget {request.execution_budget_id} was not found" + ) + trace = _record_lifecycle_trace( + store, + thread=thread, + request_payload=request_payload, + state_payload={ + "execution_budget_id": str(current_state["id"]), + "requested_action": "supersede", + "previous_status": cast(str, current["status"]), + "current_status": cast(str, current_state["status"]), + "tool_key": current_state["tool_key"], + "domain_hint": current_state["domain_hint"], + "max_completed_executions": current_state["max_completed_executions"], + "rolling_window_seconds": current_state["rolling_window_seconds"], + "deactivated_at": ( + None + if current_state["deactivated_at"] is None + else current_state["deactivated_at"].isoformat() + ), + "superseded_by_budget_id": ( + None + if current_state["superseded_by_budget_id"] is None + else str(current_state["superseded_by_budget_id"]) + ), + "supersedes_budget_id": ( + None + if current_state["supersedes_budget_id"] is None + else str(current_state["supersedes_budget_id"]) + ), + "replacement_budget_id": None, + "replacement_status": None, + "replacement_max_completed_executions": request.max_completed_executions, + "replacement_rolling_window_seconds": current["rolling_window_seconds"], + "rejection_reason": str(error), + }, + summary_payload={ + "execution_budget_id": str(current_state["id"]), + "requested_action": "supersede", + "outcome": "rejected", + "replacement_budget_id": None, + "active_budget_id": ( + str(current_state["id"]) + if cast(str, current_state["status"]) == "active" + else None + ), + }, + requested_action="supersede", + outcome="rejected", + ) + del trace + raise error + + trace = _record_lifecycle_trace( + store, + thread=thread, + request_payload=request_payload, + state_payload={ + "execution_budget_id": str(superseded["id"]), + "requested_action": "supersede", + "previous_status": "active", + "current_status": cast(str, superseded["status"]), + "tool_key": superseded["tool_key"], + "domain_hint": superseded["domain_hint"], + "max_completed_executions": superseded["max_completed_executions"], + "rolling_window_seconds": superseded["rolling_window_seconds"], + "deactivated_at": ( + None if superseded["deactivated_at"] is None else superseded["deactivated_at"].isoformat() + ), + "superseded_by_budget_id": ( + None if superseded["superseded_by_budget_id"] is None else str(superseded["superseded_by_budget_id"]) + ), + "supersedes_budget_id": ( + None if superseded["supersedes_budget_id"] is None else str(superseded["supersedes_budget_id"]) + ), + "replacement_budget_id": str(replacement["id"]), + "replacement_status": cast(str, replacement["status"]), + "replacement_max_completed_executions": replacement["max_completed_executions"], + "replacement_rolling_window_seconds": replacement["rolling_window_seconds"], + "rejection_reason": None, + }, + summary_payload={ + "execution_budget_id": str(superseded["id"]), + "requested_action": "supersede", + "outcome": "superseded", + "replacement_budget_id": str(replacement["id"]), + "active_budget_id": str(replacement["id"]), + }, + requested_action="supersede", + outcome="superseded", + ) + return { + "superseded_budget": serialize_execution_budget_row(superseded), + "replacement_budget": serialize_execution_budget_row(replacement), + "trace": cast(dict[str, object], trace), + } + + +def _budget_specificity(row: ExecutionBudgetRow) -> int: + return int(row["tool_key"] is not None) + int(row["domain_hint"] is not None) + + +def _matches_budget( + row: ExecutionBudgetRow, + *, + tool_key: str, + domain_hint: str | None, +) -> bool: + if row["tool_key"] is not None and row["tool_key"] != tool_key: + return False + if row["domain_hint"] is not None and row["domain_hint"] != domain_hint: + return False + return True + + +def _matching_budget_rows( + store: ContinuityStore, + *, + active_thread_profile_id: str | None, + tool_key: str, + domain_hint: str | None, +) -> list[ExecutionBudgetRow]: + scoped_rows: list[ExecutionBudgetRow] = [] + global_rows: list[ExecutionBudgetRow] = [] + for row in store.list_execution_budgets(): + budget_row = cast(ExecutionBudgetRow, row) + if cast(str, budget_row["status"]) != "active": + continue + if not _matches_budget(budget_row, tool_key=tool_key, domain_hint=domain_hint): + continue + budget_profile_id = cast(str | None, budget_row["agent_profile_id"]) + if active_thread_profile_id is not None and budget_profile_id == active_thread_profile_id: + scoped_rows.append(budget_row) + elif budget_profile_id is None: + global_rows.append(budget_row) + scoped_rows.sort(key=lambda row: (-_budget_specificity(row), row["created_at"], row["id"])) + global_rows.sort(key=lambda row: (-_budget_specificity(row), row["created_at"], row["id"])) + return [*scoped_rows, *global_rows] + + +def _thread_profile_id_optional( + store: ContinuityStore, + *, + thread_id: UUID, +) -> str | None: + thread = store.get_thread_optional(thread_id) + if thread is None: + return None + profile_id = cast(str | None, thread.get("agent_profile_id")) + if profile_id is None: + profile_id = DEFAULT_AGENT_PROFILE_ID + if store.get_agent_profile_optional(profile_id) is None: + return None + return profile_id + + +def _resolve_request_context( + store: ContinuityStore, + *, + request: ToolRoutingRequestRecord, +) -> _RequestContextResolution: + request_thread_id = request.get("thread_id") + if not isinstance(request_thread_id, str): + return _RequestContextResolution( + request_thread_id=None, + active_thread_profile_id=None, + context_resolution="invalid", + context_reason="request.thread_id is missing", + ) + try: + thread_id = UUID(request_thread_id) + except (TypeError, ValueError): + return _RequestContextResolution( + request_thread_id=request_thread_id, + active_thread_profile_id=None, + context_resolution="invalid", + context_reason=f"request.thread_id '{request_thread_id}' is not a valid UUID", + ) + active_thread_profile_id = _thread_profile_id_optional(store, thread_id=thread_id) + if active_thread_profile_id is None: + return _RequestContextResolution( + request_thread_id=request_thread_id, + active_thread_profile_id=None, + context_resolution="invalid", + context_reason=( + f"request.thread_id '{request_thread_id}' did not resolve to a visible " + "thread/profile context" + ), + ) + return _RequestContextResolution( + request_thread_id=request_thread_id, + active_thread_profile_id=active_thread_profile_id, + context_resolution="resolved", + context_reason=None, + ) + + +def _count_profile_scope_id( + *, + matched_budget: ExecutionBudgetRow, + active_thread_profile_id: str | None, +) -> str | None: + matched_budget_profile_id = cast(str | None, matched_budget["agent_profile_id"]) + if matched_budget_profile_id is not None: + return matched_budget_profile_id + return active_thread_profile_id + + +def _execution_matches_budget(row: ToolExecutionRow, budget: ExecutionBudgetRow) -> bool: + if cast(str, row["status"]) != "completed": + return False + + tool = cast(dict[str, object], row["tool"]) + request = cast(dict[str, object], row["request"]) + + if budget["tool_key"] is not None and tool.get("tool_key") != budget["tool_key"]: + return False + if budget["domain_hint"] is not None and request.get("domain_hint") != budget["domain_hint"]: + return False + return True + + +def _current_time(store: ContinuityStore) -> datetime: + current_time = getattr(store, "current_time", None) + if callable(current_time): + value = current_time() + if isinstance(value, datetime): + return value + return datetime.now(UTC) + + +def _window_started_at( + *, + evaluation_time: datetime, + rolling_window_seconds: int | None, +) -> datetime | None: + if rolling_window_seconds is None: + return None + return evaluation_time - timedelta(seconds=rolling_window_seconds) + + +def _counted_completed_execution_rows( + store: ContinuityStore, + *, + matched_budget: ExecutionBudgetRow, + evaluation_time: datetime, + count_profile_scope_id: str | None, +) -> list[ToolExecutionRow]: + window_started_at = _window_started_at( + evaluation_time=evaluation_time, + rolling_window_seconds=matched_budget["rolling_window_seconds"], + ) + counted_rows: list[ToolExecutionRow] = [] + thread_profile_cache: dict[UUID, str | None] = {} + for row in store.list_tool_executions(): + execution_row = cast(ToolExecutionRow, row) + if not _execution_matches_budget(execution_row, matched_budget): + continue + request_payload = cast(dict[str, object], execution_row["request"]) + request_thread_id_raw = request_payload.get("thread_id") + if not isinstance(request_thread_id_raw, str): + continue + try: + request_thread_id = UUID(request_thread_id_raw) + except (TypeError, ValueError): + continue + if request_thread_id != execution_row["thread_id"]: + continue + if request_thread_id not in thread_profile_cache: + thread_profile_cache[request_thread_id] = _thread_profile_id_optional( + store, + thread_id=request_thread_id, + ) + row_profile_id = thread_profile_cache[request_thread_id] + if row_profile_id is None: + continue + if count_profile_scope_id is not None and row_profile_id != count_profile_scope_id: + continue + if window_started_at is not None and execution_row["executed_at"] < window_started_at: + continue + counted_rows.append(execution_row) + return counted_rows + + +def _blocked_result( + decision: ExecutionBudgetDecisionRecord, +) -> ToolExecutionResultRecord: + matched_budget_id = decision["matched_budget_id"] + max_completed_executions = decision["max_completed_executions"] + projected_completed_execution_count = decision["projected_completed_execution_count"] + rolling_window_seconds = decision["rolling_window_seconds"] + if rolling_window_seconds is None: + reason = ( + f"execution budget {matched_budget_id} blocks execution: projected completed executions " + f"{projected_completed_execution_count} would exceed limit {max_completed_executions}" + ) + else: + reason = ( + f"execution budget {matched_budget_id} blocks execution: projected completed executions " + f"{projected_completed_execution_count} within rolling window {rolling_window_seconds} " + f"seconds would exceed limit {max_completed_executions}" + ) + return { + "handler_key": None, + "status": "blocked", + "output": None, + "reason": reason, + "budget_decision": decision, + } + + +def _invalid_context_blocked_result( + decision: ExecutionBudgetDecisionRecord, +) -> ToolExecutionResultRecord: + context_reason = cast(str | None, decision.get("context_reason")) + reason = "execution budget invariance blocks execution: invalid request thread/profile context" + if context_reason is not None: + reason = f"{reason}: {context_reason}" + return { + "handler_key": None, + "status": "blocked", + "output": None, + "reason": reason, + "budget_decision": decision, + } + + +def evaluate_execution_budget( + store: ContinuityStore, + *, + tool: ToolRecord, + request: ToolRoutingRequestRecord, +) -> ExecutionBudgetDecision: + request_context = _resolve_request_context(store, request=request) + active_thread_profile_id = request_context.active_thread_profile_id + matching_budgets = ( + [] + if request_context.context_resolution == "invalid" + else _matching_budget_rows( + store, + active_thread_profile_id=active_thread_profile_id, + tool_key=tool["tool_key"], + domain_hint=request["domain_hint"], + ) + ) + matched_budget = matching_budgets[0] if matching_budgets else None + evaluation_time = _current_time(store) + window_started_at = ( + None + if matched_budget is None + else _window_started_at( + evaluation_time=evaluation_time, + rolling_window_seconds=matched_budget["rolling_window_seconds"], + ) + ) + completed_execution_count = 0 + projected_completed_execution_count = 1 + + if matched_budget is not None: + completed_execution_count = len( + _counted_completed_execution_rows( + store, + matched_budget=matched_budget, + evaluation_time=evaluation_time, + count_profile_scope_id=_count_profile_scope_id( + matched_budget=matched_budget, + active_thread_profile_id=active_thread_profile_id, + ), + ) + ) + projected_completed_execution_count = completed_execution_count + 1 + + record: ExecutionBudgetDecisionRecord = { + "matched_budget_id": None if matched_budget is None else str(matched_budget["id"]), + "tool_key": tool["tool_key"], + "domain_hint": request["domain_hint"], + "budget_tool_key": None if matched_budget is None else matched_budget["tool_key"], + "budget_domain_hint": None if matched_budget is None else matched_budget["domain_hint"], + "max_completed_executions": ( + None if matched_budget is None else matched_budget["max_completed_executions"] + ), + "rolling_window_seconds": ( + None if matched_budget is None else matched_budget["rolling_window_seconds"] + ), + "count_scope": ( + "lifetime" + if matched_budget is None or matched_budget["rolling_window_seconds"] is None + else "rolling_window" + ), + "window_started_at": None if window_started_at is None else window_started_at.isoformat(), + "completed_execution_count": completed_execution_count, + "projected_completed_execution_count": projected_completed_execution_count, + "decision": "allow", + "reason": "no_matching_budget", + "order": list(EXECUTION_BUDGET_MATCH_ORDER), + "history_order": list(TOOL_EXECUTION_LIST_ORDER), + } + + if request_context.context_resolution == "invalid": + record["decision"] = "block" + record["reason"] = "invalid_request_context" + record["request_thread_id"] = request_context.request_thread_id + record["context_resolution"] = request_context.context_resolution + record["context_reason"] = request_context.context_reason + blocked_result = _invalid_context_blocked_result(record) + return ExecutionBudgetDecision(record=record, blocked_result=blocked_result) + + if matched_budget is None: + return ExecutionBudgetDecision(record=record, blocked_result=None) + + if projected_completed_execution_count <= matched_budget["max_completed_executions"]: + record["reason"] = "within_budget" + return ExecutionBudgetDecision(record=record, blocked_result=None) + + record["decision"] = "block" + record["reason"] = "budget_exceeded" + blocked_result = _blocked_result(record) + return ExecutionBudgetDecision(record=record, blocked_result=blocked_result) diff --git a/apps/api/src/alicebot_api/executions.py b/apps/api/src/alicebot_api/executions.py new file mode 100644 index 0000000..6350370 --- /dev/null +++ b/apps/api/src/alicebot_api/executions.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from typing import cast +from uuid import UUID + +from alicebot_api.contracts import ( + TOOL_EXECUTION_LIST_ORDER, + ToolExecutionDetailResponse, + ToolExecutionListResponse, + ToolExecutionListSummary, + ToolExecutionRecord, +) +from alicebot_api.store import ContinuityStore, ToolExecutionRow + + +class ToolExecutionNotFoundError(LookupError): + """Raised when an execution record is not visible inside the current user scope.""" + + +def serialize_tool_execution_row(row: ToolExecutionRow) -> ToolExecutionRecord: + return { + "id": str(row["id"]), + "approval_id": str(row["approval_id"]), + "task_run_id": None if row.get("task_run_id") is None else str(cast(UUID, row["task_run_id"])), + "task_step_id": str(row["task_step_id"]), + "thread_id": str(row["thread_id"]), + "tool_id": str(row["tool_id"]), + "trace_id": str(row["trace_id"]), + "request_event_id": None if row["request_event_id"] is None else str(row["request_event_id"]), + "result_event_id": None if row["result_event_id"] is None else str(row["result_event_id"]), + "status": cast(str, row["status"]), + "handler_key": row["handler_key"], + "idempotency_key": cast(str | None, row.get("idempotency_key")), + "request": cast(dict[str, object], row["request"]), + "tool": cast(dict[str, object], row["tool"]), + "result": cast(dict[str, object], row["result"]), + "executed_at": row["executed_at"].isoformat(), + } + + +def list_tool_execution_records( + store: ContinuityStore, + *, + user_id: UUID, +) -> ToolExecutionListResponse: + del user_id + + items = [serialize_tool_execution_row(row) for row in store.list_tool_executions()] + summary: ToolExecutionListSummary = { + "total_count": len(items), + "order": list(TOOL_EXECUTION_LIST_ORDER), + } + return { + "items": items, + "summary": summary, + } + + +def get_tool_execution_record( + store: ContinuityStore, + *, + user_id: UUID, + execution_id: UUID, +) -> ToolExecutionDetailResponse: + del user_id + + execution = store.get_tool_execution_optional(execution_id) + if execution is None: + raise ToolExecutionNotFoundError(f"tool execution {execution_id} was not found") + return {"execution": serialize_tool_execution_row(execution)} diff --git a/apps/api/src/alicebot_api/explicit_commitments.py b/apps/api/src/alicebot_api/explicit_commitments.py new file mode 100644 index 0000000..d5c51b2 --- /dev/null +++ b/apps/api/src/alicebot_api/explicit_commitments.py @@ -0,0 +1,325 @@ +from __future__ import annotations + +import hashlib +import re +from collections.abc import Sequence +from uuid import UUID + +from alicebot_api.contracts import ( + AdmissionDecisionOutput, + ExplicitCommitmentAdmissionRecord, + ExplicitCommitmentExtractionRequestInput, + ExplicitCommitmentExtractionResponse, + ExplicitCommitmentExtractionSummary, + ExplicitCommitmentOpenLoopOutcome, + ExplicitCommitmentPattern, + ExtractedCommitmentCandidateRecord, + MemoryCandidateInput, + OpenLoopRecord, +) +from alicebot_api.memory import admit_memory_candidate +from alicebot_api.store import ContinuityStore, EventRow, OpenLoopRow + +_DIRECT_PATTERNS: tuple[tuple[ExplicitCommitmentPattern, re.Pattern[str]], ...] = ( + ("remind_me_to", re.compile(r"^remind me to (?P.+)$", re.IGNORECASE)), + ("i_need_to", re.compile(r"^i need to (?P.+)$", re.IGNORECASE)), + ( + "dont_let_me_forget_to", + re.compile(r"^don'?t let me forget to (?P.+)$", re.IGNORECASE), + ), + ("remember_to", re.compile(r"^remember to (?P.+)$", re.IGNORECASE)), +) +_TRAILING_PUNCTUATION = ".!?" +_MEMORY_KEY_PREFIX = "user.commitment." +_MAX_MEMORY_KEY_LENGTH = 200 +_MEMORY_KEY_HASH_LENGTH = 12 +_MAX_COMMITMENT_TOKENS = 20 +_MAX_COMMITMENT_CHARACTERS = 180 +_ALLOWED_COMMITMENT_TOKEN = re.compile(r"^[a-z0-9][a-z0-9+#&./+'-]*$", re.IGNORECASE) +_DISALLOWED_COMMITMENT_PREFIX_TOKENS = { + "that", + "if", + "when", + "because", + "whether", +} + + +class ExplicitCommitmentExtractionValidationError(ValueError): + """Raised when an explicit-commitment extraction request is invalid.""" + + +def _normalize_whitespace(value: str) -> str: + return re.sub(r"\s+", " ", value).strip() + + +def _normalize_commitment_text(commitment_text: str) -> str: + normalized = _normalize_whitespace(commitment_text) + normalized = normalized.rstrip(_TRAILING_PUNCTUATION).strip() + return normalized + + +def _canonicalize_commitment_for_key(commitment_text: str) -> str: + return commitment_text.casefold() + + +def _commitment_has_supported_shape(commitment_text: str) -> bool: + if len(commitment_text) > _MAX_COMMITMENT_CHARACTERS: + return False + + tokens = commitment_text.split(" ") + if not tokens or len(tokens) > _MAX_COMMITMENT_TOKENS: + return False + + if tokens[0].casefold() in _DISALLOWED_COMMITMENT_PREFIX_TOKENS: + return False + + return all(_ALLOWED_COMMITMENT_TOKEN.fullmatch(token) is not None for token in tokens) + + +def _slugify_commitment(commitment_text: str, *, max_length: int) -> str: + slug = commitment_text.casefold() + slug = slug.replace("'", "") + slug = re.sub(r"[^a-z0-9]+", "_", slug) + slug = slug.strip("_") + if len(slug) > max_length: + slug = slug[:max_length].rstrip("_") + return slug + + +def _build_memory_key(commitment_text: str) -> str: + canonical_commitment = _canonicalize_commitment_for_key(commitment_text) + digest = hashlib.sha256(canonical_commitment.encode("utf-8")).hexdigest()[:_MEMORY_KEY_HASH_LENGTH] + max_slug_length = _MAX_MEMORY_KEY_LENGTH - len(_MEMORY_KEY_PREFIX) - len("__") - len(digest) + slug = _slugify_commitment(canonical_commitment, max_length=max_slug_length) + if not slug: + return f"{_MEMORY_KEY_PREFIX}{digest}" + return f"{_MEMORY_KEY_PREFIX}{slug}__{digest}" + + +def _build_open_loop_title(commitment_text: str) -> str: + return f"Remember to {commitment_text}" + + +def _build_candidate( + *, + source_event_id: UUID, + pattern: ExplicitCommitmentPattern, + commitment_text: str, +) -> ExtractedCommitmentCandidateRecord | None: + normalized_commitment = _normalize_commitment_text(commitment_text) + if not normalized_commitment: + return None + + if not _commitment_has_supported_shape(normalized_commitment): + return None + + return { + "memory_key": _build_memory_key(normalized_commitment), + "value": { + "kind": "explicit_commitment", + "text": normalized_commitment, + }, + "source_event_ids": [str(source_event_id)], + "delete_requested": False, + "pattern": pattern, + "commitment_text": normalized_commitment, + "open_loop_title": _build_open_loop_title(normalized_commitment), + } + + +def extract_explicit_commitment_candidates( + *, + source_event_id: UUID, + text: str, +) -> list[ExtractedCommitmentCandidateRecord]: + normalized_text = _normalize_whitespace(text) + if not normalized_text: + return [] + + for pattern_name, pattern in _DIRECT_PATTERNS: + match = pattern.fullmatch(normalized_text) + if match is None: + continue + candidate = _build_candidate( + source_event_id=source_event_id, + pattern=pattern_name, + commitment_text=match.group("commitment"), + ) + return [] if candidate is None else [candidate] + + return [] + + +def _get_single_source_event(store: ContinuityStore, source_event_id: UUID) -> EventRow: + events = store.list_events_by_ids([source_event_id]) + if not events: + raise ExplicitCommitmentExtractionValidationError( + "source_event_id must reference an existing message.user event owned by the user" + ) + return events[0] + + +def _extract_text_payload(event: EventRow) -> str: + if event["kind"] != "message.user": + raise ExplicitCommitmentExtractionValidationError( + "source_event_id must reference an existing message.user event owned by the user" + ) + + payload_text = event["payload"].get("text") + if not isinstance(payload_text, str): + raise ExplicitCommitmentExtractionValidationError( + "source_event_id must reference a message.user event with string payload.text" + ) + + return payload_text + + +def _serialize_open_loop(open_loop: OpenLoopRow) -> OpenLoopRecord: + return { + "id": str(open_loop["id"]), + "memory_id": None if open_loop["memory_id"] is None else str(open_loop["memory_id"]), + "title": open_loop["title"], + "status": open_loop["status"], + "opened_at": open_loop["opened_at"].isoformat(), + "due_at": None if open_loop["due_at"] is None else open_loop["due_at"].isoformat(), + "resolved_at": ( + None if open_loop["resolved_at"] is None else open_loop["resolved_at"].isoformat() + ), + "resolution_note": open_loop["resolution_note"], + "created_at": open_loop["created_at"].isoformat(), + "updated_at": open_loop["updated_at"].isoformat(), + } + + +def _find_active_open_loop_for_memory(store: ContinuityStore, memory_id: UUID) -> OpenLoopRow | None: + for open_loop in store.list_open_loops(status="open"): + if open_loop["memory_id"] == memory_id: + return open_loop + return None + + +def _extract_memory_id(decision: AdmissionDecisionOutput) -> UUID | None: + if decision.memory is None: + return None + return UUID(decision.memory["id"]) + + +def _resolve_open_loop_outcome( + store: ContinuityStore, + *, + memory_id: UUID | None, + open_loop_title: str, +) -> ExplicitCommitmentOpenLoopOutcome: + if memory_id is None: + return { + "decision": "NOOP_MEMORY_NOT_PERSISTED", + "reason": "memory_not_persisted", + "open_loop": None, + } + + existing = _find_active_open_loop_for_memory(store, memory_id) + if existing is not None: + return { + "decision": "NOOP_ACTIVE_EXISTS", + "reason": "active_open_loop_exists_for_memory", + "open_loop": _serialize_open_loop(existing), + } + + created = store.create_open_loop( + memory_id=memory_id, + title=open_loop_title, + status="open", + opened_at=None, + due_at=None, + resolved_at=None, + resolution_note=None, + ) + return { + "decision": "CREATED", + "reason": "created_open_loop_for_memory", + "open_loop": _serialize_open_loop(created), + } + + +def _serialize_admission( + decision: AdmissionDecisionOutput, + open_loop_outcome: ExplicitCommitmentOpenLoopOutcome, +) -> ExplicitCommitmentAdmissionRecord: + return { + "decision": decision.action, + "reason": decision.reason, + "memory": decision.memory, + "revision": decision.revision, + "open_loop": open_loop_outcome, + } + + +def _build_summary( + *, + source_event_id: UUID, + source_event_kind: str, + admissions: Sequence[ExplicitCommitmentAdmissionRecord], + candidates: Sequence[ExtractedCommitmentCandidateRecord], +) -> ExplicitCommitmentExtractionSummary: + noop_count = sum(1 for admission in admissions if admission["decision"] == "NOOP") + open_loop_created_count = sum( + 1 + for admission in admissions + if admission["open_loop"]["decision"] == "CREATED" + ) + return { + "source_event_id": str(source_event_id), + "source_event_kind": source_event_kind, + "candidate_count": len(candidates), + "admission_count": len(admissions), + "persisted_change_count": len(admissions) - noop_count, + "noop_count": noop_count, + "open_loop_created_count": open_loop_created_count, + "open_loop_noop_count": len(admissions) - open_loop_created_count, + } + + +def extract_and_admit_explicit_commitments( + store: ContinuityStore, + *, + user_id: UUID, + request: ExplicitCommitmentExtractionRequestInput, +) -> ExplicitCommitmentExtractionResponse: + source_event = _get_single_source_event(store, request.source_event_id) + payload_text = _extract_text_payload(source_event) + candidates = extract_explicit_commitment_candidates( + source_event_id=request.source_event_id, + text=payload_text, + ) + + admissions: list[ExplicitCommitmentAdmissionRecord] = [] + for candidate in candidates: + decision = admit_memory_candidate( + store, + user_id=user_id, + candidate=MemoryCandidateInput( + memory_key=candidate["memory_key"], + value=candidate["value"], + source_event_ids=(request.source_event_id,), + delete_requested=candidate["delete_requested"], + memory_type="commitment", + ), + ) + open_loop_outcome = _resolve_open_loop_outcome( + store, + memory_id=_extract_memory_id(decision), + open_loop_title=candidate["open_loop_title"], + ) + admissions.append(_serialize_admission(decision, open_loop_outcome)) + + return { + "candidates": list(candidates), + "admissions": admissions, + "summary": _build_summary( + source_event_id=request.source_event_id, + source_event_kind=source_event["kind"], + admissions=admissions, + candidates=candidates, + ), + } diff --git a/apps/api/src/alicebot_api/explicit_preferences.py b/apps/api/src/alicebot_api/explicit_preferences.py new file mode 100644 index 0000000..f451426 --- /dev/null +++ b/apps/api/src/alicebot_api/explicit_preferences.py @@ -0,0 +1,262 @@ +from __future__ import annotations + +import hashlib +import re +from collections.abc import Sequence +from typing import Literal +from uuid import UUID + +from alicebot_api.contracts import ( + AdmissionDecisionOutput, + ExplicitPreferenceAdmissionRecord, + ExplicitPreferenceExtractionRequestInput, + ExplicitPreferenceExtractionResponse, + ExplicitPreferenceExtractionSummary, + ExplicitPreferencePattern, + ExtractedPreferenceCandidateRecord, + MemoryCandidateInput, +) +from alicebot_api.memory import admit_memory_candidate +from alicebot_api.store import ContinuityStore, EventRow, JsonObject + +PreferenceKind = Literal["like", "dislike", "prefer"] +_DIRECT_PATTERNS: tuple[tuple[ExplicitPreferencePattern, PreferenceKind, re.Pattern[str]], ...] = ( + ("i_like", "like", re.compile(r"^i like (?P.+)$", re.IGNORECASE)), + ("i_dont_like", "dislike", re.compile(r"^i don't like (?P.+)$", re.IGNORECASE)), + ("i_prefer", "prefer", re.compile(r"^i prefer (?P.+)$", re.IGNORECASE)), +) +_REMEMBER_PREFIX = "remember that " +_TRAILING_PUNCTUATION = ".!?" +_MEMORY_KEY_PREFIX = "user.preference." +_MAX_MEMORY_KEY_LENGTH = 200 +_MEMORY_KEY_HASH_LENGTH = 12 +_MAX_SUBJECT_TOKENS = 6 +_ALLOWED_SUBJECT_TOKEN = re.compile(r"^[a-z0-9][a-z0-9+#&./+'-]*$", re.IGNORECASE) +_DISALLOWED_SUBJECT_PREFIX_TOKENS = { + "that", + "to", + "if", + "when", + "because", + "whether", + "we", + "you", + "they", + "he", + "she", + "it", + "there", + "this", +} +_REMEMBER_PATTERN_MAP: dict[ExplicitPreferencePattern, ExplicitPreferencePattern] = { + "i_like": "remember_that_i_like", + "i_dont_like": "remember_that_i_dont_like", + "i_prefer": "remember_that_i_prefer", + "remember_that_i_like": "remember_that_i_like", + "remember_that_i_dont_like": "remember_that_i_dont_like", + "remember_that_i_prefer": "remember_that_i_prefer", +} + + +class ExplicitPreferenceExtractionValidationError(ValueError): + """Raised when an explicit-preference extraction request is invalid.""" + + +def _normalize_whitespace(value: str) -> str: + return re.sub(r"\s+", " ", value).strip() + + +def _normalize_subject(subject: str) -> str: + normalized = _normalize_whitespace(subject) + normalized = normalized.rstrip(_TRAILING_PUNCTUATION).strip() + return normalized + + +def _canonicalize_subject_for_key(subject: str) -> str: + return subject.casefold() + + +def _subject_has_supported_shape(subject: str) -> bool: + tokens = subject.split(" ") + if not tokens or len(tokens) > _MAX_SUBJECT_TOKENS: + return False + + if tokens[0].casefold() in _DISALLOWED_SUBJECT_PREFIX_TOKENS: + return False + + return all(_ALLOWED_SUBJECT_TOKEN.fullmatch(token) is not None for token in tokens) + + +def _slugify_subject(subject: str, *, max_length: int) -> str: + slug = subject.casefold() + slug = slug.replace("'", "") + slug = re.sub(r"[^a-z0-9]+", "_", slug) + slug = slug.strip("_") + if len(slug) > max_length: + slug = slug[:max_length].rstrip("_") + return slug + + +def _build_memory_key(subject: str) -> str: + canonical_subject = _canonicalize_subject_for_key(subject) + digest = hashlib.sha256(canonical_subject.encode("utf-8")).hexdigest()[:_MEMORY_KEY_HASH_LENGTH] + max_slug_length = _MAX_MEMORY_KEY_LENGTH - len(_MEMORY_KEY_PREFIX) - len("__") - len(digest) + slug = _slugify_subject(canonical_subject, max_length=max_slug_length) + if not slug: + return f"{_MEMORY_KEY_PREFIX}{digest}" + return f"{_MEMORY_KEY_PREFIX}{slug}__{digest}" + + +def _build_candidate( + *, + source_event_id: UUID, + pattern: ExplicitPreferencePattern, + preference: PreferenceKind, + subject_text: str, +) -> ExtractedPreferenceCandidateRecord | None: + normalized_subject = _normalize_subject(subject_text) + if not normalized_subject: + return None + + if not _subject_has_supported_shape(normalized_subject): + return None + + value: JsonObject = { + "kind": "explicit_preference", + "preference": preference, + "text": normalized_subject, + } + return { + "memory_key": _build_memory_key(normalized_subject), + "value": value, + "source_event_ids": [str(source_event_id)], + "delete_requested": False, + "pattern": pattern, + "subject_text": normalized_subject, + } + + +def extract_explicit_preference_candidates( + *, + source_event_id: UUID, + text: str, +) -> list[ExtractedPreferenceCandidateRecord]: + normalized_text = _normalize_whitespace(text) + if not normalized_text: + return [] + + for pattern_name, preference, pattern in _DIRECT_PATTERNS: + match = pattern.fullmatch(normalized_text) + if match is not None: + candidate = _build_candidate( + source_event_id=source_event_id, + pattern=pattern_name, + preference=preference, + subject_text=match.group("subject"), + ) + return [] if candidate is None else [candidate] + + lowered_text = normalized_text.lower() + if lowered_text.startswith(_REMEMBER_PREFIX): + nested_text = normalized_text[len(_REMEMBER_PREFIX) :] + nested_candidates = extract_explicit_preference_candidates( + source_event_id=source_event_id, + text=nested_text, + ) + if not nested_candidates: + return [] + candidate = dict(nested_candidates[0]) + candidate["pattern"] = _REMEMBER_PATTERN_MAP[candidate["pattern"]] + return [candidate] + + return [] + + +def _get_single_source_event(store: ContinuityStore, source_event_id: UUID) -> EventRow: + events = store.list_events_by_ids([source_event_id]) + if not events: + raise ExplicitPreferenceExtractionValidationError( + "source_event_id must reference an existing message.user event owned by the user" + ) + return events[0] + + +def _extract_text_payload(event: EventRow) -> str: + if event["kind"] != "message.user": + raise ExplicitPreferenceExtractionValidationError( + "source_event_id must reference an existing message.user event owned by the user" + ) + + payload_text = event["payload"].get("text") + if not isinstance(payload_text, str): + raise ExplicitPreferenceExtractionValidationError( + "source_event_id must reference a message.user event with string payload.text" + ) + + return payload_text + + +def _serialize_admission(decision: AdmissionDecisionOutput) -> ExplicitPreferenceAdmissionRecord: + return { + "decision": decision.action, + "reason": decision.reason, + "memory": decision.memory, + "revision": decision.revision, + } + + +def _build_summary( + *, + source_event_id: UUID, + source_event_kind: str, + admissions: Sequence[ExplicitPreferenceAdmissionRecord], + candidates: Sequence[ExtractedPreferenceCandidateRecord], +) -> ExplicitPreferenceExtractionSummary: + noop_count = sum(1 for admission in admissions if admission["decision"] == "NOOP") + return { + "source_event_id": str(source_event_id), + "source_event_kind": source_event_kind, + "candidate_count": len(candidates), + "admission_count": len(admissions), + "persisted_change_count": len(admissions) - noop_count, + "noop_count": noop_count, + } + + +def extract_and_admit_explicit_preferences( + store: ContinuityStore, + *, + user_id: UUID, + request: ExplicitPreferenceExtractionRequestInput, +) -> ExplicitPreferenceExtractionResponse: + source_event = _get_single_source_event(store, request.source_event_id) + payload_text = _extract_text_payload(source_event) + candidates = extract_explicit_preference_candidates( + source_event_id=request.source_event_id, + text=payload_text, + ) + + admissions: list[ExplicitPreferenceAdmissionRecord] = [] + for candidate in candidates: + decision = admit_memory_candidate( + store, + user_id=user_id, + candidate=MemoryCandidateInput( + memory_key=candidate["memory_key"], + value=candidate["value"], + source_event_ids=(request.source_event_id,), + delete_requested=candidate["delete_requested"], + ), + ) + admissions.append(_serialize_admission(decision)) + + return { + "candidates": list(candidates), + "admissions": admissions, + "summary": _build_summary( + source_event_id=request.source_event_id, + source_event_kind=source_event["kind"], + admissions=admissions, + candidates=candidates, + ), + } diff --git a/apps/api/src/alicebot_api/explicit_signal_capture.py b/apps/api/src/alicebot_api/explicit_signal_capture.py new file mode 100644 index 0000000..f1844ce --- /dev/null +++ b/apps/api/src/alicebot_api/explicit_signal_capture.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from uuid import UUID + +from alicebot_api.contracts import ( + ExplicitCommitmentExtractionRequestInput, + ExplicitCommitmentExtractionResponse, + ExplicitPreferenceExtractionRequestInput, + ExplicitPreferenceExtractionResponse, + ExplicitSignalCaptureRequestInput, + ExplicitSignalCaptureResponse, + ExplicitSignalCaptureSummary, +) +from alicebot_api.explicit_commitments import ( + ExplicitCommitmentExtractionValidationError, + extract_and_admit_explicit_commitments, +) +from alicebot_api.explicit_preferences import ( + ExplicitPreferenceExtractionValidationError, + extract_and_admit_explicit_preferences, +) +from alicebot_api.store import ContinuityStore + + +class ExplicitSignalCaptureValidationError(ValueError): + """Raised when an explicit-signal capture request is invalid.""" + + +def _build_summary( + *, + source_event_id: UUID, + source_event_kind: str, + preferences: ExplicitPreferenceExtractionResponse, + commitments: ExplicitCommitmentExtractionResponse, +) -> ExplicitSignalCaptureSummary: + preference_candidate_count = preferences["summary"]["candidate_count"] + preference_admission_count = preferences["summary"]["admission_count"] + preference_persisted_change_count = preferences["summary"]["persisted_change_count"] + preference_noop_count = preferences["summary"]["noop_count"] + + commitment_candidate_count = commitments["summary"]["candidate_count"] + commitment_admission_count = commitments["summary"]["admission_count"] + commitment_persisted_change_count = commitments["summary"]["persisted_change_count"] + commitment_noop_count = commitments["summary"]["noop_count"] + open_loop_created_count = commitments["summary"]["open_loop_created_count"] + open_loop_noop_count = commitments["summary"]["open_loop_noop_count"] + + return { + "source_event_id": str(source_event_id), + "source_event_kind": source_event_kind, + "candidate_count": preference_candidate_count + commitment_candidate_count, + "admission_count": preference_admission_count + commitment_admission_count, + "persisted_change_count": preference_persisted_change_count + commitment_persisted_change_count, + "noop_count": preference_noop_count + commitment_noop_count, + "open_loop_created_count": open_loop_created_count, + "open_loop_noop_count": open_loop_noop_count, + "preference_candidate_count": preference_candidate_count, + "preference_admission_count": preference_admission_count, + "commitment_candidate_count": commitment_candidate_count, + "commitment_admission_count": commitment_admission_count, + } + + +def extract_and_admit_explicit_signals( + store: ContinuityStore, + *, + user_id: UUID, + request: ExplicitSignalCaptureRequestInput, +) -> ExplicitSignalCaptureResponse: + try: + # Preserve explicit deterministic sequencing: preferences first, commitments second. + preferences = extract_and_admit_explicit_preferences( + store, + user_id=user_id, + request=ExplicitPreferenceExtractionRequestInput( + source_event_id=request.source_event_id, + ), + ) + commitments = extract_and_admit_explicit_commitments( + store, + user_id=user_id, + request=ExplicitCommitmentExtractionRequestInput( + source_event_id=request.source_event_id, + ), + ) + except ( + ExplicitPreferenceExtractionValidationError, + ExplicitCommitmentExtractionValidationError, + ) as exc: + raise ExplicitSignalCaptureValidationError(str(exc)) from exc + + return { + "preferences": preferences, + "commitments": commitments, + "summary": _build_summary( + source_event_id=request.source_event_id, + source_event_kind=preferences["summary"]["source_event_kind"], + preferences=preferences, + commitments=commitments, + ), + } diff --git a/apps/api/src/alicebot_api/gmail.py b/apps/api/src/alicebot_api/gmail.py new file mode 100644 index 0000000..3d023a9 --- /dev/null +++ b/apps/api/src/alicebot_api/gmail.py @@ -0,0 +1,802 @@ +from __future__ import annotations + +import base64 +import json +import re +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from pathlib import Path +from urllib.error import HTTPError, URLError +from urllib.parse import quote, urlencode +from urllib.request import Request, urlopen +from uuid import UUID + +import psycopg + +from alicebot_api.artifacts import ( + SUPPORTED_RFC822_ARTIFACT_MEDIA_TYPE, + TaskArtifactAlreadyExistsError, + TaskArtifactValidationError, + ensure_artifact_path_is_rooted, + extract_artifact_text_from_bytes, + ingest_task_artifact_record, + register_task_artifact_record, +) +from alicebot_api.contracts import ( + GMAIL_ACCOUNT_LIST_ORDER, + GMAIL_AUTH_KIND_OAUTH_ACCESS_TOKEN, + GMAIL_PROTECTED_CREDENTIAL_KIND, + GMAIL_PROVIDER, + GMAIL_REFRESHABLE_PROTECTED_CREDENTIAL_KIND, + GMAIL_READONLY_SCOPE, + GmailAccountConnectInput, + GmailAccountConnectResponse, + GmailAccountDetailResponse, + GmailAccountListResponse, + GmailAccountRecord, + GmailMessageIngestInput, + GmailMessageIngestionResponse, + TaskArtifactIngestInput, + TaskArtifactRegisterInput, +) +from alicebot_api.gmail_secret_manager import ( + GMAIL_SECRET_MANAGER_KIND_FILE_V1, + GmailSecretManager, + GmailSecretManagerError, +) +from alicebot_api.store import ContinuityStore, ContinuityStoreInvariantError, GmailAccountRow, JsonObject +from alicebot_api.workspaces import TaskWorkspaceNotFoundError + +GMAIL_MESSAGE_FETCH_TIMEOUT_SECONDS = 30 +GMAIL_TOKEN_REFRESH_TIMEOUT_SECONDS = 30 +GMAIL_TOKEN_REFRESH_URL = "https://oauth2.googleapis.com/token" +GMAIL_MESSAGE_ARTIFACT_ROOT = "gmail" +GMAIL_SECRET_MANAGER_KIND_LEGACY_DB_V0 = "legacy_db_v0" +_PATH_SEGMENT_PATTERN = re.compile(r"[^A-Za-z0-9._-]+") + + +class GmailAccountNotFoundError(LookupError): + """Raised when a Gmail account is not visible inside the current user scope.""" + + +class GmailAccountAlreadyExistsError(RuntimeError): + """Raised when the same provider account is connected twice for one user.""" + + +class GmailMessageNotFoundError(LookupError): + """Raised when a Gmail message cannot be found in the current account.""" + + +class GmailMessageUnsupportedError(ValueError): + """Raised when Gmail content cannot be converted into the RFC822 artifact seam.""" + + +class GmailMessageFetchError(RuntimeError): + """Raised when the Gmail API call fails for non-deterministic upstream reasons.""" + + +class GmailCredentialNotFoundError(RuntimeError): + """Raised when Gmail protected credentials are missing for a visible account.""" + + +class GmailCredentialInvalidError(RuntimeError): + """Raised when Gmail protected credentials are malformed for a visible account.""" + + +class GmailCredentialRefreshError(RuntimeError): + """Raised when Gmail access-token renewal fails for non-deterministic reasons.""" + + +class GmailCredentialPersistenceError(RuntimeError): + """Raised when renewed Gmail protected credentials cannot be persisted.""" + + +class GmailCredentialValidationError(ValueError): + """Raised when Gmail connect input contains an invalid credential combination.""" + + +@dataclass(frozen=True, slots=True) +class ParsedGmailCredential: + access_token: str + credential_kind: str + refresh_token: str | None = None + client_id: str | None = None + client_secret: str | None = None + access_token_expires_at: datetime | None = None + + +@dataclass(frozen=True, slots=True) +class RefreshedGmailCredential: + access_token: str + access_token_expires_at: datetime + refresh_token: str | None = None + + +@dataclass(frozen=True, slots=True) +class ResolvedGmailCredential: + parsed_credential: ParsedGmailCredential + credential_kind: str + secret_manager_kind: str + secret_ref: str | None + credential_blob: JsonObject | None + + +def serialize_gmail_account_row(row: GmailAccountRow) -> GmailAccountRecord: + return { + "id": str(row["id"]), + "provider": GMAIL_PROVIDER, + "auth_kind": GMAIL_AUTH_KIND_OAUTH_ACCESS_TOKEN, + "provider_account_id": row["provider_account_id"], + "email_address": row["email_address"], + "display_name": row["display_name"], + "scope": row["scope"], + "created_at": row["created_at"].isoformat(), + "updated_at": row["updated_at"].isoformat(), + } + + +def _coerce_nonempty_string(value: object) -> str | None: + if not isinstance(value, str): + return None + normalized = value.strip() + if normalized == "": + return None + return normalized + + +def _normalize_datetime(value: datetime) -> datetime: + if value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value.astimezone(UTC) + + +def build_gmail_protected_credential_blob( + *, + access_token: str, + refresh_token: str | None = None, + client_id: str | None = None, + client_secret: str | None = None, + access_token_expires_at: datetime | None = None, +) -> dict[str, str]: + normalized_access_token = _coerce_nonempty_string(access_token) + if normalized_access_token is None: + raise GmailCredentialValidationError("gmail access token must be non-empty") + + refresh_bundle = ( + refresh_token, + client_id, + client_secret, + access_token_expires_at, + ) + if all(value is None for value in refresh_bundle): + return { + "credential_kind": GMAIL_PROTECTED_CREDENTIAL_KIND, + "access_token": normalized_access_token, + } + + normalized_refresh_token = _coerce_nonempty_string(refresh_token) + normalized_client_id = _coerce_nonempty_string(client_id) + normalized_client_secret = _coerce_nonempty_string(client_secret) + if ( + normalized_refresh_token is None + or normalized_client_id is None + or normalized_client_secret is None + or access_token_expires_at is None + ): + raise GmailCredentialValidationError( + "gmail refresh credentials must include refresh_token, client_id, client_secret, " + "and access_token_expires_at" + ) + + normalized_expires_at = _normalize_datetime(access_token_expires_at) + return { + "credential_kind": GMAIL_REFRESHABLE_PROTECTED_CREDENTIAL_KIND, + "access_token": normalized_access_token, + "refresh_token": normalized_refresh_token, + "client_id": normalized_client_id, + "client_secret": normalized_client_secret, + "access_token_expires_at": normalized_expires_at.isoformat(), + } + + +def build_gmail_secret_ref(*, user_id: UUID, gmail_account_id: UUID) -> str: + return f"users/{user_id}/gmail-account-credentials/{gmail_account_id}.json" + + +def _write_external_gmail_secret( + secret_manager: GmailSecretManager, + *, + gmail_account_id: UUID, + secret_ref: str, + credential_blob: JsonObject, +) -> None: + try: + secret_manager.write_secret(secret_ref=secret_ref, payload=credential_blob) + except GmailSecretManagerError as exc: + raise GmailCredentialPersistenceError( + f"gmail account {gmail_account_id} protected credentials could not be persisted" + ) from exc + + +def _load_external_gmail_secret( + secret_manager: GmailSecretManager, + *, + gmail_account_id: UUID, + secret_ref: str, +) -> JsonObject: + try: + return secret_manager.load_secret(secret_ref=secret_ref) + except GmailSecretManagerError as exc: + message = str(exc) + if message.endswith("was not found"): + raise GmailCredentialNotFoundError( + f"gmail account {gmail_account_id} is missing protected credentials" + ) from exc + raise GmailCredentialInvalidError( + f"gmail account {gmail_account_id} has invalid protected credentials" + ) from exc + + +def _persist_external_gmail_credential_metadata( + store: ContinuityStore, + *, + gmail_account_id: UUID, + auth_kind: str, + credential_kind: str, + secret_manager_kind: str, + secret_ref: str, +) -> None: + store.update_gmail_account_credential( + gmail_account_id=gmail_account_id, + auth_kind=auth_kind, + credential_kind=credential_kind, + secret_manager_kind=secret_manager_kind, + secret_ref=secret_ref, + credential_blob=None, + ) + + +def _resolve_gmail_credential( + store: ContinuityStore, + secret_manager: GmailSecretManager, + *, + gmail_account_id: UUID, +) -> ResolvedGmailCredential: + credential = store.get_gmail_account_credential_optional(gmail_account_id) + if credential is None: + raise GmailCredentialNotFoundError( + f"gmail account {gmail_account_id} is missing protected credentials" + ) + + if credential["auth_kind"] != GMAIL_AUTH_KIND_OAUTH_ACCESS_TOKEN: + raise GmailCredentialInvalidError( + f"gmail account {gmail_account_id} has invalid protected credentials" + ) + + if credential["secret_manager_kind"] == GMAIL_SECRET_MANAGER_KIND_FILE_V1: + secret_ref = _coerce_nonempty_string(credential["secret_ref"]) + if secret_ref is None: + raise GmailCredentialInvalidError( + f"gmail account {gmail_account_id} has invalid protected credentials" + ) + return ResolvedGmailCredential( + parsed_credential=_parse_gmail_credential( + gmail_account_id=gmail_account_id, + credential_blob=_load_external_gmail_secret( + secret_manager, + gmail_account_id=gmail_account_id, + secret_ref=secret_ref, + ), + ), + credential_kind=credential["credential_kind"], + secret_manager_kind=credential["secret_manager_kind"], + secret_ref=secret_ref, + credential_blob=None, + ) + + if credential["secret_manager_kind"] != GMAIL_SECRET_MANAGER_KIND_LEGACY_DB_V0: + raise GmailCredentialInvalidError( + f"gmail account {gmail_account_id} has invalid protected credentials" + ) + + if credential["credential_blob"] is None: + raise GmailCredentialInvalidError( + f"gmail account {gmail_account_id} has invalid protected credentials" + ) + + parsed_credential = _parse_gmail_credential( + gmail_account_id=gmail_account_id, + credential_blob=credential["credential_blob"], + ) + secret_ref = build_gmail_secret_ref( + user_id=credential["user_id"], + gmail_account_id=gmail_account_id, + ) + _write_external_gmail_secret( + secret_manager, + gmail_account_id=gmail_account_id, + secret_ref=secret_ref, + credential_blob=credential["credential_blob"], + ) + try: + _persist_external_gmail_credential_metadata( + store, + gmail_account_id=gmail_account_id, + auth_kind=credential["auth_kind"], + credential_kind=parsed_credential.credential_kind, + secret_manager_kind=secret_manager.kind, + secret_ref=secret_ref, + ) + except (ContinuityStoreInvariantError, psycopg.Error) as exc: + try: + secret_manager.delete_secret(secret_ref=secret_ref) + except GmailSecretManagerError: + pass + raise GmailCredentialPersistenceError( + f"gmail account {gmail_account_id} protected credentials could not be persisted" + ) from exc + + return ResolvedGmailCredential( + parsed_credential=parsed_credential, + credential_kind=parsed_credential.credential_kind, + secret_manager_kind=secret_manager.kind, + secret_ref=secret_ref, + credential_blob=None, + ) + + +def _parse_gmail_credential( + *, + gmail_account_id: UUID, + credential_blob: object, +) -> ParsedGmailCredential: + if not isinstance(credential_blob, dict): + raise GmailCredentialInvalidError( + f"gmail account {gmail_account_id} has invalid protected credentials" + ) + + credential_kind = credential_blob.get("credential_kind") + access_token = _coerce_nonempty_string(credential_blob.get("access_token")) + if access_token is None: + raise GmailCredentialInvalidError( + f"gmail account {gmail_account_id} has invalid protected credentials" + ) + + if credential_kind == GMAIL_PROTECTED_CREDENTIAL_KIND: + return ParsedGmailCredential( + access_token=access_token, + credential_kind=credential_kind, + ) + + if credential_kind != GMAIL_REFRESHABLE_PROTECTED_CREDENTIAL_KIND: + raise GmailCredentialInvalidError( + f"gmail account {gmail_account_id} has invalid protected credentials" + ) + + refresh_token = _coerce_nonempty_string(credential_blob.get("refresh_token")) + client_id = _coerce_nonempty_string(credential_blob.get("client_id")) + client_secret = _coerce_nonempty_string(credential_blob.get("client_secret")) + access_token_expires_at_raw = _coerce_nonempty_string( + credential_blob.get("access_token_expires_at") + ) + if ( + refresh_token is None + or client_id is None + or client_secret is None + or access_token_expires_at_raw is None + ): + raise GmailCredentialInvalidError( + f"gmail account {gmail_account_id} has invalid protected credentials" + ) + + try: + access_token_expires_at = _normalize_datetime( + datetime.fromisoformat(access_token_expires_at_raw) + ) + except ValueError as exc: + raise GmailCredentialInvalidError( + f"gmail account {gmail_account_id} has invalid protected credentials" + ) from exc + + return ParsedGmailCredential( + access_token=access_token, + credential_kind=credential_kind, + refresh_token=refresh_token, + client_id=client_id, + client_secret=client_secret, + access_token_expires_at=access_token_expires_at, + ) + + +def refresh_gmail_access_token( + *, + gmail_account_id: UUID, + refresh_token: str, + client_id: str, + client_secret: str, +) -> RefreshedGmailCredential: + request = Request( + GMAIL_TOKEN_REFRESH_URL, + data=urlencode( + { + "client_id": client_id, + "client_secret": client_secret, + "refresh_token": refresh_token, + "grant_type": "refresh_token", + } + ).encode("utf-8"), + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + }, + method="POST", + ) + + try: + with urlopen(request, timeout=GMAIL_TOKEN_REFRESH_TIMEOUT_SECONDS) as response: + payload = json.loads(response.read().decode("utf-8")) + except HTTPError as exc: + if exc.code in {400, 401}: + raise GmailCredentialInvalidError( + f"gmail account {gmail_account_id} refresh credentials were rejected" + ) from exc + raise GmailCredentialRefreshError( + f"gmail account {gmail_account_id} access token could not be renewed" + ) from exc + except (OSError, URLError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise GmailCredentialRefreshError( + f"gmail account {gmail_account_id} access token could not be renewed" + ) from exc + + refreshed_access_token = _coerce_nonempty_string(payload.get("access_token")) + replacement_refresh_token = _coerce_nonempty_string(payload.get("refresh_token")) + expires_in = payload.get("expires_in") + if refreshed_access_token is None or not isinstance(expires_in, (int, float)) or expires_in <= 0: + raise GmailCredentialRefreshError( + f"gmail account {gmail_account_id} access token could not be renewed" + ) + + refreshed_expires_at = datetime.now(UTC) + timedelta(seconds=float(expires_in)) + return RefreshedGmailCredential( + access_token=refreshed_access_token, + access_token_expires_at=refreshed_expires_at, + refresh_token=replacement_refresh_token, + ) + + +def _persist_refreshed_gmail_credential( + store: ContinuityStore, + secret_manager: GmailSecretManager, + *, + gmail_account_id: UUID, + auth_kind: str, + secret_ref: str, + existing_credential: ParsedGmailCredential, + refreshed_credential: RefreshedGmailCredential, +) -> None: + original_credential_blob = build_gmail_protected_credential_blob( + access_token=existing_credential.access_token, + refresh_token=existing_credential.refresh_token, + client_id=existing_credential.client_id, + client_secret=existing_credential.client_secret, + access_token_expires_at=existing_credential.access_token_expires_at, + ) + replacement_refresh_token = ( + refreshed_credential.refresh_token + if refreshed_credential.refresh_token is not None + else existing_credential.refresh_token + ) + replacement_credential_blob = build_gmail_protected_credential_blob( + access_token=refreshed_credential.access_token, + refresh_token=replacement_refresh_token, + client_id=existing_credential.client_id, + client_secret=existing_credential.client_secret, + access_token_expires_at=refreshed_credential.access_token_expires_at, + ) + try: + secret_manager.write_secret(secret_ref=secret_ref, payload=replacement_credential_blob) + store.update_gmail_account_credential( + gmail_account_id=gmail_account_id, + auth_kind=auth_kind, + credential_kind=replacement_credential_blob["credential_kind"], + secret_manager_kind=secret_manager.kind, + secret_ref=secret_ref, + credential_blob=None, + ) + except (GmailSecretManagerError, ContinuityStoreInvariantError, psycopg.Error) as exc: + try: + secret_manager.write_secret(secret_ref=secret_ref, payload=original_credential_blob) + except GmailSecretManagerError: + pass + raise GmailCredentialPersistenceError( + f"gmail account {gmail_account_id} renewed protected credentials could not be persisted" + ) from exc + + +def resolve_gmail_access_token( + store: ContinuityStore, + secret_manager: GmailSecretManager, + *, + gmail_account_id: UUID, +) -> str: + credential = _resolve_gmail_credential( + store, + secret_manager, + gmail_account_id=gmail_account_id, + ) + parsed_credential = credential.parsed_credential + if ( + parsed_credential.credential_kind != GMAIL_REFRESHABLE_PROTECTED_CREDENTIAL_KIND + or parsed_credential.access_token_expires_at is None + or parsed_credential.access_token_expires_at > datetime.now(UTC) + ): + return parsed_credential.access_token + + refreshed_credential = refresh_gmail_access_token( + gmail_account_id=gmail_account_id, + refresh_token=parsed_credential.refresh_token, + client_id=parsed_credential.client_id, + client_secret=parsed_credential.client_secret, + ) + _persist_refreshed_gmail_credential( + store, + secret_manager, + gmail_account_id=gmail_account_id, + auth_kind=GMAIL_AUTH_KIND_OAUTH_ACCESS_TOKEN, + secret_ref=credential.secret_ref, + existing_credential=parsed_credential, + refreshed_credential=refreshed_credential, + ) + return refreshed_credential.access_token + + +def create_gmail_account_record( + store: ContinuityStore, + secret_manager: GmailSecretManager, + *, + user_id: UUID, + request: GmailAccountConnectInput, +) -> GmailAccountConnectResponse: + del user_id + + existing = store.get_gmail_account_by_provider_account_id_optional(request.provider_account_id) + if existing is not None: + raise GmailAccountAlreadyExistsError( + f"gmail account {request.provider_account_id} is already connected" + ) + + row: GmailAccountRow | None = None + secret_ref: str | None = None + try: + row = store.create_gmail_account( + provider_account_id=request.provider_account_id, + email_address=request.email_address, + display_name=request.display_name, + scope=request.scope, + ) + credential_blob = build_gmail_protected_credential_blob( + access_token=request.access_token, + refresh_token=request.refresh_token, + client_id=request.client_id, + client_secret=request.client_secret, + access_token_expires_at=request.access_token_expires_at, + ) + secret_ref = build_gmail_secret_ref( + user_id=row["user_id"], + gmail_account_id=row["id"], + ) + _write_external_gmail_secret( + secret_manager, + gmail_account_id=row["id"], + secret_ref=secret_ref, + credential_blob=credential_blob, + ) + store.create_gmail_account_credential( + gmail_account_id=row["id"], + auth_kind=GMAIL_AUTH_KIND_OAUTH_ACCESS_TOKEN, + credential_kind=credential_blob["credential_kind"], + secret_manager_kind=secret_manager.kind, + secret_ref=secret_ref, + credential_blob=None, + ) + except psycopg.errors.UniqueViolation as exc: + raise GmailAccountAlreadyExistsError( + f"gmail account {request.provider_account_id} is already connected" + ) from exc + except GmailCredentialPersistenceError: + raise + except (ContinuityStoreInvariantError, psycopg.Error) as exc: + if secret_ref is not None: + try: + secret_manager.delete_secret(secret_ref=secret_ref) + except GmailSecretManagerError: + pass + raise GmailCredentialPersistenceError( + "gmail protected credentials could not be persisted" + ) from exc + + return {"account": serialize_gmail_account_row(row)} + + +def list_gmail_account_records( + store: ContinuityStore, + *, + user_id: UUID, +) -> GmailAccountListResponse: + del user_id + + items = [serialize_gmail_account_row(row) for row in store.list_gmail_accounts()] + return { + "items": items, + "summary": { + "total_count": len(items), + "order": list(GMAIL_ACCOUNT_LIST_ORDER), + }, + } + + +def get_gmail_account_record( + store: ContinuityStore, + *, + user_id: UUID, + gmail_account_id: UUID, +) -> GmailAccountDetailResponse: + del user_id + + row = store.get_gmail_account_optional(gmail_account_id) + if row is None: + raise GmailAccountNotFoundError(f"gmail account {gmail_account_id} was not found") + return {"account": serialize_gmail_account_row(row)} + + +def _sanitize_path_segment(value: str) -> str: + sanitized = _PATH_SEGMENT_PATTERN.sub("_", value.strip()) + return sanitized.strip("._") or "message" + + +def build_gmail_message_artifact_relative_path( + *, + provider_account_id: str, + provider_message_id: str, +) -> str: + return ( + f"{GMAIL_MESSAGE_ARTIFACT_ROOT}/" + f"{_sanitize_path_segment(provider_account_id)}/" + f"{_sanitize_path_segment(provider_message_id)}.eml" + ) + + +def fetch_gmail_message_raw_bytes(*, access_token: str, provider_message_id: str) -> bytes: + request = Request( + ( + "https://gmail.googleapis.com/gmail/v1/users/me/messages/" + f"{quote(provider_message_id, safe='')}?format=raw" + ), + headers={ + "Authorization": f"Bearer {access_token}", + "Accept": "application/json", + }, + method="GET", + ) + + try: + with urlopen(request, timeout=GMAIL_MESSAGE_FETCH_TIMEOUT_SECONDS) as response: + payload = json.loads(response.read().decode("utf-8")) + except HTTPError as exc: + if exc.code == 404: + raise GmailMessageNotFoundError( + f"gmail message {provider_message_id} was not found" + ) from exc + raise GmailMessageFetchError( + f"gmail message {provider_message_id} could not be fetched" + ) from exc + except (OSError, URLError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise GmailMessageFetchError( + f"gmail message {provider_message_id} could not be fetched" + ) from exc + + raw_payload = payload.get("raw") + if not isinstance(raw_payload, str) or raw_payload == "": + raise GmailMessageUnsupportedError( + f"gmail message {provider_message_id} did not include RFC822 raw content" + ) + + padding = "=" * (-len(raw_payload) % 4) + try: + return base64.urlsafe_b64decode(raw_payload + padding) + except (ValueError, TypeError) as exc: + raise GmailMessageUnsupportedError( + f"gmail message {provider_message_id} did not include valid RFC822 raw content" + ) from exc + + +def ingest_gmail_message_record( + store: ContinuityStore, + secret_manager: GmailSecretManager, + *, + user_id: UUID, + request: GmailMessageIngestInput, +) -> GmailMessageIngestionResponse: + account = store.get_gmail_account_optional(request.gmail_account_id) + if account is None: + raise GmailAccountNotFoundError(f"gmail account {request.gmail_account_id} was not found") + + workspace = store.get_task_workspace_optional(request.task_workspace_id) + if workspace is None: + raise TaskWorkspaceNotFoundError( + f"task workspace {request.task_workspace_id} was not found" + ) + + access_token = resolve_gmail_access_token( + store, + secret_manager, + gmail_account_id=request.gmail_account_id, + ) + + store.lock_task_artifacts(workspace["id"]) + relative_path = build_gmail_message_artifact_relative_path( + provider_account_id=account["provider_account_id"], + provider_message_id=request.provider_message_id, + ) + existing_artifact = store.get_task_artifact_by_workspace_relative_path_optional( + task_workspace_id=request.task_workspace_id, + relative_path=relative_path, + ) + if existing_artifact is not None: + raise TaskArtifactAlreadyExistsError( + f"artifact {relative_path} is already registered for task workspace {request.task_workspace_id}" + ) + + raw_bytes = fetch_gmail_message_raw_bytes( + access_token=access_token, + provider_message_id=request.provider_message_id, + ) + + try: + extract_artifact_text_from_bytes( + relative_path=relative_path, + payload=raw_bytes, + media_type=SUPPORTED_RFC822_ARTIFACT_MEDIA_TYPE, + ) + except TaskArtifactValidationError as exc: + raise GmailMessageUnsupportedError( + f"gmail message {request.provider_message_id} is not a supported RFC822 email" + ) from exc + + workspace_path = Path(workspace["local_path"]).expanduser().resolve() + artifact_path = (workspace_path / relative_path).resolve() + ensure_artifact_path_is_rooted( + workspace_path=workspace_path, + artifact_path=artifact_path, + ) + artifact_path.parent.mkdir(parents=True, exist_ok=True) + if artifact_path.exists(): + raise TaskArtifactValidationError( + f"artifact path {artifact_path} already exists before Gmail ingestion registration" + ) + artifact_path.write_bytes(raw_bytes) + + artifact_payload = register_task_artifact_record( + store, + user_id=user_id, + request=TaskArtifactRegisterInput( + task_workspace_id=request.task_workspace_id, + local_path=str(artifact_path), + media_type_hint=SUPPORTED_RFC822_ARTIFACT_MEDIA_TYPE, + ), + ) + ingestion_payload = ingest_task_artifact_record( + store, + user_id=user_id, + request=TaskArtifactIngestInput(task_artifact_id=UUID(artifact_payload["artifact"]["id"])), + ) + return { + "account": serialize_gmail_account_row(account), + "message": { + "provider_message_id": request.provider_message_id, + "artifact_relative_path": ingestion_payload["artifact"]["relative_path"], + "media_type": SUPPORTED_RFC822_ARTIFACT_MEDIA_TYPE, + }, + "artifact": ingestion_payload["artifact"], + "summary": ingestion_payload["summary"], + } diff --git a/apps/api/src/alicebot_api/gmail_secret_manager.py b/apps/api/src/alicebot_api/gmail_secret_manager.py new file mode 100644 index 0000000..3fa8fa2 --- /dev/null +++ b/apps/api/src/alicebot_api/gmail_secret_manager.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import json +import os +from dataclasses import dataclass +from pathlib import Path +from urllib.parse import unquote, urlparse + +from alicebot_api.store import JsonObject + +GMAIL_SECRET_MANAGER_KIND_FILE_V1 = "file_v1" +SECRET_DIRECTORY_MODE = 0o700 +SECRET_FILE_MODE = 0o600 + + +class GmailSecretManagerError(RuntimeError): + """Raised when the configured Gmail secret manager cannot service a request.""" + + +@dataclass(frozen=True, slots=True) +class GmailSecretReference: + kind: str + ref: str + + +class GmailSecretManager: + kind: str + + def load_secret(self, *, secret_ref: str) -> JsonObject: + raise NotImplementedError + + def write_secret(self, *, secret_ref: str, payload: JsonObject) -> None: + raise NotImplementedError + + def delete_secret(self, *, secret_ref: str) -> None: + raise NotImplementedError + + +class FileGmailSecretManager(GmailSecretManager): + kind = GMAIL_SECRET_MANAGER_KIND_FILE_V1 + + def __init__(self, *, root: Path) -> None: + self._root = root.expanduser().resolve() + try: + self._root.mkdir(parents=True, exist_ok=True, mode=SECRET_DIRECTORY_MODE) + self._root.chmod(SECRET_DIRECTORY_MODE) + except OSError as exc: + raise GmailSecretManagerError("gmail secret manager root is not writable") from exc + + def load_secret(self, *, secret_ref: str) -> JsonObject: + path = self._resolve_secret_path(secret_ref) + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError as exc: + raise GmailSecretManagerError(f"gmail secret {secret_ref} was not found") from exc + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise GmailSecretManagerError(f"gmail secret {secret_ref} could not be loaded") from exc + if not isinstance(payload, dict): + raise GmailSecretManagerError(f"gmail secret {secret_ref} could not be loaded") + return payload + + def write_secret(self, *, secret_ref: str, payload: JsonObject) -> None: + path = self._resolve_secret_path(secret_ref) + self._ensure_private_directory(path.parent) + temp_path = path.with_name(f".{path.name}.{os.getpid()}.tmp") + try: + with os.fdopen( + os.open(temp_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, SECRET_FILE_MODE), + "w", + encoding="utf-8", + ) as secret_file: + secret_file.write(json.dumps(payload, sort_keys=True)) + temp_path.chmod(SECRET_FILE_MODE) + temp_path.replace(path) + path.chmod(SECRET_FILE_MODE) + except OSError as exc: + try: + temp_path.unlink(missing_ok=True) + except OSError: + pass + raise GmailSecretManagerError(f"gmail secret {secret_ref} could not be written") from exc + + def delete_secret(self, *, secret_ref: str) -> None: + path = self._resolve_secret_path(secret_ref) + try: + path.unlink(missing_ok=True) + except OSError as exc: + raise GmailSecretManagerError(f"gmail secret {secret_ref} could not be deleted") from exc + + def _resolve_secret_path(self, secret_ref: str) -> Path: + candidate = (self._root / secret_ref).resolve() + try: + candidate.relative_to(self._root) + except ValueError as exc: + raise GmailSecretManagerError(f"gmail secret {secret_ref} is outside the configured root") from exc + return candidate + + def _ensure_private_directory(self, directory: Path) -> None: + try: + directory.mkdir(parents=True, exist_ok=True, mode=SECRET_DIRECTORY_MODE) + directory.chmod(SECRET_DIRECTORY_MODE) + except OSError as exc: + raise GmailSecretManagerError("gmail secret directory permissions could not be secured") from exc + + +def build_gmail_secret_manager(secret_manager_url: str) -> GmailSecretManager: + if secret_manager_url.strip() == "": + raise ValueError("GMAIL_SECRET_MANAGER_URL must be configured") + + parsed = urlparse(secret_manager_url) + if parsed.scheme != "file": + raise ValueError("GMAIL_SECRET_MANAGER_URL must use the file:// scheme") + + root_path = Path(unquote(parsed.path or "/")) + if parsed.netloc not in ("", "localhost"): + root_path = Path(f"/{parsed.netloc}{root_path.as_posix()}") + + return FileGmailSecretManager(root=root_path) diff --git a/apps/api/src/alicebot_api/hosted_admin.py b/apps/api/src/alicebot_api/hosted_admin.py new file mode 100644 index 0000000..d893814 --- /dev/null +++ b/apps/api/src/alicebot_api/hosted_admin.py @@ -0,0 +1,597 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from typing import Any, Literal +from uuid import UUID + +from alicebot_api.hosted_telemetry import serialize_chat_telemetry + + +def utc_now() -> datetime: + return datetime.now(UTC) + + +def list_hosted_workspaces_for_admin( + conn, + *, + limit: int, +) -> list[dict[str, object]]: + bounded_limit = max(1, min(limit, 200)) + + with conn.cursor() as cur: + cur.execute( + """ + SELECT w.id, + w.owner_user_account_id, + w.slug, + w.name, + w.bootstrap_status, + w.bootstrapped_at, + w.support_status, + w.support_notes, + w.onboarding_last_error_code, + w.onboarding_last_error_detail, + w.onboarding_last_error_at, + w.onboarding_error_count, + w.rollout_evidence, + w.rate_limit_evidence, + w.incident_evidence, + w.created_at, + w.updated_at, + count(DISTINCT wm.user_account_id) AS member_count, + count(DISTINCT CASE WHEN ci.status = 'linked' THEN ci.id END) AS linked_identity_count, + max(cm.created_at) AS last_message_at, + max(dr.recorded_at) AS last_delivery_receipt_at + FROM workspaces AS w + LEFT JOIN workspace_members AS wm + ON wm.workspace_id = w.id + LEFT JOIN channel_identities AS ci + ON ci.workspace_id = w.id + AND ci.channel_type = 'telegram' + LEFT JOIN channel_messages AS cm + ON cm.workspace_id = w.id + AND cm.channel_type = 'telegram' + LEFT JOIN channel_delivery_receipts AS dr + ON dr.workspace_id = w.id + AND dr.channel_type = 'telegram' + GROUP BY w.id + ORDER BY w.updated_at DESC, w.id DESC + LIMIT %s + """, + (bounded_limit,), + ) + rows = cur.fetchall() + + payload: list[dict[str, object]] = [] + for row in rows: + payload.append( + { + "id": str(row["id"]), + "owner_user_account_id": str(row["owner_user_account_id"]), + "slug": row["slug"], + "name": row["name"], + "bootstrap_status": row["bootstrap_status"], + "bootstrapped_at": None + if row["bootstrapped_at"] is None + else row["bootstrapped_at"].isoformat(), + "support_status": row["support_status"], + "support_notes": row["support_notes"], + "onboarding_last_error_code": row["onboarding_last_error_code"], + "onboarding_last_error_detail": row["onboarding_last_error_detail"], + "onboarding_last_error_at": None + if row["onboarding_last_error_at"] is None + else row["onboarding_last_error_at"].isoformat(), + "onboarding_error_count": row["onboarding_error_count"], + "rollout_evidence": row["rollout_evidence"], + "rate_limit_evidence": row["rate_limit_evidence"], + "incident_evidence": row["incident_evidence"], + "member_count": int(row["member_count"]), + "linked_identity_count": int(row["linked_identity_count"]), + "last_message_at": None + if row["last_message_at"] is None + else row["last_message_at"].isoformat(), + "last_delivery_receipt_at": None + if row["last_delivery_receipt_at"] is None + else row["last_delivery_receipt_at"].isoformat(), + "created_at": row["created_at"].isoformat(), + "updated_at": row["updated_at"].isoformat(), + } + ) + + return payload + + +def list_hosted_delivery_receipts_for_admin( + conn, + *, + limit: int, + workspace_id: UUID | None = None, +) -> list[dict[str, object]]: + bounded_limit = max(1, min(limit, 400)) + + with conn.cursor() as cur: + if workspace_id is None: + cur.execute( + """ + SELECT r.id, + r.workspace_id, + r.channel_message_id, + r.channel_type, + r.status, + r.provider_receipt_id, + r.failure_code, + r.failure_detail, + r.scheduled_job_id, + r.scheduler_job_kind, + r.scheduled_for, + r.schedule_slot, + r.notification_policy, + r.rollout_flag_state, + r.support_evidence, + r.rate_limit_evidence, + r.incident_evidence, + r.recorded_at, + r.created_at, + w.slug AS workspace_slug, + w.name AS workspace_name, + m.direction AS message_direction + FROM channel_delivery_receipts AS r + JOIN workspaces AS w + ON w.id = r.workspace_id + LEFT JOIN channel_messages AS m + ON m.id = r.channel_message_id + WHERE r.channel_type = 'telegram' + ORDER BY r.recorded_at DESC, r.id DESC + LIMIT %s + """, + (bounded_limit,), + ) + else: + cur.execute( + """ + SELECT r.id, + r.workspace_id, + r.channel_message_id, + r.channel_type, + r.status, + r.provider_receipt_id, + r.failure_code, + r.failure_detail, + r.scheduled_job_id, + r.scheduler_job_kind, + r.scheduled_for, + r.schedule_slot, + r.notification_policy, + r.rollout_flag_state, + r.support_evidence, + r.rate_limit_evidence, + r.incident_evidence, + r.recorded_at, + r.created_at, + w.slug AS workspace_slug, + w.name AS workspace_name, + m.direction AS message_direction + FROM channel_delivery_receipts AS r + JOIN workspaces AS w + ON w.id = r.workspace_id + LEFT JOIN channel_messages AS m + ON m.id = r.channel_message_id + WHERE r.channel_type = 'telegram' + AND r.workspace_id = %s + ORDER BY r.recorded_at DESC, r.id DESC + LIMIT %s + """, + (workspace_id, bounded_limit), + ) + rows = cur.fetchall() + + payload: list[dict[str, object]] = [] + for row in rows: + payload.append( + { + "id": str(row["id"]), + "workspace_id": str(row["workspace_id"]), + "workspace_slug": row["workspace_slug"], + "workspace_name": row["workspace_name"], + "channel_message_id": str(row["channel_message_id"]), + "message_direction": row["message_direction"], + "channel_type": row["channel_type"], + "status": row["status"], + "provider_receipt_id": row["provider_receipt_id"], + "failure_code": row["failure_code"], + "failure_detail": row["failure_detail"], + "scheduled_job_id": None + if row["scheduled_job_id"] is None + else str(row["scheduled_job_id"]), + "scheduler_job_kind": row["scheduler_job_kind"], + "scheduled_for": None if row["scheduled_for"] is None else row["scheduled_for"].isoformat(), + "schedule_slot": row["schedule_slot"], + "notification_policy": row["notification_policy"], + "rollout_flag_state": row["rollout_flag_state"], + "support_evidence": row["support_evidence"], + "rate_limit_evidence": row["rate_limit_evidence"], + "incident_evidence": row["incident_evidence"], + "recorded_at": row["recorded_at"].isoformat(), + "created_at": row["created_at"].isoformat(), + } + ) + + return payload + + +def _workspace_onboarding_incidents(conn) -> list[dict[str, object]]: + with conn.cursor() as cur: + cur.execute( + """ + SELECT id, + slug, + name, + support_status, + onboarding_last_error_code, + onboarding_last_error_detail, + onboarding_last_error_at, + onboarding_error_count, + incident_evidence, + updated_at + FROM workspaces + WHERE onboarding_error_count > 0 + AND onboarding_last_error_at IS NOT NULL + ORDER BY onboarding_last_error_at DESC, id DESC + """, + ) + rows = cur.fetchall() + + incidents: list[dict[str, object]] = [] + for row in rows: + evidence = row["incident_evidence"] or {} + resolved = bool(evidence.get("resolved", False)) + incidents.append( + { + "incident_id": f"workspace-onboarding:{row['id']}", + "workspace_id": str(row["id"]), + "workspace_slug": row["slug"], + "workspace_name": row["name"], + "source": "workspace_onboarding", + "severity": "critical" if row["support_status"] == "blocked" else "warning", + "status": "resolved" if resolved else "open", + "code": row["onboarding_last_error_code"] or "onboarding_error", + "detail": row["onboarding_last_error_detail"] + or "workspace onboarding encountered an error", + "evidence": { + "onboarding_error_count": int(row["onboarding_error_count"]), + "support_status": row["support_status"], + **evidence, + }, + "occurred_at": row["onboarding_last_error_at"].isoformat(), + "updated_at": row["updated_at"].isoformat(), + } + ) + + return incidents + + +def _delivery_incidents(conn) -> list[dict[str, object]]: + with conn.cursor() as cur: + cur.execute( + """ + SELECT r.id, + r.workspace_id, + w.slug, + w.name, + r.status, + r.failure_code, + r.failure_detail, + r.scheduler_job_kind, + r.incident_evidence, + r.recorded_at + FROM channel_delivery_receipts AS r + JOIN workspaces AS w + ON w.id = r.workspace_id + WHERE r.channel_type = 'telegram' + AND ( + r.status IN ('failed', 'suppressed') + OR r.incident_evidence <> '{}'::jsonb + ) + ORDER BY r.recorded_at DESC, r.id DESC + LIMIT 400 + """, + ) + rows = cur.fetchall() + + incidents: list[dict[str, object]] = [] + for row in rows: + evidence = row["incident_evidence"] or {} + resolved = bool(evidence.get("resolved", False)) + incidents.append( + { + "incident_id": f"delivery-receipt:{row['id']}", + "workspace_id": str(row["workspace_id"]), + "workspace_slug": row["slug"], + "workspace_name": row["name"], + "source": "delivery_receipt", + "severity": "critical" if row["status"] == "failed" else "warning", + "status": "resolved" if resolved else "open", + "code": row["failure_code"] or f"delivery_{row['status']}", + "detail": row["failure_detail"] or "delivery receipt indicates a non-delivered status", + "evidence": { + "scheduler_job_kind": row["scheduler_job_kind"], + **evidence, + }, + "occurred_at": row["recorded_at"].isoformat(), + "updated_at": row["recorded_at"].isoformat(), + } + ) + + return incidents + + +def _telemetry_incidents(conn) -> list[dict[str, object]]: + with conn.cursor() as cur: + cur.execute( + """ + SELECT t.id, + t.workspace_id, + w.slug, + w.name, + t.flow_kind, + t.event_kind, + t.status, + t.route_path, + t.evidence, + t.created_at + FROM chat_telemetry AS t + LEFT JOIN workspaces AS w + ON w.id = t.workspace_id + WHERE t.status IN ('failed', 'blocked_rollout', 'rate_limited', 'abuse_blocked') + OR t.event_kind = 'incident' + ORDER BY t.created_at DESC, t.id DESC + LIMIT 400 + """, + ) + rows = cur.fetchall() + + incidents: list[dict[str, object]] = [] + for row in rows: + evidence = row["evidence"] or {} + resolved = bool(evidence.get("resolved", False)) + incidents.append( + { + "incident_id": f"chat-telemetry:{row['id']}", + "workspace_id": None if row["workspace_id"] is None else str(row["workspace_id"]), + "workspace_slug": row["slug"], + "workspace_name": row["name"], + "source": "chat_telemetry", + "severity": "critical" + if row["status"] in {"failed", "abuse_blocked"} + else "warning", + "status": "resolved" if resolved else "open", + "code": str(row["status"]), + "detail": f"{row['flow_kind']} {row['event_kind']} via {row['route_path']}", + "evidence": { + "flow_kind": row["flow_kind"], + "event_kind": row["event_kind"], + "route_path": row["route_path"], + **evidence, + }, + "occurred_at": row["created_at"].isoformat(), + "updated_at": row["created_at"].isoformat(), + } + ) + + return incidents + + +def list_hosted_incidents_for_admin( + conn, + *, + limit: int, + status_filter: Literal["open", "resolved", "all"] = "open", + workspace_id: UUID | None = None, +) -> list[dict[str, object]]: + bounded_limit = max(1, min(limit, 500)) + + incidents = [ + *_workspace_onboarding_incidents(conn), + *_delivery_incidents(conn), + *_telemetry_incidents(conn), + ] + + filtered: list[dict[str, object]] = [] + for incident in incidents: + if workspace_id is not None and incident["workspace_id"] != str(workspace_id): + continue + if status_filter != "all" and incident["status"] != status_filter: + continue + filtered.append(incident) + + filtered.sort(key=lambda item: str(item["occurred_at"]), reverse=True) + return filtered[:bounded_limit] + + +def get_hosted_overview_for_admin( + conn, + *, + window_hours: int, +) -> dict[str, object]: + bounded_hours = max(1, min(window_hours, 168)) + window_start = utc_now() - timedelta(hours=bounded_hours) + + with conn.cursor() as cur: + cur.execute( + """ + SELECT count(*) AS total_count, + count(*) FILTER (WHERE bootstrap_status = 'ready') AS ready_count, + count(*) FILTER (WHERE bootstrap_status = 'pending') AS pending_count, + count(*) FILTER (WHERE support_status = 'blocked') AS blocked_support_count, + count(*) FILTER (WHERE support_status = 'needs_attention') AS attention_support_count + FROM workspaces + """, + ) + workspace_counts = cur.fetchone() + + cur.execute( + """ + SELECT count(DISTINCT workspace_id) AS linked_workspace_count + FROM channel_identities + WHERE channel_type = 'telegram' + AND status = 'linked' + """, + ) + linked_counts = cur.fetchone() + + cur.execute( + """ + SELECT count(*) AS total_count, + count(*) FILTER (WHERE status = 'failed') AS failed_count, + count(*) FILTER (WHERE status = 'suppressed') AS suppressed_count, + count(*) FILTER (WHERE status IN ('simulated', 'delivered')) AS delivered_or_simulated_count + FROM channel_delivery_receipts + WHERE channel_type = 'telegram' + AND recorded_at >= %s + """, + (window_start,), + ) + delivery_counts = cur.fetchone() + + cur.execute( + """ + SELECT count(*) AS total_count, + count(*) FILTER (WHERE status = 'ok') AS ok_count, + count(*) FILTER (WHERE status = 'failed') AS failed_count, + count(*) FILTER (WHERE status = 'blocked_rollout') AS rollout_blocked_count, + count(*) FILTER (WHERE status = 'rate_limited') AS rate_limited_count, + count(*) FILTER (WHERE status = 'abuse_blocked') AS abuse_blocked_count + FROM chat_telemetry + WHERE created_at >= %s + """, + (window_start,), + ) + telemetry_counts = cur.fetchone() + + cur.execute( + """ + SELECT count(*) AS total_count, + count(*) FILTER (WHERE enabled = true) AS enabled_count, + count(*) FILTER (WHERE enabled = false) AS disabled_count + FROM feature_flags + WHERE flag_key LIKE 'hosted_%%' + """, + ) + rollout_counts = cur.fetchone() + + incident_count = len( + list_hosted_incidents_for_admin( + conn, + limit=500, + status_filter="open", + workspace_id=None, + ) + ) + + return { + "window_hours": bounded_hours, + "window_start": window_start.isoformat(), + "workspaces": { + "total_count": int(workspace_counts["total_count"]), + "ready_count": int(workspace_counts["ready_count"]), + "pending_count": int(workspace_counts["pending_count"]), + "blocked_support_count": int(workspace_counts["blocked_support_count"]), + "attention_support_count": int(workspace_counts["attention_support_count"]), + "linked_telegram_workspace_count": int(linked_counts["linked_workspace_count"]), + }, + "delivery_receipts": { + "total_count": int(delivery_counts["total_count"]), + "failed_count": int(delivery_counts["failed_count"]), + "suppressed_count": int(delivery_counts["suppressed_count"]), + "delivered_or_simulated_count": int(delivery_counts["delivered_or_simulated_count"]), + }, + "chat_telemetry": { + "total_count": int(telemetry_counts["total_count"]), + "ok_count": int(telemetry_counts["ok_count"]), + "failed_count": int(telemetry_counts["failed_count"]), + "rollout_blocked_count": int(telemetry_counts["rollout_blocked_count"]), + "rate_limited_count": int(telemetry_counts["rate_limited_count"]), + "abuse_blocked_count": int(telemetry_counts["abuse_blocked_count"]), + }, + "rollout_flags": { + "total_count": int(rollout_counts["total_count"]), + "enabled_count": int(rollout_counts["enabled_count"]), + "disabled_count": int(rollout_counts["disabled_count"]), + }, + "incidents": { + "open_count": incident_count, + }, + } + + +def get_hosted_rate_limits_for_admin( + conn, + *, + window_hours: int, + limit: int, +) -> dict[str, object]: + bounded_hours = max(1, min(window_hours, 168)) + bounded_limit = max(1, min(limit, 200)) + window_start = utc_now() - timedelta(hours=bounded_hours) + + with conn.cursor() as cur: + cur.execute( + """ + SELECT flow_kind, + status, + count(*) AS total_count + FROM chat_telemetry + WHERE created_at >= %s + AND status IN ('rate_limited', 'abuse_blocked') + GROUP BY flow_kind, status + ORDER BY flow_kind ASC, status ASC + """, + (window_start,), + ) + grouped_rows = cur.fetchall() + + summary: dict[str, dict[str, int]] = {} + for row in grouped_rows: + flow_kind = str(row["flow_kind"]) + status = str(row["status"]) + bucket = summary.setdefault(flow_kind, {}) + bucket[status] = int(row["total_count"]) + + with conn.cursor() as cur: + cur.execute( + """ + SELECT id, + user_account_id, + workspace_id, + channel_message_id, + daily_brief_job_id, + delivery_receipt_id, + flow_kind, + event_kind, + status, + route_path, + rollout_flag_key, + rollout_flag_state, + rate_limit_key, + rate_limit_window_seconds, + rate_limit_max_requests, + retry_after_seconds, + abuse_signal, + evidence, + created_at + FROM chat_telemetry + WHERE created_at >= %s + AND status IN ('rate_limited', 'abuse_blocked') + ORDER BY created_at DESC, id DESC + LIMIT %s + """, + (window_start, bounded_limit), + ) + recent_rows = cur.fetchall() + + return { + "window_hours": bounded_hours, + "window_start": window_start.isoformat(), + "summary": summary, + "items": [serialize_chat_telemetry(row) for row in recent_rows], + } diff --git a/apps/api/src/alicebot_api/hosted_auth.py b/apps/api/src/alicebot_api/hosted_auth.py new file mode 100644 index 0000000..36da8c6 --- /dev/null +++ b/apps/api/src/alicebot_api/hosted_auth.py @@ -0,0 +1,572 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +import hashlib +import re +import secrets +from typing import TypedDict +from uuid import UUID + +from psycopg.types.json import Jsonb + + +EMAIL_PATTERN = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") + + +class MagicLinkTokenInvalidError(ValueError): + """Raised when a magic-link challenge token is unknown or already consumed.""" + + +class MagicLinkTokenExpiredError(ValueError): + """Raised when a magic-link challenge token has expired.""" + + +class AuthSessionInvalidError(ValueError): + """Raised when an auth session token is missing or invalid.""" + + +class AuthSessionExpiredError(ValueError): + """Raised when an auth session is expired.""" + + +class AuthSessionRevokedDeviceError(ValueError): + """Raised when the auth session is bound to a revoked device.""" + + +class UserAccountRow(TypedDict): + id: UUID + email: str + display_name: str | None + beta_cohort_key: str | None + created_at: datetime + + +class MagicLinkChallengeRow(TypedDict): + id: UUID + email: str + status: str + expires_at: datetime + consumed_at: datetime | None + created_at: datetime + + +class IssuedMagicLinkChallengeRow(MagicLinkChallengeRow): + challenge_token: str + + +class AuthSessionRow(TypedDict): + id: UUID + user_account_id: UUID + workspace_id: UUID | None + device_id: UUID | None + session_token_hash: str + status: str + expires_at: datetime + revoked_at: datetime | None + last_seen_at: datetime | None + created_at: datetime + + +class SessionResolution(TypedDict): + session: AuthSessionRow + user_account: UserAccountRow + device_status: str | None + device_label: str | None + + +def utc_now() -> datetime: + return datetime.now(timezone.utc) + + +def normalize_email(email: str) -> str: + normalized = email.strip().lower() + if not EMAIL_PATTERN.match(normalized): + raise ValueError("email must be valid for magic-link authentication") + return normalized + + +def hash_token(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def generate_token() -> str: + return secrets.token_urlsafe(32) + + +def _default_display_name(email: str) -> str: + stem = email.split("@", 1)[0].replace(".", " ").replace("_", " ").strip() + if stem == "": + return "Alice User" + words = [word for word in stem.split(" ") if word] + return " ".join(word.capitalize() for word in words[:3]) + + +def ensure_beta_cohort(conn, cohort_key: str = "p10-beta") -> None: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO beta_cohorts (cohort_key, description) + VALUES (%s, %s) + ON CONFLICT (cohort_key) DO NOTHING + """, + (cohort_key, "Phase 10 hosted beta cohort"), + ) + + +def get_or_create_user_account_by_email(conn, *, email: str) -> UserAccountRow: + normalized_email = normalize_email(email) + ensure_beta_cohort(conn) + + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO user_accounts (email, display_name, beta_cohort_key) + VALUES (%s, %s, %s) + ON CONFLICT (email) DO UPDATE + SET email = EXCLUDED.email + RETURNING id, email, display_name, beta_cohort_key, created_at + """, + (normalized_email, _default_display_name(normalized_email), "p10-beta"), + ) + row = cur.fetchone() + + if row is None: + raise RuntimeError("failed to load or create hosted user account") + return row + + +def start_magic_link_challenge( + conn, + *, + email: str, + ttl_seconds: int, +) -> IssuedMagicLinkChallengeRow: + normalized_email = normalize_email(email) + now = utc_now() + + with conn.cursor() as cur: + cur.execute( + """ + UPDATE magic_link_challenges + SET status = 'expired' + WHERE email = %s + AND status = 'pending' + AND expires_at > %s + """, + (normalized_email, now), + ) + challenge_token = generate_token() + challenge_token_hash = hash_token(challenge_token) + expires_at = now + timedelta(seconds=ttl_seconds) + cur.execute( + """ + INSERT INTO magic_link_challenges (email, challenge_token_hash, status, expires_at) + VALUES (%s, %s, 'pending', %s) + RETURNING id, email, status, expires_at, consumed_at, created_at + """, + (normalized_email, challenge_token_hash, expires_at), + ) + created = cur.fetchone() + + if created is None: + raise RuntimeError("failed to create magic-link challenge") + created["challenge_token"] = challenge_token + return created + + +def _lookup_magic_link_challenge_for_update( + conn, + *, + challenge_token: str, +) -> MagicLinkChallengeRow | None: + token = challenge_token.strip() + if token == "": + return None + + with conn.cursor() as cur: + cur.execute( + """ + SELECT id, email, status, expires_at, consumed_at, created_at + FROM magic_link_challenges + WHERE challenge_token_hash = %s + FOR UPDATE + """, + (hash_token(token),), + ) + return cur.fetchone() + + +def _derive_device_key(user_account_id: UUID, device_label: str) -> str: + token_source = f"{user_account_id}:{device_label.strip().lower()}" + return hashlib.sha256(token_source.encode("utf-8")).hexdigest()[:48] + + +def _upsert_device( + conn, + *, + user_account_id: UUID, + workspace_id: UUID | None, + device_label: str, + device_key: str, +) -> dict[str, object]: + now = utc_now() + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO devices ( + user_account_id, + workspace_id, + device_key, + device_label, + status, + last_seen_at, + updated_at + ) + VALUES (%s, %s, %s, %s, 'active', %s, %s) + ON CONFLICT (user_account_id, device_key) DO UPDATE + SET workspace_id = EXCLUDED.workspace_id, + device_label = EXCLUDED.device_label, + status = 'active', + revoked_at = NULL, + last_seen_at = EXCLUDED.last_seen_at, + updated_at = EXCLUDED.updated_at + RETURNING id, user_account_id, workspace_id, device_key, device_label, status, + last_seen_at, revoked_at, created_at, updated_at + """, + (user_account_id, workspace_id, device_key, device_label, now, now), + ) + row = cur.fetchone() + + if row is None: + raise RuntimeError("failed to upsert hosted device") + return row + + +def _get_current_workspace_id(conn, *, user_account_id: UUID) -> UUID | None: + with conn.cursor() as cur: + cur.execute( + """ + SELECT workspace_id + FROM workspace_members + WHERE user_account_id = %s + ORDER BY CASE WHEN role = 'owner' THEN 0 ELSE 1 END, created_at ASC, id ASC + LIMIT 1 + """, + (user_account_id,), + ) + row = cur.fetchone() + + if row is None: + return None + return row["workspace_id"] + + +def verify_magic_link_challenge( + conn, + *, + challenge_token: str, + session_ttl_seconds: int, + device_label: str, + device_key: str | None, +) -> tuple[UserAccountRow, AuthSessionRow, str, dict[str, object]]: + now = utc_now() + challenge = _lookup_magic_link_challenge_for_update(conn, challenge_token=challenge_token) + + if challenge is None: + raise MagicLinkTokenInvalidError("magic-link token is invalid") + + if challenge["status"] != "pending": + raise MagicLinkTokenInvalidError("magic-link token is no longer valid") + + if challenge["expires_at"] <= now: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE magic_link_challenges + SET status = 'expired' + WHERE id = %s + """, + (challenge["id"],), + ) + raise MagicLinkTokenExpiredError("magic-link token has expired") + + user_account = get_or_create_user_account_by_email(conn, email=challenge["email"]) + workspace_id = _get_current_workspace_id(conn, user_account_id=user_account["id"]) + normalized_device_label = device_label.strip() or "Primary device" + resolved_device_key = (device_key or "").strip() or _derive_device_key( + user_account["id"], normalized_device_label + ) + device = _upsert_device( + conn, + user_account_id=user_account["id"], + workspace_id=workspace_id, + device_label=normalized_device_label, + device_key=resolved_device_key, + ) + + raw_session_token = generate_token() + session_expires_at = now + timedelta(seconds=session_ttl_seconds) + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO auth_sessions ( + user_account_id, + workspace_id, + device_id, + session_token_hash, + status, + expires_at, + last_seen_at + ) + VALUES (%s, %s, %s, %s, 'active', %s, %s) + RETURNING id, user_account_id, workspace_id, device_id, session_token_hash, status, + expires_at, revoked_at, last_seen_at, created_at + """, + ( + user_account["id"], + workspace_id, + device["id"], + hash_token(raw_session_token), + session_expires_at, + now, + ), + ) + session = cur.fetchone() + cur.execute( + """ + UPDATE magic_link_challenges + SET status = 'consumed', + consumed_at = %s + WHERE id = %s + """, + (now, challenge["id"]), + ) + + if session is None: + raise RuntimeError("failed to create auth session") + + return user_account, session, raw_session_token, device + + +def resolve_auth_session(conn, *, session_token: str) -> SessionResolution: + token = session_token.strip() + if token == "": + raise AuthSessionInvalidError("session token is required") + + token_hash = hash_token(token) + now = utc_now() + + with conn.cursor() as cur: + cur.execute( + """ + SELECT + s.id, + s.user_account_id, + s.workspace_id, + s.device_id, + s.session_token_hash, + s.status, + s.expires_at, + s.revoked_at, + s.last_seen_at, + s.created_at, + u.email AS user_email, + u.display_name AS user_display_name, + u.beta_cohort_key AS user_beta_cohort_key, + u.created_at AS user_created_at, + d.status AS device_status, + d.device_label AS device_label + FROM auth_sessions AS s + JOIN user_accounts AS u + ON u.id = s.user_account_id + LEFT JOIN devices AS d + ON d.id = s.device_id + WHERE s.session_token_hash = %s + LIMIT 1 + """, + (token_hash,), + ) + row = cur.fetchone() + + if row is None: + raise AuthSessionInvalidError("session token is invalid") + + if row["status"] != "active": + if row["device_status"] == "revoked": + raise AuthSessionRevokedDeviceError("session device has been revoked") + raise AuthSessionInvalidError("session is not active") + + if row["expires_at"] <= now: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE auth_sessions + SET status = 'expired' + WHERE id = %s + AND status = 'active' + """, + (row["id"],), + ) + raise AuthSessionExpiredError("session token has expired") + + if row["device_status"] == "revoked": + raise AuthSessionRevokedDeviceError("session device has been revoked") + + with conn.cursor() as cur: + cur.execute( + """ + UPDATE auth_sessions + SET last_seen_at = %s + WHERE id = %s + """, + (now, row["id"]), + ) + if row["device_id"] is not None: + cur.execute( + """ + UPDATE devices + SET last_seen_at = %s, + updated_at = %s + WHERE id = %s + """, + (now, now, row["device_id"]), + ) + + session: AuthSessionRow = { + "id": row["id"], + "user_account_id": row["user_account_id"], + "workspace_id": row["workspace_id"], + "device_id": row["device_id"], + "session_token_hash": row["session_token_hash"], + "status": row["status"], + "expires_at": row["expires_at"], + "revoked_at": row["revoked_at"], + "last_seen_at": now, + "created_at": row["created_at"], + } + user_account: UserAccountRow = { + "id": row["user_account_id"], + "email": row["user_email"], + "display_name": row["user_display_name"], + "beta_cohort_key": row["user_beta_cohort_key"], + "created_at": row["user_created_at"], + } + return { + "session": session, + "user_account": user_account, + "device_status": row["device_status"], + "device_label": row["device_label"], + } + + +def logout_auth_session(conn, *, session_token: str) -> None: + token = session_token.strip() + if token == "": + raise AuthSessionInvalidError("session token is required") + + with conn.cursor() as cur: + cur.execute( + """ + UPDATE auth_sessions + SET status = 'revoked', + revoked_at = %s + WHERE session_token_hash = %s + AND status = 'active' + RETURNING id + """, + (utc_now(), hash_token(token)), + ) + row = cur.fetchone() + + if row is None: + raise AuthSessionInvalidError("session token is invalid") + + +def list_feature_flags_for_user(conn, *, user_account_id: UUID) -> list[str]: + with conn.cursor() as cur: + cur.execute( + """ + SELECT beta_cohort_key + FROM user_accounts + WHERE id = %s + """, + (user_account_id,), + ) + user = cur.fetchone() + + if user is None: + return [] + + cohort_key = user["beta_cohort_key"] + with conn.cursor() as cur: + cur.execute( + """ + SELECT flag_key + FROM feature_flags + WHERE enabled = true + AND (cohort_key IS NULL OR cohort_key = %s) + ORDER BY flag_key ASC + """, + (cohort_key,), + ) + rows = cur.fetchall() + + return [str(row["flag_key"]) for row in rows] + + +def serialize_user_account(user_account: UserAccountRow) -> dict[str, object]: + return { + "id": str(user_account["id"]), + "email": user_account["email"], + "display_name": user_account["display_name"], + "beta_cohort_key": user_account["beta_cohort_key"], + "created_at": user_account["created_at"].isoformat(), + } + + +def serialize_auth_session(session: AuthSessionRow) -> dict[str, object]: + return { + "id": str(session["id"]), + "user_account_id": str(session["user_account_id"]), + "workspace_id": None if session["workspace_id"] is None else str(session["workspace_id"]), + "device_id": None if session["device_id"] is None else str(session["device_id"]), + "status": session["status"], + "expires_at": session["expires_at"].isoformat(), + "revoked_at": None if session["revoked_at"] is None else session["revoked_at"].isoformat(), + "last_seen_at": None if session["last_seen_at"] is None else session["last_seen_at"].isoformat(), + "created_at": session["created_at"].isoformat(), + } + + +def serialize_magic_link_challenge(challenge: IssuedMagicLinkChallengeRow) -> dict[str, object]: + return { + "id": str(challenge["id"]), + "email": challenge["email"], + "challenge_token": challenge["challenge_token"], + "status": challenge["status"], + "expires_at": challenge["expires_at"].isoformat(), + "consumed_at": None if challenge["consumed_at"] is None else challenge["consumed_at"].isoformat(), + "created_at": challenge["created_at"].isoformat(), + } + + +def ensure_user_preferences_row(conn, *, user_account_id: UUID) -> None: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO user_preferences ( + user_account_id, + timezone, + brief_preferences, + quiet_hours + ) + VALUES (%s, %s, %s, %s) + ON CONFLICT (user_account_id) DO NOTHING + """, + ( + user_account_id, + "UTC", + Jsonb({"daily_brief": {"enabled": False, "window_start": "07:00"}}), + Jsonb({"start": "22:00", "end": "07:00", "enabled": False}), + ), + ) diff --git a/apps/api/src/alicebot_api/hosted_devices.py b/apps/api/src/alicebot_api/hosted_devices.py new file mode 100644 index 0000000..4624c6e --- /dev/null +++ b/apps/api/src/alicebot_api/hosted_devices.py @@ -0,0 +1,364 @@ +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import TypedDict +from uuid import UUID + +from alicebot_api.hosted_auth import generate_token, hash_token, utc_now + + +class DeviceLinkTokenInvalidError(ValueError): + """Raised when a device-link challenge token is invalid.""" + + +class DeviceLinkTokenExpiredError(ValueError): + """Raised when a device-link challenge has expired.""" + + +class HostedDeviceNotFoundError(LookupError): + """Raised when a hosted device is not visible for the account.""" + + +class DeviceRow(TypedDict): + id: UUID + user_account_id: UUID + workspace_id: UUID | None + device_key: str + device_label: str + status: str + last_seen_at: datetime | None + revoked_at: datetime | None + created_at: datetime + updated_at: datetime + + +class DeviceLinkChallengeRow(TypedDict): + id: UUID + user_account_id: UUID + workspace_id: UUID | None + device_key: str + device_label: str + status: str + expires_at: datetime + confirmed_at: datetime | None + device_id: UUID | None + created_at: datetime + + +class IssuedDeviceLinkChallengeRow(DeviceLinkChallengeRow): + challenge_token: str + + +def _normalize_device_label(device_label: str) -> str: + normalized = device_label.strip() + if normalized == "": + raise ValueError("device_label is required") + return normalized[:120] + + +def _normalize_device_key(device_key: str) -> str: + normalized = device_key.strip() + if normalized == "": + raise ValueError("device_key is required") + return normalized[:160] + + +def _upsert_device( + conn, + *, + user_account_id: UUID, + workspace_id: UUID | None, + device_key: str, + device_label: str, +) -> DeviceRow: + now = utc_now() + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO devices ( + user_account_id, + workspace_id, + device_key, + device_label, + status, + last_seen_at, + updated_at + ) + VALUES (%s, %s, %s, %s, 'active', %s, %s) + ON CONFLICT (user_account_id, device_key) DO UPDATE + SET workspace_id = EXCLUDED.workspace_id, + device_label = EXCLUDED.device_label, + status = 'active', + revoked_at = NULL, + last_seen_at = EXCLUDED.last_seen_at, + updated_at = EXCLUDED.updated_at + RETURNING id, user_account_id, workspace_id, device_key, device_label, status, + last_seen_at, revoked_at, created_at, updated_at + """, + (user_account_id, workspace_id, device_key, device_label, now, now), + ) + row = cur.fetchone() + + if row is None: + raise RuntimeError("failed to upsert hosted device") + return row + + +def start_device_link_challenge( + conn, + *, + user_account_id: UUID, + workspace_id: UUID | None, + device_key: str, + device_label: str, + ttl_seconds: int, +) -> IssuedDeviceLinkChallengeRow: + normalized_key = _normalize_device_key(device_key) + normalized_label = _normalize_device_label(device_label) + now = utc_now() + + with conn.cursor() as cur: + cur.execute( + """ + UPDATE device_link_challenges + SET status = 'expired' + WHERE user_account_id = %s + AND device_key = %s + AND status = 'pending' + AND expires_at > %s + """, + (user_account_id, normalized_key, now), + ) + challenge_token = generate_token() + challenge_token_hash = hash_token(challenge_token) + expires_at = now + timedelta(seconds=ttl_seconds) + cur.execute( + """ + INSERT INTO device_link_challenges ( + user_account_id, + workspace_id, + device_key, + device_label, + challenge_token_hash, + status, + expires_at + ) + VALUES (%s, %s, %s, %s, %s, 'pending', %s) + RETURNING id, user_account_id, workspace_id, device_key, device_label, + status, expires_at, confirmed_at, device_id, created_at + """, + ( + user_account_id, + workspace_id, + normalized_key, + normalized_label, + challenge_token_hash, + expires_at, + ), + ) + challenge = cur.fetchone() + + if challenge is None: + raise RuntimeError("failed to create device-link challenge") + challenge["challenge_token"] = challenge_token + return challenge + + +def _lookup_device_link_challenge_for_update( + conn, + *, + user_account_id: UUID, + challenge_token: str, +) -> DeviceLinkChallengeRow | None: + token = challenge_token.strip() + if token == "": + return None + + with conn.cursor() as cur: + cur.execute( + """ + SELECT id, user_account_id, workspace_id, device_key, device_label, + status, expires_at, confirmed_at, device_id, created_at + FROM device_link_challenges + WHERE user_account_id = %s + AND challenge_token_hash = %s + FOR UPDATE + """, + (user_account_id, hash_token(token)), + ) + return cur.fetchone() + + +def confirm_device_link_challenge( + conn, + *, + user_account_id: UUID, + challenge_token: str, +) -> DeviceRow: + now = utc_now() + challenge = _lookup_device_link_challenge_for_update( + conn, + user_account_id=user_account_id, + challenge_token=challenge_token, + ) + + if challenge is None: + raise DeviceLinkTokenInvalidError("device-link token is invalid") + + if challenge["status"] == "confirmed" and challenge["device_id"] is not None: + with conn.cursor() as cur: + cur.execute( + """ + SELECT id, user_account_id, workspace_id, device_key, device_label, status, + last_seen_at, revoked_at, created_at, updated_at + FROM devices + WHERE id = %s + """, + (challenge["device_id"],), + ) + existing_device = cur.fetchone() + if existing_device is not None: + return existing_device + + if challenge["status"] != "pending": + raise DeviceLinkTokenInvalidError("device-link token is no longer valid") + + if challenge["expires_at"] <= now: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE device_link_challenges + SET status = 'expired' + WHERE id = %s + """, + (challenge["id"],), + ) + raise DeviceLinkTokenExpiredError("device-link token has expired") + + device = _upsert_device( + conn, + user_account_id=user_account_id, + workspace_id=challenge["workspace_id"], + device_key=challenge["device_key"], + device_label=challenge["device_label"], + ) + + with conn.cursor() as cur: + cur.execute( + """ + UPDATE device_link_challenges + SET status = 'confirmed', + confirmed_at = %s, + device_id = %s + WHERE id = %s + """, + (now, device["id"], challenge["id"]), + ) + + return device + + +def list_devices( + conn, + *, + user_account_id: UUID, + workspace_id: UUID | None, +) -> list[DeviceRow]: + with conn.cursor() as cur: + if workspace_id is None: + cur.execute( + """ + SELECT id, user_account_id, workspace_id, device_key, device_label, status, + last_seen_at, revoked_at, created_at, updated_at + FROM devices + WHERE user_account_id = %s + ORDER BY created_at DESC, id DESC + """, + (user_account_id,), + ) + else: + cur.execute( + """ + SELECT id, user_account_id, workspace_id, device_key, device_label, status, + last_seen_at, revoked_at, created_at, updated_at + FROM devices + WHERE user_account_id = %s + AND (workspace_id = %s OR workspace_id IS NULL) + ORDER BY created_at DESC, id DESC + """, + (user_account_id, workspace_id), + ) + rows = cur.fetchall() + + return rows + + +def revoke_device( + conn, + *, + user_account_id: UUID, + device_id: UUID, +) -> DeviceRow: + now = utc_now() + with conn.cursor() as cur: + cur.execute( + """ + UPDATE devices + SET status = 'revoked', + revoked_at = %s, + updated_at = %s + WHERE id = %s + AND user_account_id = %s + RETURNING id, user_account_id, workspace_id, device_key, device_label, status, + last_seen_at, revoked_at, created_at, updated_at + """, + (now, now, device_id, user_account_id), + ) + row = cur.fetchone() + + if row is None: + raise HostedDeviceNotFoundError(f"device {device_id} was not found") + + cur.execute( + """ + UPDATE auth_sessions + SET status = 'revoked', + revoked_at = %s + WHERE device_id = %s + AND status = 'active' + """, + (now, device_id), + ) + + return row + + +def serialize_device(device: DeviceRow) -> dict[str, object]: + return { + "id": str(device["id"]), + "user_account_id": str(device["user_account_id"]), + "workspace_id": None if device["workspace_id"] is None else str(device["workspace_id"]), + "device_key": device["device_key"], + "device_label": device["device_label"], + "status": device["status"], + "last_seen_at": None if device["last_seen_at"] is None else device["last_seen_at"].isoformat(), + "revoked_at": None if device["revoked_at"] is None else device["revoked_at"].isoformat(), + "created_at": device["created_at"].isoformat(), + "updated_at": device["updated_at"].isoformat(), + } + + +def serialize_device_link_challenge(challenge: IssuedDeviceLinkChallengeRow) -> dict[str, object]: + return { + "id": str(challenge["id"]), + "user_account_id": str(challenge["user_account_id"]), + "workspace_id": None if challenge["workspace_id"] is None else str(challenge["workspace_id"]), + "device_key": challenge["device_key"], + "device_label": challenge["device_label"], + "challenge_token": challenge["challenge_token"], + "status": challenge["status"], + "expires_at": challenge["expires_at"].isoformat(), + "confirmed_at": None if challenge["confirmed_at"] is None else challenge["confirmed_at"].isoformat(), + "device_id": None if challenge["device_id"] is None else str(challenge["device_id"]), + "created_at": challenge["created_at"].isoformat(), + } diff --git a/apps/api/src/alicebot_api/hosted_preferences.py b/apps/api/src/alicebot_api/hosted_preferences.py new file mode 100644 index 0000000..ae5369f --- /dev/null +++ b/apps/api/src/alicebot_api/hosted_preferences.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +from datetime import datetime +from typing import TypedDict +from uuid import UUID +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from psycopg.types.json import Jsonb + + +class HostedPreferencesValidationError(ValueError): + """Raised when hosted preference input is invalid.""" + + +class UserPreferencesRow(TypedDict): + id: UUID + user_account_id: UUID + timezone: str + brief_preferences: dict[str, object] + quiet_hours: dict[str, object] + created_at: datetime + updated_at: datetime + + +DEFAULT_TIMEZONE = "UTC" +DEFAULT_BRIEF_PREFERENCES: dict[str, object] = { + "daily_brief": { + "enabled": False, + "window_start": "07:00", + } +} +DEFAULT_QUIET_HOURS: dict[str, object] = { + "enabled": False, + "start": "22:00", + "end": "07:00", +} + + +def validate_timezone(timezone: str) -> str: + normalized = timezone.strip() + if normalized == "": + raise HostedPreferencesValidationError("timezone must not be empty") + + try: + ZoneInfo(normalized) + except ZoneInfoNotFoundError as exc: + raise HostedPreferencesValidationError(f"timezone {timezone!r} is not recognized") from exc + + return normalized + + +def _default_brief_preferences() -> dict[str, object]: + return { + "daily_brief": { + "enabled": False, + "window_start": "07:00", + } + } + + +def _default_quiet_hours() -> dict[str, object]: + return { + "enabled": False, + "start": "22:00", + "end": "07:00", + } + + +def ensure_user_preferences( + conn, + *, + user_account_id: UUID, +) -> UserPreferencesRow: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO user_preferences (user_account_id, timezone, brief_preferences, quiet_hours) + VALUES (%s, %s, %s, %s) + ON CONFLICT (user_account_id) DO NOTHING + """, + ( + user_account_id, + DEFAULT_TIMEZONE, + Jsonb(_default_brief_preferences()), + Jsonb(_default_quiet_hours()), + ), + ) + cur.execute( + """ + SELECT id, + user_account_id, + timezone, + brief_preferences, + quiet_hours, + created_at, + updated_at + FROM user_preferences + WHERE user_account_id = %s + """, + (user_account_id,), + ) + row = cur.fetchone() + + if row is None: + raise RuntimeError("failed to load hosted user preferences") + + return row + + +def patch_user_preferences( + conn, + *, + user_account_id: UUID, + timezone: str | None, + brief_preferences: dict[str, object] | None, + quiet_hours: dict[str, object] | None, +) -> UserPreferencesRow: + existing = ensure_user_preferences(conn, user_account_id=user_account_id) + + resolved_timezone = existing["timezone"] if timezone is None else validate_timezone(timezone) + resolved_brief = existing["brief_preferences"] if brief_preferences is None else brief_preferences + resolved_quiet = existing["quiet_hours"] if quiet_hours is None else quiet_hours + + if not isinstance(resolved_brief, dict): + raise HostedPreferencesValidationError("brief_preferences must be an object") + if not isinstance(resolved_quiet, dict): + raise HostedPreferencesValidationError("quiet_hours must be an object") + + with conn.cursor() as cur: + cur.execute( + """ + UPDATE user_preferences + SET timezone = %s, + brief_preferences = %s, + quiet_hours = %s, + updated_at = clock_timestamp() + WHERE user_account_id = %s + RETURNING id, + user_account_id, + timezone, + brief_preferences, + quiet_hours, + created_at, + updated_at + """, + ( + resolved_timezone, + Jsonb(resolved_brief), + Jsonb(resolved_quiet), + user_account_id, + ), + ) + row = cur.fetchone() + + if row is None: + raise RuntimeError("failed to update hosted user preferences") + + return row + + +def serialize_user_preferences(preferences: UserPreferencesRow) -> dict[str, object]: + return { + "id": str(preferences["id"]), + "user_account_id": str(preferences["user_account_id"]), + "timezone": preferences["timezone"], + "brief_preferences": preferences["brief_preferences"], + "quiet_hours": preferences["quiet_hours"], + "created_at": preferences["created_at"].isoformat(), + "updated_at": preferences["updated_at"].isoformat(), + } diff --git a/apps/api/src/alicebot_api/hosted_rate_limits.py b/apps/api/src/alicebot_api/hosted_rate_limits.py new file mode 100644 index 0000000..2511a1c --- /dev/null +++ b/apps/api/src/alicebot_api/hosted_rate_limits.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from typing import Literal, TypedDict +from uuid import UUID + +from alicebot_api.config import Settings + + +HostedFlowKind = Literal["chat_handle", "scheduler_daily_brief", "scheduler_open_loop_prompt"] + + +@dataclass(frozen=True) +class RateLimitPolicy: + key: str + window_seconds: int + max_requests: int + + +class RateLimitDecision(TypedDict): + allowed: bool + code: str | None + message: str + retry_after_seconds: int + rate_limit_key: str + window_seconds: int + max_requests: int + observed_requests: int + abuse_signal: str | None + + +def utc_now() -> datetime: + return datetime.now(UTC) + + +def _policy_for_flow(settings: Settings, *, flow_kind: HostedFlowKind) -> RateLimitPolicy: + if flow_kind == "chat_handle": + return RateLimitPolicy( + key="hosted_chat_handle", + window_seconds=settings.hosted_chat_rate_limit_window_seconds, + max_requests=settings.hosted_chat_rate_limit_max_requests, + ) + + return RateLimitPolicy( + key="hosted_scheduler_delivery", + window_seconds=settings.hosted_scheduler_rate_limit_window_seconds, + max_requests=settings.hosted_scheduler_rate_limit_max_requests, + ) + + +def evaluate_hosted_flow_limits( + conn, + *, + settings: Settings, + user_account_id: UUID, + workspace_id: UUID, + flow_kind: HostedFlowKind, + now: datetime | None = None, +) -> RateLimitDecision: + del user_account_id + timestamp = utc_now() if now is None else now + policy = _policy_for_flow(settings, flow_kind=flow_kind) + + if not settings.hosted_rate_limits_enabled_by_default: + return { + "allowed": True, + "code": None, + "message": "hosted rate limits are disabled by configuration", + "retry_after_seconds": 0, + "rate_limit_key": policy.key, + "window_seconds": policy.window_seconds, + "max_requests": policy.max_requests, + "observed_requests": 0, + "abuse_signal": None, + } + + window_start = timestamp - timedelta(seconds=policy.window_seconds) + with conn.cursor() as cur: + cur.execute( + """ + SELECT count(*) AS total_count, + min(created_at) AS oldest_created_at + FROM chat_telemetry + WHERE workspace_id = %s + AND flow_kind = %s + AND event_kind = 'attempt' + AND created_at >= %s + """, + (workspace_id, flow_kind, window_start), + ) + attempts_row = cur.fetchone() + + observed_requests = int(attempts_row["total_count"]) if attempts_row is not None else 0 + + abuse_window_start = timestamp - timedelta(seconds=settings.hosted_abuse_window_seconds) + with conn.cursor() as cur: + cur.execute( + """ + SELECT count(*) AS blocked_count, + min(created_at) AS oldest_created_at + FROM chat_telemetry + WHERE workspace_id = %s + AND flow_kind = %s + AND status IN ('rate_limited', 'abuse_blocked') + AND created_at >= %s + """, + (workspace_id, flow_kind, abuse_window_start), + ) + blocked_row = cur.fetchone() + + blocked_count = int(blocked_row["blocked_count"]) if blocked_row is not None else 0 + if settings.hosted_abuse_controls_enabled_by_default and blocked_count >= settings.hosted_abuse_block_threshold: + oldest = blocked_row["oldest_created_at"] + retry_after = settings.hosted_abuse_window_seconds + if oldest is not None: + elapsed = int((timestamp - oldest).total_seconds()) + retry_after = max(1, settings.hosted_abuse_window_seconds - elapsed) + return { + "allowed": False, + "code": "hosted_abuse_limit_exceeded", + "message": ( + "hosted abuse controls blocked this flow after repeated rate-limit violations" + ), + "retry_after_seconds": retry_after, + "rate_limit_key": policy.key, + "window_seconds": settings.hosted_abuse_window_seconds, + "max_requests": settings.hosted_abuse_block_threshold, + "observed_requests": blocked_count, + "abuse_signal": "repeated_rate_limit_violations", + } + + if observed_requests >= policy.max_requests: + oldest = attempts_row["oldest_created_at"] if attempts_row is not None else None + retry_after = policy.window_seconds + if oldest is not None: + elapsed = int((timestamp - oldest).total_seconds()) + retry_after = max(1, policy.window_seconds - elapsed) + + return { + "allowed": False, + "code": "hosted_rate_limit_exceeded", + "message": ( + "hosted flow rate limit exceeded; " + f"max {policy.max_requests} requests per {policy.window_seconds} seconds" + ), + "retry_after_seconds": retry_after, + "rate_limit_key": policy.key, + "window_seconds": policy.window_seconds, + "max_requests": policy.max_requests, + "observed_requests": observed_requests, + "abuse_signal": None, + } + + return { + "allowed": True, + "code": None, + "message": "within hosted flow rate limits", + "retry_after_seconds": 0, + "rate_limit_key": policy.key, + "window_seconds": policy.window_seconds, + "max_requests": policy.max_requests, + "observed_requests": observed_requests, + "abuse_signal": None, + } diff --git a/apps/api/src/alicebot_api/hosted_rollout.py b/apps/api/src/alicebot_api/hosted_rollout.py new file mode 100644 index 0000000..d24975c --- /dev/null +++ b/apps/api/src/alicebot_api/hosted_rollout.py @@ -0,0 +1,289 @@ +from __future__ import annotations + +from datetime import datetime +from typing import TypedDict +from uuid import UUID + + +class RolloutFlagBlockedError(RuntimeError): + """Raised when a hosted rollout flag blocks the requested operation.""" + + +class FeatureFlagRow(TypedDict): + id: UUID + flag_key: str + cohort_key: str | None + enabled: bool + description: str | None + created_at: datetime + updated_at: datetime + + +class RolloutFlagResolution(TypedDict): + flag_key: str + enabled: bool + source_scope: str + source_cohort_key: str | None + description: str | None + updated_at: str + + +class RolloutFlagPatch(TypedDict): + flag_key: str + enabled: bool + cohort_key: str | None + description: str | None + + +def _get_user_cohort(conn, *, user_account_id: UUID) -> str | None: + with conn.cursor() as cur: + cur.execute( + """ + SELECT beta_cohort_key + FROM user_accounts + WHERE id = %s + LIMIT 1 + """, + (user_account_id,), + ) + row = cur.fetchone() + if row is None: + return None + return row["beta_cohort_key"] + + +def _normalize_flag_key(flag_key: str, *, hosted_only: bool = False) -> str: + normalized = flag_key.strip() + if normalized == "": + raise ValueError("rollout flag key is required") + if len(normalized) > 120: + raise ValueError("rollout flag key must be 120 characters or less") + if hosted_only and not normalized.startswith("hosted_"): + raise ValueError("rollout flag key must start with 'hosted_'") + return normalized + + +def _normalize_cohort_key(cohort_key: str | None) -> str | None: + if cohort_key is None: + return None + normalized = cohort_key.strip() + if normalized == "": + return None + if len(normalized) > 120: + raise ValueError("cohort key must be 120 characters or less") + return normalized + + +def resolve_rollout_flag( + conn, + *, + user_account_id: UUID, + flag_key: str, +) -> RolloutFlagResolution: + normalized_flag_key = _normalize_flag_key(flag_key) + cohort_key = _get_user_cohort(conn, user_account_id=user_account_id) + + with conn.cursor() as cur: + cur.execute( + """ + SELECT id, + flag_key, + cohort_key, + enabled, + description, + created_at, + updated_at + FROM feature_flags + WHERE flag_key = %s + AND (cohort_key IS NULL OR cohort_key = %s) + ORDER BY CASE WHEN cohort_key = %s THEN 0 ELSE 1 END, + updated_at DESC, + id DESC + LIMIT 1 + """, + (normalized_flag_key, cohort_key, cohort_key), + ) + row = cur.fetchone() + + if row is None: + return { + "flag_key": normalized_flag_key, + "enabled": False, + "source_scope": "missing", + "source_cohort_key": None, + "description": None, + "updated_at": "", + } + + source_scope = "cohort" if row["cohort_key"] is not None else "global" + return { + "flag_key": row["flag_key"], + "enabled": bool(row["enabled"]), + "source_scope": source_scope, + "source_cohort_key": row["cohort_key"], + "description": row["description"], + "updated_at": row["updated_at"].isoformat(), + } + + +def ensure_rollout_flag_enabled( + conn, + *, + user_account_id: UUID, + flag_key: str, +) -> RolloutFlagResolution: + resolution = resolve_rollout_flag( + conn, + user_account_id=user_account_id, + flag_key=flag_key, + ) + if not resolution["enabled"]: + raise RolloutFlagBlockedError( + f"rollout flag '{resolution['flag_key']}' is disabled for this account" + ) + return resolution + + +def list_rollout_flags_for_admin( + conn, + *, + user_account_id: UUID, + include_non_hosted_flags: bool = False, +) -> list[RolloutFlagResolution]: + cohort_key = _get_user_cohort(conn, user_account_id=user_account_id) + + with conn.cursor() as cur: + if include_non_hosted_flags: + cur.execute( + """ + SELECT id, + flag_key, + cohort_key, + enabled, + description, + created_at, + updated_at + FROM feature_flags + WHERE cohort_key IS NULL OR cohort_key = %s + ORDER BY flag_key ASC, + CASE WHEN cohort_key = %s THEN 0 ELSE 1 END, + updated_at DESC, + id DESC + """, + (cohort_key, cohort_key), + ) + else: + cur.execute( + """ + SELECT id, + flag_key, + cohort_key, + enabled, + description, + created_at, + updated_at + FROM feature_flags + WHERE flag_key LIKE 'hosted_%%' + AND (cohort_key IS NULL OR cohort_key = %s) + ORDER BY flag_key ASC, + CASE WHEN cohort_key = %s THEN 0 ELSE 1 END, + updated_at DESC, + id DESC + """, + (cohort_key, cohort_key), + ) + rows = cur.fetchall() + + selected: dict[str, FeatureFlagRow] = {} + for row in rows: + key = str(row["flag_key"]) + if key in selected: + continue + selected[key] = row + + payload: list[RolloutFlagResolution] = [] + for key in sorted(selected): + row = selected[key] + payload.append( + { + "flag_key": row["flag_key"], + "enabled": bool(row["enabled"]), + "source_scope": "cohort" if row["cohort_key"] is not None else "global", + "source_cohort_key": row["cohort_key"], + "description": row["description"], + "updated_at": row["updated_at"].isoformat(), + } + ) + + return payload + + +def patch_rollout_flags( + conn, + *, + patches: list[RolloutFlagPatch], + allowed_cohort_key: str | None = None, +) -> list[RolloutFlagResolution]: + updated: list[RolloutFlagResolution] = [] + with conn.cursor() as cur: + for patch in patches: + flag_key = _normalize_flag_key(patch["flag_key"], hosted_only=True) + cohort_key = _normalize_cohort_key(patch.get("cohort_key")) + description = patch.get("description") + enabled = bool(patch["enabled"]) + if cohort_key != allowed_cohort_key: + raise ValueError("rollout flag cohort must match caller cohort") + + if cohort_key is not None: + cur.execute( + """ + SELECT 1 + FROM beta_cohorts + WHERE cohort_key = %s + LIMIT 1 + """, + (cohort_key,), + ) + if cur.fetchone() is None: + raise ValueError(f"cohort {cohort_key!r} was not found") + + cur.execute( + """ + UPDATE feature_flags + SET enabled = %s, + description = COALESCE(%s, description), + updated_at = clock_timestamp() + WHERE flag_key = %s + AND cohort_key IS NOT DISTINCT FROM %s + RETURNING flag_key, cohort_key, enabled, description, updated_at + """, + (enabled, description, flag_key, cohort_key), + ) + row = cur.fetchone() + + if row is None: + cur.execute( + """ + INSERT INTO feature_flags (flag_key, cohort_key, enabled, description) + VALUES (%s, %s, %s, %s) + RETURNING flag_key, cohort_key, enabled, description, updated_at + """, + (flag_key, cohort_key, enabled, description), + ) + row = cur.fetchone() + + if row is None: + raise RuntimeError("failed to patch rollout flag") + + updated.append( + { + "flag_key": row["flag_key"], + "enabled": bool(row["enabled"]), + "source_scope": "cohort" if row["cohort_key"] is not None else "global", + "source_cohort_key": row["cohort_key"], + "description": row["description"], + "updated_at": row["updated_at"].isoformat(), + } + ) + + return updated diff --git a/apps/api/src/alicebot_api/hosted_telemetry.py b/apps/api/src/alicebot_api/hosted_telemetry.py new file mode 100644 index 0000000..86fc9b5 --- /dev/null +++ b/apps/api/src/alicebot_api/hosted_telemetry.py @@ -0,0 +1,336 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from typing import Any, Literal, TypedDict +from uuid import UUID + +from psycopg.types.json import Jsonb + + +HostedFlowKind = Literal["chat_handle", "scheduler_daily_brief", "scheduler_open_loop_prompt"] +HostedTelemetryEventKind = Literal[ + "attempt", + "result", + "rollout_block", + "rate_limited", + "abuse_block", + "incident", +] +HostedTelemetryStatus = Literal[ + "ok", + "failed", + "blocked_rollout", + "rate_limited", + "abuse_blocked", + "suppressed", + "simulated", + "delivered", +] + + +class ChatTelemetryRow(TypedDict): + id: UUID + user_account_id: UUID + workspace_id: UUID | None + channel_message_id: UUID | None + daily_brief_job_id: UUID | None + delivery_receipt_id: UUID | None + flow_kind: HostedFlowKind + event_kind: HostedTelemetryEventKind + status: HostedTelemetryStatus + route_path: str + rollout_flag_key: str | None + rollout_flag_state: str | None + rate_limit_key: str | None + rate_limit_window_seconds: int | None + rate_limit_max_requests: int | None + retry_after_seconds: int | None + abuse_signal: str | None + evidence: dict[str, Any] + created_at: datetime + + +def utc_now() -> datetime: + return datetime.now(UTC) + + +def record_chat_telemetry( + conn, + *, + user_account_id: UUID, + workspace_id: UUID | None, + flow_kind: HostedFlowKind, + event_kind: HostedTelemetryEventKind, + status: HostedTelemetryStatus, + route_path: str, + channel_message_id: UUID | None = None, + daily_brief_job_id: UUID | None = None, + delivery_receipt_id: UUID | None = None, + rollout_flag_key: str | None = None, + rollout_flag_state: str | None = None, + rate_limit_key: str | None = None, + rate_limit_window_seconds: int | None = None, + rate_limit_max_requests: int | None = None, + retry_after_seconds: int | None = None, + abuse_signal: str | None = None, + evidence: dict[str, Any] | None = None, + created_at: datetime | None = None, +) -> ChatTelemetryRow: + normalized_route_path = route_path.strip() + if normalized_route_path == "": + raise ValueError("route_path is required for chat telemetry") + + timestamp = utc_now() if created_at is None else created_at + + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO chat_telemetry ( + user_account_id, + workspace_id, + channel_message_id, + daily_brief_job_id, + delivery_receipt_id, + flow_kind, + event_kind, + status, + route_path, + rollout_flag_key, + rollout_flag_state, + rate_limit_key, + rate_limit_window_seconds, + rate_limit_max_requests, + retry_after_seconds, + abuse_signal, + evidence, + created_at + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id, + user_account_id, + workspace_id, + channel_message_id, + daily_brief_job_id, + delivery_receipt_id, + flow_kind, + event_kind, + status, + route_path, + rollout_flag_key, + rollout_flag_state, + rate_limit_key, + rate_limit_window_seconds, + rate_limit_max_requests, + retry_after_seconds, + abuse_signal, + evidence, + created_at + """, + ( + user_account_id, + workspace_id, + channel_message_id, + daily_brief_job_id, + delivery_receipt_id, + flow_kind, + event_kind, + status, + normalized_route_path, + rollout_flag_key, + rollout_flag_state, + rate_limit_key, + rate_limit_window_seconds, + rate_limit_max_requests, + retry_after_seconds, + abuse_signal, + Jsonb(evidence or {}), + timestamp, + ), + ) + row = cur.fetchone() + + if row is None: + raise RuntimeError("failed to persist hosted chat telemetry") + return row + + +def serialize_chat_telemetry(row: ChatTelemetryRow) -> dict[str, object]: + return { + "id": str(row["id"]), + "user_account_id": str(row["user_account_id"]), + "workspace_id": None if row["workspace_id"] is None else str(row["workspace_id"]), + "channel_message_id": None if row["channel_message_id"] is None else str(row["channel_message_id"]), + "daily_brief_job_id": None if row["daily_brief_job_id"] is None else str(row["daily_brief_job_id"]), + "delivery_receipt_id": None + if row["delivery_receipt_id"] is None + else str(row["delivery_receipt_id"]), + "flow_kind": row["flow_kind"], + "event_kind": row["event_kind"], + "status": row["status"], + "route_path": row["route_path"], + "rollout_flag_key": row["rollout_flag_key"], + "rollout_flag_state": row["rollout_flag_state"], + "rate_limit_key": row["rate_limit_key"], + "rate_limit_window_seconds": row["rate_limit_window_seconds"], + "rate_limit_max_requests": row["rate_limit_max_requests"], + "retry_after_seconds": row["retry_after_seconds"], + "abuse_signal": row["abuse_signal"], + "evidence": row["evidence"], + "created_at": row["created_at"].isoformat(), + } + + +def list_recent_chat_telemetry( + conn, + *, + limit: int, + workspace_id: UUID | None = None, +) -> list[ChatTelemetryRow]: + bounded_limit = max(1, min(limit, 200)) + + with conn.cursor() as cur: + if workspace_id is None: + cur.execute( + """ + SELECT id, + user_account_id, + workspace_id, + channel_message_id, + daily_brief_job_id, + delivery_receipt_id, + flow_kind, + event_kind, + status, + route_path, + rollout_flag_key, + rollout_flag_state, + rate_limit_key, + rate_limit_window_seconds, + rate_limit_max_requests, + retry_after_seconds, + abuse_signal, + evidence, + created_at + FROM chat_telemetry + ORDER BY created_at DESC, id DESC + LIMIT %s + """, + (bounded_limit,), + ) + else: + cur.execute( + """ + SELECT id, + user_account_id, + workspace_id, + channel_message_id, + daily_brief_job_id, + delivery_receipt_id, + flow_kind, + event_kind, + status, + route_path, + rollout_flag_key, + rollout_flag_state, + rate_limit_key, + rate_limit_window_seconds, + rate_limit_max_requests, + retry_after_seconds, + abuse_signal, + evidence, + created_at + FROM chat_telemetry + WHERE workspace_id = %s + ORDER BY created_at DESC, id DESC + LIMIT %s + """, + (workspace_id, bounded_limit), + ) + rows = cur.fetchall() + + return rows + + +def aggregate_chat_telemetry( + conn, + *, + window_hours: int, +) -> dict[str, object]: + bounded_hours = max(1, min(window_hours, 168)) + start_at = utc_now() - timedelta(hours=bounded_hours) + + with conn.cursor() as cur: + cur.execute( + """ + SELECT flow_kind, + status, + count(*) AS total_count + FROM chat_telemetry + WHERE created_at >= %s + GROUP BY flow_kind, status + ORDER BY flow_kind ASC, status ASC + """, + (start_at,), + ) + rows = cur.fetchall() + + flow_counts: dict[str, int] = {} + status_counts: dict[str, int] = {} + matrix: dict[str, dict[str, int]] = {} + + for row in rows: + flow_kind = str(row["flow_kind"]) + status = str(row["status"]) + count = int(row["total_count"]) + + flow_counts[flow_kind] = flow_counts.get(flow_kind, 0) + count + status_counts[status] = status_counts.get(status, 0) + count + + bucket = matrix.setdefault(flow_kind, {}) + bucket[status] = count + + total_events = sum(status_counts.values()) + + with conn.cursor() as cur: + cur.execute( + """ + SELECT date_trunc('hour', created_at) AS hour_bucket, + count(*) AS total_count, + count(*) FILTER (WHERE status = 'ok') AS ok_count, + count(*) FILTER (WHERE status = 'failed') AS failed_count, + count(*) FILTER (WHERE status = 'blocked_rollout') AS blocked_rollout_count, + count(*) FILTER (WHERE status = 'rate_limited') AS rate_limited_count, + count(*) FILTER (WHERE status = 'abuse_blocked') AS abuse_blocked_count + FROM chat_telemetry + WHERE created_at >= %s + GROUP BY hour_bucket + ORDER BY hour_bucket DESC + LIMIT 48 + """, + (start_at,), + ) + hourly_rows = cur.fetchall() + + hourly: list[dict[str, object]] = [] + for row in hourly_rows: + hourly.append( + { + "hour": row["hour_bucket"].isoformat(), + "total_count": int(row["total_count"]), + "ok_count": int(row["ok_count"]), + "failed_count": int(row["failed_count"]), + "blocked_rollout_count": int(row["blocked_rollout_count"]), + "rate_limited_count": int(row["rate_limited_count"]), + "abuse_blocked_count": int(row["abuse_blocked_count"]), + } + ) + + return { + "window_hours": bounded_hours, + "window_start": start_at.isoformat(), + "total_events": total_events, + "flow_counts": flow_counts, + "status_counts": status_counts, + "flow_status_matrix": matrix, + "hourly": hourly, + } diff --git a/apps/api/src/alicebot_api/hosted_workspace.py b/apps/api/src/alicebot_api/hosted_workspace.py new file mode 100644 index 0000000..cb39fcc --- /dev/null +++ b/apps/api/src/alicebot_api/hosted_workspace.py @@ -0,0 +1,272 @@ +from __future__ import annotations + +from datetime import datetime +import re +from typing import TypedDict +from uuid import UUID + + +SLUG_SANITIZE_PATTERN = re.compile(r"[^a-z0-9-]+") +SLUG_COLLAPSE_PATTERN = re.compile(r"-+") + + +class HostedWorkspaceNotFoundError(LookupError): + """Raised when a hosted workspace is not visible for the current account.""" + + +class HostedWorkspaceBootstrapConflictError(RuntimeError): + """Raised when hosted bootstrap is requested after completion.""" + + +class WorkspaceRow(TypedDict): + id: UUID + owner_user_account_id: UUID + slug: str + name: str + bootstrap_status: str + bootstrapped_at: datetime | None + created_at: datetime + updated_at: datetime + + +def slugify_workspace_name(value: str) -> str: + normalized = value.strip().lower().replace(" ", "-") + normalized = SLUG_SANITIZE_PATTERN.sub("-", normalized) + normalized = SLUG_COLLAPSE_PATTERN.sub("-", normalized).strip("-") + if normalized == "": + return "alice-workspace" + return normalized[:120] + + +def _next_available_slug(conn, *, preferred_slug: str) -> str: + base_slug = slugify_workspace_name(preferred_slug) + with conn.cursor() as cur: + for suffix in range(1, 201): + candidate = base_slug if suffix == 1 else f"{base_slug}-{suffix}" + cur.execute("SELECT 1 FROM workspaces WHERE slug = %s", (candidate,)) + if cur.fetchone() is None: + return candidate + + raise RuntimeError("unable to allocate unique workspace slug") + + +def create_workspace( + conn, + *, + user_account_id: UUID, + name: str, + slug: str | None, +) -> WorkspaceRow: + workspace_name = name.strip() + if workspace_name == "": + raise ValueError("workspace name is required") + + preferred_slug = slug if slug is not None and slug.strip() != "" else workspace_name + workspace_slug = _next_available_slug(conn, preferred_slug=preferred_slug) + + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO workspaces (owner_user_account_id, slug, name, bootstrap_status) + VALUES (%s, %s, %s, 'pending') + RETURNING id, owner_user_account_id, slug, name, bootstrap_status, bootstrapped_at, + created_at, updated_at + """, + (user_account_id, workspace_slug, workspace_name), + ) + workspace = cur.fetchone() + + if workspace is None: + raise RuntimeError("failed to create workspace") + + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO workspace_members (workspace_id, user_account_id, role) + VALUES (%s, %s, 'owner') + ON CONFLICT (workspace_id, user_account_id) DO UPDATE + SET role = EXCLUDED.role + """, + (workspace["id"], user_account_id), + ) + + return workspace + + +def get_workspace_for_member( + conn, + *, + workspace_id: UUID, + user_account_id: UUID, +) -> WorkspaceRow | None: + with conn.cursor() as cur: + cur.execute( + """ + SELECT w.id, + w.owner_user_account_id, + w.slug, + w.name, + w.bootstrap_status, + w.bootstrapped_at, + w.created_at, + w.updated_at + FROM workspaces AS w + JOIN workspace_members AS wm + ON wm.workspace_id = w.id + WHERE w.id = %s + AND wm.user_account_id = %s + LIMIT 1 + """, + (workspace_id, user_account_id), + ) + row = cur.fetchone() + + return row + + +def get_current_workspace( + conn, + *, + user_account_id: UUID, + preferred_workspace_id: UUID | None, +) -> WorkspaceRow | None: + if preferred_workspace_id is not None: + preferred = get_workspace_for_member( + conn, + workspace_id=preferred_workspace_id, + user_account_id=user_account_id, + ) + if preferred is not None: + return preferred + + with conn.cursor() as cur: + cur.execute( + """ + SELECT w.id, + w.owner_user_account_id, + w.slug, + w.name, + w.bootstrap_status, + w.bootstrapped_at, + w.created_at, + w.updated_at + FROM workspaces AS w + JOIN workspace_members AS wm + ON wm.workspace_id = w.id + WHERE wm.user_account_id = %s + ORDER BY CASE WHEN wm.role = 'owner' THEN 0 ELSE 1 END, + w.created_at ASC, + w.id ASC + LIMIT 1 + """, + (user_account_id,), + ) + row = cur.fetchone() + + return row + + +def set_session_workspace( + conn, + *, + session_id: UUID, + user_account_id: UUID, + workspace_id: UUID, +) -> None: + workspace = get_workspace_for_member( + conn, + workspace_id=workspace_id, + user_account_id=user_account_id, + ) + if workspace is None: + raise HostedWorkspaceNotFoundError(f"workspace {workspace_id} was not found") + + with conn.cursor() as cur: + cur.execute( + """ + UPDATE auth_sessions + SET workspace_id = %s + WHERE id = %s + AND user_account_id = %s + """, + (workspace_id, session_id, user_account_id), + ) + + +def complete_workspace_bootstrap( + conn, + *, + workspace_id: UUID, + user_account_id: UUID, +) -> WorkspaceRow: + workspace = get_workspace_for_member( + conn, + workspace_id=workspace_id, + user_account_id=user_account_id, + ) + if workspace is None: + raise HostedWorkspaceNotFoundError(f"workspace {workspace_id} was not found") + + if workspace["bootstrap_status"] == "ready": + raise HostedWorkspaceBootstrapConflictError( + f"workspace {workspace_id} bootstrap is already complete" + ) + + with conn.cursor() as cur: + cur.execute( + """ + UPDATE workspaces + SET bootstrap_status = 'ready', + bootstrapped_at = clock_timestamp(), + updated_at = clock_timestamp() + WHERE id = %s + RETURNING id, owner_user_account_id, slug, name, bootstrap_status, bootstrapped_at, + created_at, updated_at + """, + (workspace_id,), + ) + row = cur.fetchone() + + if row is None: + raise HostedWorkspaceNotFoundError(f"workspace {workspace_id} was not found") + return row + + +def get_bootstrap_status( + conn, + *, + workspace_id: UUID, + user_account_id: UUID, +) -> dict[str, object]: + workspace = get_workspace_for_member( + conn, + workspace_id=workspace_id, + user_account_id=user_account_id, + ) + if workspace is None: + raise HostedWorkspaceNotFoundError(f"workspace {workspace_id} was not found") + + return { + "workspace_id": str(workspace["id"]), + "status": workspace["bootstrap_status"], + "bootstrapped_at": None + if workspace["bootstrapped_at"] is None + else workspace["bootstrapped_at"].isoformat(), + "ready_for_next_phase_telegram_linkage": workspace["bootstrap_status"] == "ready", + "telegram_state": "available_in_p10_s2_transport", + } + + +def serialize_workspace(workspace: WorkspaceRow) -> dict[str, object]: + return { + "id": str(workspace["id"]), + "owner_user_account_id": str(workspace["owner_user_account_id"]), + "slug": workspace["slug"], + "name": workspace["name"], + "bootstrap_status": workspace["bootstrap_status"], + "bootstrapped_at": None + if workspace["bootstrapped_at"] is None + else workspace["bootstrapped_at"].isoformat(), + "created_at": workspace["created_at"].isoformat(), + "updated_at": workspace["updated_at"].isoformat(), + } diff --git a/apps/api/src/alicebot_api/importer_models.py b/apps/api/src/alicebot_api/importer_models.py new file mode 100644 index 0000000..680871f --- /dev/null +++ b/apps/api/src/alicebot_api/importer_models.py @@ -0,0 +1,283 @@ +from __future__ import annotations + +from dataclasses import dataclass +from hashlib import sha256 +import json + +from alicebot_api.store import JsonObject + + +CONTINUITY_IMPORT_STATUSES = { + "active", + "stale", + "completed", + "cancelled", + "superseded", +} + +CONTINUITY_IMPORT_OBJECT_TYPES = { + "Decision", + "NextAction", + "Commitment", + "WaitingFor", + "Blocker", + "MemoryFact", + "Note", +} + +OBJECT_TYPE_TO_EXPLICIT_SIGNAL: dict[str, str] = { + "Decision": "decision", + "NextAction": "next_action", + "Commitment": "commitment", + "WaitingFor": "waiting_for", + "Blocker": "blocker", + "MemoryFact": "remember_this", + "Note": "note", +} + +OBJECT_TYPE_TO_BODY_KEY: dict[str, str] = { + "Note": "body", + "MemoryFact": "fact_text", + "Decision": "decision_text", + "Commitment": "commitment_text", + "WaitingFor": "waiting_for_text", + "Blocker": "blocking_reason", + "NextAction": "action_text", +} + +OBJECT_TYPE_TO_PREFIX: dict[str, str] = { + "Decision": "Decision", + "Commitment": "Commitment", + "WaitingFor": "Waiting For", + "Blocker": "Blocker", + "NextAction": "Next Action", + "MemoryFact": "Memory Fact", + "Note": "Note", +} + +_TYPE_ALIAS_TO_OBJECT_TYPE: dict[str, str] = { + "decision": "Decision", + "decisions": "Decision", + "task": "NextAction", + "next": "NextAction", + "next_action": "NextAction", + "nextaction": "NextAction", + "action": "NextAction", + "commitment": "Commitment", + "waiting": "WaitingFor", + "waiting_for": "WaitingFor", + "waitingfor": "WaitingFor", + "blocker": "Blocker", + "fact": "MemoryFact", + "memory_fact": "MemoryFact", + "memory": "MemoryFact", + "note": "Note", +} + + +class ImporterValidationError(ValueError): + """Raised when an importer source payload is invalid.""" + + +@dataclass(frozen=True, slots=True) +class ImporterWorkspaceContext: + fixture_id: str | None + workspace_id: str + workspace_name: str | None + source_path: str + + +@dataclass(frozen=True, slots=True) +class ImporterNormalizedItem: + source_item_id: str + source_file: str + object_type: str + status: str + raw_content: str + title: str + body: JsonObject + confidence: float + source_provenance: JsonObject + dedupe_key: str + + +@dataclass(frozen=True, slots=True) +class ImporterNormalizedBatch: + context: ImporterWorkspaceContext + items: list[ImporterNormalizedItem] + + +def normalize_optional_text(value: object) -> str | None: + if not isinstance(value, str): + return None + normalized = " ".join(value.split()).strip() + if normalized == "": + return None + return normalized + + +def normalize_required_text(value: object, *, field_name: str) -> str: + normalized = normalize_optional_text(value) + if normalized is None: + raise ImporterValidationError(f"{field_name} must be a non-empty string") + return normalized + + +def normalize_object_type(value: object, *, default: str = "Note") -> str: + normalized = normalize_optional_text(value) + if normalized is None: + return default + + if normalized in CONTINUITY_IMPORT_OBJECT_TYPES: + return normalized + + lowered = normalized.casefold().replace("-", "_").replace(" ", "_") + return _TYPE_ALIAS_TO_OBJECT_TYPE.get(lowered, default) + + +def parse_optional_confidence(value: object) -> float | None: + if value is None: + return None + + if isinstance(value, bool): + raise ImporterValidationError("confidence must be a number") + + if isinstance(value, (int, float)): + parsed = float(value) + elif isinstance(value, str): + stripped = value.strip() + if stripped == "": + return None + try: + parsed = float(stripped) + except ValueError as exc: + raise ImporterValidationError("confidence must be a number") from exc + else: + raise ImporterValidationError("confidence must be a number") + + if parsed < 0.0 or parsed > 1.0: + raise ImporterValidationError("confidence must be between 0.0 and 1.0") + return parsed + + +def parse_optional_status(value: object) -> str | None: + normalized = normalize_optional_text(value) + if normalized is None: + return None + lowered = normalized.casefold() + if lowered not in CONTINUITY_IMPORT_STATUSES: + supported = ", ".join(sorted(CONTINUITY_IMPORT_STATUSES)) + raise ImporterValidationError( + f"status must be one of: {supported}" + ) + return lowered + + +def ensure_json_object(value: object, *, field_name: str) -> JsonObject: + if not isinstance(value, dict): + raise ImporterValidationError(f"{field_name} must be a JSON object") + return value + + +def canonicalize_json(value: object) -> object: + if isinstance(value, dict): + return { + str(key): canonicalize_json(value[key]) + for key in sorted(value) + } + if isinstance(value, list): + return [canonicalize_json(item) for item in value] + return value + + +def canonical_json_string(value: object) -> str: + return json.dumps( + canonicalize_json(value), + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + ) + + +def dedupe_key_for_payload(value: object) -> str: + return sha256(canonical_json_string(value).encode("utf-8")).hexdigest() + + +def as_json_object(value: object) -> JsonObject: + if not isinstance(value, dict): + return {} + output: JsonObject = {} + for key, child in value.items(): + if not isinstance(key, str): + continue + output[key] = _as_json_value(child) + return output + + +def _as_json_value(value: object): + if value is None or isinstance(value, (str, int, float, bool)): + return value + if isinstance(value, list): + return [_as_json_value(item) for item in value] + if isinstance(value, dict): + return as_json_object(value) + return str(value) + + +def merge_json_objects(*payloads: JsonObject) -> JsonObject: + merged: JsonObject = {} + for payload in payloads: + merged.update(payload) + return merged + + +def pick_first_text(*candidates: object) -> str | None: + for candidate in candidates: + normalized = normalize_optional_text(candidate) + if normalized is not None: + return normalized + return None + + +def to_string_list(value: object) -> list[str]: + if isinstance(value, str): + normalized = normalize_optional_text(value) + return [] if normalized is None else [normalized] + + if isinstance(value, list): + items: list[str] = [] + seen: set[str] = set() + for raw in value: + normalized = normalize_optional_text(raw) + if normalized is None or normalized in seen: + continue + items.append(normalized) + seen.add(normalized) + return items + + return [] + + +__all__ = [ + "CONTINUITY_IMPORT_OBJECT_TYPES", + "CONTINUITY_IMPORT_STATUSES", + "ImporterNormalizedBatch", + "ImporterNormalizedItem", + "ImporterValidationError", + "ImporterWorkspaceContext", + "OBJECT_TYPE_TO_BODY_KEY", + "OBJECT_TYPE_TO_EXPLICIT_SIGNAL", + "OBJECT_TYPE_TO_PREFIX", + "as_json_object", + "canonical_json_string", + "dedupe_key_for_payload", + "ensure_json_object", + "merge_json_objects", + "normalize_object_type", + "normalize_optional_text", + "normalize_required_text", + "parse_optional_confidence", + "parse_optional_status", + "pick_first_text", + "to_string_list", +] diff --git a/apps/api/src/alicebot_api/importers/__init__.py b/apps/api/src/alicebot_api/importers/__init__.py new file mode 100644 index 0000000..80b4c9a --- /dev/null +++ b/apps/api/src/alicebot_api/importers/__init__.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +from alicebot_api.importers.common import ImportPersistenceConfig, import_normalized_batch + + +__all__ = ["ImportPersistenceConfig", "import_normalized_batch"] diff --git a/apps/api/src/alicebot_api/importers/common.py b/apps/api/src/alicebot_api/importers/common.py new file mode 100644 index 0000000..a3ecaaa --- /dev/null +++ b/apps/api/src/alicebot_api/importers/common.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +from dataclasses import dataclass +from uuid import UUID + +from alicebot_api.importer_models import ( + ImporterNormalizedBatch, + OBJECT_TYPE_TO_EXPLICIT_SIGNAL, + to_string_list, +) +from alicebot_api.store import ContinuityStore, JsonObject + + +@dataclass(frozen=True, slots=True) +class ImportPersistenceConfig: + source_kind: str + source_prefix: str + admission_reason: str + dedupe_key_field: str + dedupe_posture: str + source_label: str | None = None + + +def _existing_dedupe_keys( + store: ContinuityStore, + *, + source_kind: str, + dedupe_key_field: str, +) -> set[str]: + dedupe_keys: set[str] = set() + for row in store.list_continuity_recall_candidates(): + provenance = row["provenance"] + if not isinstance(provenance, dict): + continue + if provenance.get("source_kind") != source_kind: + continue + dedupe_key = provenance.get(dedupe_key_field) + if isinstance(dedupe_key, str) and dedupe_key.strip() != "": + dedupe_keys.add(dedupe_key) + return dedupe_keys + + +def _deterministic_source_event_id(*, source_kind: str, workspace_id: str, source_item_id: str) -> str: + return f"{source_kind}:{workspace_id}:{source_item_id}" + + +def _build_provenance( + *, + batch: ImporterNormalizedBatch, + source_file: str, + source_item_id: str, + source_provenance: JsonObject, + source_dedupe_key: str, + source_event_ids: list[str], + config: ImportPersistenceConfig, +) -> JsonObject: + source_prefix = config.source_prefix + provenance = { + **source_provenance, + "source_event_ids": source_event_ids, + "source_kind": config.source_kind, + f"{source_prefix}_workspace_id": batch.context.workspace_id, + f"{source_prefix}_workspace_name": batch.context.workspace_name, + f"{source_prefix}_fixture_id": batch.context.fixture_id, + f"{source_prefix}_source_path": batch.context.source_path, + f"{source_prefix}_source_file": source_file, + f"{source_prefix}_source_item_id": source_item_id, + config.dedupe_key_field: source_dedupe_key, + f"{source_prefix}_dedupe_posture": config.dedupe_posture, + } + if config.source_label is not None: + provenance["source_label"] = config.source_label + return provenance + + +def import_normalized_batch( + store: ContinuityStore, + *, + user_id: UUID, + batch: ImporterNormalizedBatch, + config: ImportPersistenceConfig, +) -> JsonObject: + del user_id + + existing_dedupe_keys = _existing_dedupe_keys( + store, + source_kind=config.source_kind, + dedupe_key_field=config.dedupe_key_field, + ) + run_dedupe_keys: set[str] = set() + + imported_object_ids: list[str] = [] + imported_capture_ids: list[str] = [] + skipped_duplicates = 0 + + for item in batch.items: + if item.dedupe_key in existing_dedupe_keys or item.dedupe_key in run_dedupe_keys: + skipped_duplicates += 1 + continue + + run_dedupe_keys.add(item.dedupe_key) + + capture = store.create_continuity_capture_event( + raw_content=item.raw_content, + explicit_signal=OBJECT_TYPE_TO_EXPLICIT_SIGNAL[item.object_type], + admission_posture="DERIVED", + admission_reason=config.admission_reason, + ) + + source_event_ids = to_string_list(item.source_provenance.get("source_event_ids")) + if not source_event_ids: + source_event_ids = [ + _deterministic_source_event_id( + source_kind=config.source_kind, + workspace_id=batch.context.workspace_id, + source_item_id=item.source_item_id, + ) + ] + + provenance = _build_provenance( + batch=batch, + source_file=item.source_file, + source_item_id=item.source_item_id, + source_provenance=item.source_provenance, + source_dedupe_key=item.dedupe_key, + source_event_ids=source_event_ids, + config=config, + ) + + continuity_object = store.create_continuity_object( + capture_event_id=capture["id"], + object_type=item.object_type, + status=item.status, + title=item.title, + body=item.body, + provenance=provenance, + confidence=item.confidence, + ) + + imported_capture_ids.append(str(capture["id"])) + imported_object_ids.append(str(continuity_object["id"])) + + imported_count = len(imported_object_ids) + status = "ok" if imported_count > 0 else "noop" + + return { + "status": status, + "source_path": batch.context.source_path, + "fixture_id": batch.context.fixture_id, + "workspace_id": batch.context.workspace_id, + "workspace_name": batch.context.workspace_name, + "total_candidates": len(batch.items), + "imported_count": imported_count, + "skipped_duplicates": skipped_duplicates, + "dedupe_posture": config.dedupe_posture, + "provenance_source_kind": config.source_kind, + "provenance_source_label": config.source_label, + "imported_capture_event_ids": imported_capture_ids, + "imported_object_ids": imported_object_ids, + } + + +__all__ = ["ImportPersistenceConfig", "import_normalized_batch"] diff --git a/apps/api/src/alicebot_api/main.py b/apps/api/src/alicebot_api/main.py new file mode 100644 index 0000000..c03724b --- /dev/null +++ b/apps/api/src/alicebot_api/main.py @@ -0,0 +1,7588 @@ +from __future__ import annotations + +from collections import defaultdict, deque +from datetime import datetime +import hmac +import hashlib +import json +import threading +import time +from typing import Annotated, Awaitable, Callable, Literal, TypedDict +from uuid import UUID +from fastapi import FastAPI, Query, Request, Response +from fastapi.encoders import jsonable_encoder +from pydantic import BaseModel, ConfigDict, Field, model_validator +from fastapi.responses import JSONResponse +import psycopg +from psycopg.rows import dict_row +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit +try: + import redis + from redis.exceptions import RedisError +except Exception: # pragma: no cover - optional dependency for local-only test environments + redis = None + + class RedisError(Exception): + """Fallback Redis error used when redis package is unavailable.""" + +from alicebot_api.compiler import compile_and_persist_trace, compile_resumption_brief +from alicebot_api.config import Settings, get_settings +from alicebot_api.contracts import ( + AGENT_PROFILE_LIST_ORDER, + ApprovalApproveInput, + ApprovalRejectInput, + ApprovalRequestCreateInput, + AgentProfileListResponse, + AgentProfileListSummary, + ArtifactScopedSemanticArtifactChunkRetrievalInput, + CompileContextArtifactScopedSemanticArtifactRetrievalInput, + CompileContextArtifactScopedArtifactRetrievalInput, + CompileContextSemanticArtifactRetrievalInput, + CompileContextTaskScopedArtifactRetrievalInput, + CompileContextTaskScopedSemanticArtifactRetrievalInput, + ConsentStatus, + ConsentUpsertInput, + CompileContextSemanticRetrievalInput, + DEFAULT_ARTIFACT_CHUNK_RETRIEVAL_LIMIT, + DEFAULT_AGENT_PROFILE_ID, + DEFAULT_CALENDAR_EVENT_LIST_LIMIT, + DEFAULT_CONTINUITY_CAPTURE_LIMIT, + DEFAULT_CONTINUITY_LIFECYCLE_LIMIT, + DEFAULT_CONTINUITY_REVIEW_LIMIT, + DEFAULT_CONTINUITY_RECALL_LIMIT, + DEFAULT_CONTINUITY_OPEN_LOOP_LIMIT, + DEFAULT_CONTINUITY_DAILY_BRIEF_LIMIT, + DEFAULT_CONTINUITY_WEEKLY_REVIEW_LIMIT, + DEFAULT_CONTINUITY_RESUMPTION_OPEN_LOOP_LIMIT, + DEFAULT_CONTINUITY_RESUMPTION_RECENT_CHANGES_LIMIT, + DEFAULT_CHIEF_OF_STAFF_PRIORITY_LIMIT, + DEFAULT_MAX_EVENTS, + DEFAULT_MAX_ENTITY_EDGES, + DEFAULT_MAX_ENTITIES, + DEFAULT_MAX_MEMORIES, + DEFAULT_MEMORY_REVIEW_LIMIT, + DEFAULT_MEMORY_REVIEW_QUEUE_PRIORITY_MODE, + DEFAULT_OPEN_LOOP_LIMIT, + DEFAULT_RESUMPTION_BRIEF_EVENT_LIMIT, + DEFAULT_RESUMPTION_BRIEF_MEMORY_LIMIT, + DEFAULT_RESUMPTION_BRIEF_OPEN_LOOP_LIMIT, + DEFAULT_MAX_SESSIONS, + DEFAULT_SEMANTIC_MEMORY_RETRIEVAL_LIMIT, + MAX_MEMORY_REVIEW_LIMIT, + MAX_OPEN_LOOP_LIMIT, + MAX_RESUMPTION_BRIEF_EVENT_LIMIT, + MAX_RESUMPTION_BRIEF_MEMORY_LIMIT, + MAX_RESUMPTION_BRIEF_OPEN_LOOP_LIMIT, + MAX_ARTIFACT_CHUNK_RETRIEVAL_LIMIT, + MAX_CALENDAR_EVENT_LIST_LIMIT, + MAX_CONTINUITY_CAPTURE_LIMIT, + MAX_CONTINUITY_LIFECYCLE_LIMIT, + MAX_CONTINUITY_REVIEW_LIMIT, + MAX_CONTINUITY_RECALL_LIMIT, + MAX_CONTINUITY_OPEN_LOOP_LIMIT, + MAX_CONTINUITY_DAILY_BRIEF_LIMIT, + MAX_CONTINUITY_WEEKLY_REVIEW_LIMIT, + MAX_CONTINUITY_RESUMPTION_OPEN_LOOP_LIMIT, + MAX_CONTINUITY_RESUMPTION_RECENT_CHANGES_LIMIT, + MAX_CHIEF_OF_STAFF_PRIORITY_LIMIT, + MAX_SEMANTIC_MEMORY_RETRIEVAL_LIMIT, + ContextCompilerLimits, + ContinuityCaptureCreateInput, + ContinuityLifecycleDetailResponse, + ContinuityLifecycleListResponse, + ContinuityLifecycleQueryInput, + ContinuityDailyBriefRequestInput, + ContinuityDailyBriefResponse, + ContinuityOpenLoopDashboardQueryInput, + ContinuityOpenLoopDashboardResponse, + ContinuityOpenLoopReviewActionInput, + ContinuityOpenLoopReviewActionResponse, + ContinuityCorrectionInput, + ContinuityRecallQueryInput, + ContinuityRecallResponse, + ContinuityReviewDetailResponse, + ContinuityReviewQueueQueryInput, + ContinuityReviewQueueResponse, + ContinuityResumptionBriefRequestInput, + ContinuityResumptionBriefResponse, + ChiefOfStaffPriorityBriefRequestInput, + ChiefOfStaffPriorityBriefResponse, + ChiefOfStaffExecutionRoutingActionInput, + ChiefOfStaffExecutionRoutingActionCaptureResponse, + ChiefOfStaffHandoffOutcomeCaptureInput, + ChiefOfStaffHandoffOutcomeCaptureResponse, + ChiefOfStaffHandoffReviewActionInput, + ChiefOfStaffHandoffReviewActionCaptureResponse, + ChiefOfStaffRecommendationOutcomeCaptureInput, + ChiefOfStaffRecommendationOutcomeCaptureResponse, + ContinuityWeeklyReviewRequestInput, + ContinuityWeeklyReviewResponse, + MemoryTrustDashboardResponse, + RetrievalEvaluationResponse, + EmbeddingConfigStatus, + EmbeddingConfigCreateInput, + ExecutionBudgetCreateInput, + ExecutionBudgetDeactivateInput, + ExecutionBudgetSupersedeInput, + EntityEdgeCreateInput, + EntityCreateInput, + EntityType, + ExplicitCommitmentExtractionRequestInput, + ExplicitPreferenceExtractionRequestInput, + ExplicitSignalCaptureRequestInput, + CALENDAR_READONLY_SCOPE, + GMAIL_READONLY_SCOPE, + CalendarAccountConnectInput, + CalendarEventListInput, + CalendarEventIngestInput, + GmailAccountConnectInput, + GmailMessageIngestInput, + MemoryCandidateInput, + OpenLoopCandidateInput, + MemoryEmbeddingUpsertInput, + THREAD_EVENT_LIST_ORDER, + THREAD_LIST_ORDER, + THREAD_SESSION_LIST_ORDER, + MemoryReviewLabelValue, + MemoryReviewQueuePriorityMode, + MemoryReviewStatusFilter, + OpenLoopStatusFilter, + OpenLoopCreateInput, + OpenLoopStatusUpdateInput, + PolicyCreateInput, + PolicyEffect, + PolicyEvaluationRequestInput, + SemanticMemoryRetrievalRequestInput, + TaskArtifactChunkEmbeddingUpsertInput, + TOOL_METADATA_VERSION_V0, + ApprovalStatus, + ArtifactScopedArtifactChunkRetrievalInput, + ProxyExecutionStatus, + ToolAllowlistEvaluationRequestInput, + ProxyExecutionRequestInput, + TaskArtifactIngestInput, + TaskArtifactRegisterInput, + TaskScopedSemanticArtifactChunkRetrievalInput, + TaskScopedArtifactChunkRetrievalInput, + TaskStepKind, + TaskStepLineageInput, + TaskStepNextCreateInput, + TaskStepStatus, + TaskStepTransitionInput, + TaskRunCancelInput, + TaskRunCreateInput, + TaskRunPauseInput, + TaskRunResumeInput, + TaskRunTickInput, + TaskWorkspaceCreateInput, + ToolRoutingDecision, + ToolRoutingRequestInput, + ToolCreateInput, + ThreadCreateInput, + ThreadCreateResponse, + ThreadDetailResponse, + ThreadEventListResponse, + ThreadEventListSummary, + ThreadEventRecord, + ThreadListResponse, + ThreadListSummary, + ThreadRecord, + ResumptionBriefRequestInput, + ResumptionBriefResponse, + ThreadSessionListResponse, + ThreadSessionListSummary, + ThreadSessionRecord, +) +from alicebot_api.phase3_profiles import ( + get_agent_profile as get_registered_agent_profile, + list_agent_profile_ids as list_registered_agent_profile_ids, + list_agent_profiles as list_registered_agent_profiles, +) +from alicebot_api.artifacts import ( + TaskArtifactAlreadyExistsError, + TaskArtifactChunkRetrievalValidationError, + TaskArtifactNotFoundError, + TaskArtifactValidationError, + get_task_artifact_record, + ingest_task_artifact_record, + list_task_artifact_chunk_records, + list_task_artifact_records, + register_task_artifact_record, + retrieve_artifact_scoped_artifact_chunk_records, + retrieve_task_scoped_artifact_chunk_records, +) +from alicebot_api.approvals import ( + ApprovalNotFoundError, + ApprovalResolutionConflictError, + approve_approval_record, + get_approval_record, + list_approval_records, + reject_approval_record, + submit_approval_request, +) +from alicebot_api.db import ping_database, user_connection +from alicebot_api.executions import ( + ToolExecutionNotFoundError, + get_tool_execution_record, + list_tool_execution_records, +) +from alicebot_api.tasks import ( + TaskNotFoundError, + TaskStepApprovalLinkageError, + TaskStepExecutionLinkageError, + TaskStepLifecycleBoundaryError, + TaskStepSequenceError, + TaskStepNotFoundError, + TaskStepTransitionError, + create_next_task_step_record, + get_task_record, + get_task_step_record, + list_task_records, + list_task_step_records, + transition_task_step_record, +) +from alicebot_api.task_runs import ( + TaskRunNotFoundError, + TaskRunTransitionError, + TaskRunValidationError, + cancel_task_run_record, + create_task_run_record, + get_task_run_record, + list_task_run_records, + pause_task_run_record, + resume_task_run_record, + tick_task_run_record, +) +from alicebot_api.workspaces import ( + TaskWorkspaceAlreadyExistsError, + TaskWorkspaceNotFoundError, + TaskWorkspaceProvisioningError, + create_task_workspace_record, + get_task_workspace_record, + list_task_workspace_records, +) +from alicebot_api.execution_budgets import ( + ExecutionBudgetLifecycleError, + ExecutionBudgetNotFoundError, + ExecutionBudgetValidationError, + create_execution_budget_record, + deactivate_execution_budget_record, + get_execution_budget_record, + list_execution_budget_records, + supersede_execution_budget_record, +) +from alicebot_api.gmail import ( + GmailAccountAlreadyExistsError, + GmailCredentialInvalidError, + GmailCredentialNotFoundError, + GmailCredentialPersistenceError, + GmailCredentialRefreshError, + GmailCredentialValidationError, + GmailAccountNotFoundError, + GmailMessageFetchError, + GmailMessageNotFoundError, + GmailMessageUnsupportedError, + create_gmail_account_record, + get_gmail_account_record, + ingest_gmail_message_record, + list_gmail_account_records, +) +from alicebot_api.calendar import ( + CalendarAccountAlreadyExistsError, + CalendarAccountNotFoundError, + CalendarCredentialInvalidError, + CalendarCredentialNotFoundError, + CalendarCredentialPersistenceError, + CalendarCredentialValidationError, + CalendarEventFetchError, + CalendarEventListValidationError, + CalendarEventNotFoundError, + CalendarEventUnsupportedError, + create_calendar_account_record, + get_calendar_account_record, + ingest_calendar_event_record, + list_calendar_account_records, + list_calendar_event_records, +) +from alicebot_api.calendar_secret_manager import build_calendar_secret_manager +from alicebot_api.gmail_secret_manager import build_gmail_secret_manager +from alicebot_api.embedding import ( + EmbeddingConfigValidationError, + MemoryEmbeddingNotFoundError, + MemoryEmbeddingValidationError, + TaskArtifactChunkEmbeddingNotFoundError, + TaskArtifactChunkEmbeddingValidationError, + create_embedding_config_record, + get_memory_embedding_record, + get_task_artifact_chunk_embedding_record, + list_embedding_config_records, + list_memory_embedding_records, + list_task_artifact_chunk_embedding_records_for_artifact, + list_task_artifact_chunk_embedding_records_for_chunk, + upsert_task_artifact_chunk_embedding_record, + upsert_memory_embedding_record, +) +from alicebot_api.entity import ( + EntityNotFoundError, + EntityValidationError, + create_entity_record, + get_entity_record, + list_entity_records, +) +from alicebot_api.entity_edge import ( + EntityEdgeValidationError, + create_entity_edge_record, + list_entity_edge_records, +) +from alicebot_api.explicit_preferences import ( + ExplicitPreferenceExtractionValidationError, + extract_and_admit_explicit_preferences, +) +from alicebot_api.explicit_commitments import ( + ExplicitCommitmentExtractionValidationError, + extract_and_admit_explicit_commitments, +) +from alicebot_api.explicit_signal_capture import ( + ExplicitSignalCaptureValidationError, + extract_and_admit_explicit_signals, +) +from alicebot_api.continuity_capture import ( + ContinuityCaptureNotFoundError, + ContinuityCaptureValidationError, + capture_continuity_input, + get_continuity_capture_detail, + list_continuity_capture_inbox, +) +from alicebot_api.continuity_lifecycle import ( + ContinuityLifecycleNotFoundError, + ContinuityLifecycleValidationError, + get_continuity_lifecycle_state, + list_continuity_lifecycle_state, +) +from alicebot_api.continuity_recall import ( + ContinuityRecallValidationError, + query_continuity_recall, +) +from alicebot_api.retrieval_evaluation import get_retrieval_evaluation_summary +from alicebot_api.hosted_auth import ( + AuthSessionExpiredError, + AuthSessionInvalidError, + AuthSessionRevokedDeviceError, + MagicLinkTokenExpiredError, + MagicLinkTokenInvalidError, + ensure_user_preferences_row, + list_feature_flags_for_user, + logout_auth_session, + resolve_auth_session, + serialize_auth_session, + serialize_magic_link_challenge, + serialize_user_account, + start_magic_link_challenge, + verify_magic_link_challenge, +) +from alicebot_api.hosted_devices import ( + DeviceLinkTokenExpiredError, + DeviceLinkTokenInvalidError, + HostedDeviceNotFoundError, + confirm_device_link_challenge, + list_devices as list_hosted_devices, + revoke_device as revoke_hosted_device, + serialize_device, + serialize_device_link_challenge, + start_device_link_challenge, +) +from alicebot_api.hosted_preferences import ( + HostedPreferencesValidationError, + ensure_user_preferences, + patch_user_preferences, + serialize_user_preferences, +) +from alicebot_api.hosted_workspace import ( + HostedWorkspaceBootstrapConflictError, + HostedWorkspaceNotFoundError, + complete_workspace_bootstrap, + create_workspace, + get_bootstrap_status, + get_current_workspace, + get_workspace_for_member, + serialize_workspace, + set_session_workspace, +) +from alicebot_api.hosted_rollout import ( + list_rollout_flags_for_admin, + patch_rollout_flags, + resolve_rollout_flag, +) +from alicebot_api.hosted_telemetry import ( + aggregate_chat_telemetry, + record_chat_telemetry, +) +from alicebot_api.hosted_rate_limits import evaluate_hosted_flow_limits +from alicebot_api.hosted_admin import ( + get_hosted_overview_for_admin, + get_hosted_rate_limits_for_admin, + list_hosted_delivery_receipts_for_admin, + list_hosted_incidents_for_admin, + list_hosted_workspaces_for_admin, +) +from alicebot_api.telegram_channels import ( + TelegramIdentityNotFoundError, + TelegramLinkPendingError, + TelegramLinkTokenExpiredError, + TelegramLinkTokenInvalidError, + TelegramMessageNotFoundError, + TelegramRoutingError, + TelegramWebhookValidationError, + confirm_telegram_link_challenge, + dispatch_telegram_message, + get_telegram_link_status, + ingest_telegram_webhook, + list_workspace_telegram_delivery_receipts, + list_workspace_telegram_messages, + list_workspace_telegram_threads, + serialize_channel_identity, + serialize_channel_link_challenge, + serialize_channel_message, + serialize_channel_thread, + serialize_delivery_receipt, + serialize_webhook_ingest_result, + start_telegram_link_challenge, + unlink_telegram_identity, +) +from alicebot_api.telegram_continuity import ( + HostedUserAccountNotFoundError, + TelegramMessageResultNotFoundError, + apply_telegram_open_loop_review_with_log, + approve_telegram_approval, + get_telegram_message_result, + handle_telegram_message, + list_telegram_approvals, + prepare_telegram_continuity_context, + reject_telegram_approval, +) +from alicebot_api.telegram_notifications import ( + TelegramNotificationPreferenceValidationError, + TelegramOpenLoopPromptNotFoundError, + deliver_workspace_daily_brief, + deliver_workspace_open_loop_prompt, + get_workspace_daily_brief_preview, + get_workspace_notification_preferences, + list_workspace_open_loop_prompts, + list_workspace_scheduler_jobs, + patch_workspace_notification_subscription, +) +from alicebot_api.continuity_review import ( + ContinuityReviewNotFoundError, + ContinuityReviewValidationError, + apply_continuity_correction, + get_continuity_review_detail, + list_continuity_review_queue, +) +from alicebot_api.continuity_resumption import ( + ContinuityResumptionValidationError, + compile_continuity_resumption_brief, +) +from alicebot_api.chief_of_staff import ( + ChiefOfStaffValidationError, + capture_chief_of_staff_execution_routing_action, + capture_chief_of_staff_handoff_outcome, + capture_chief_of_staff_handoff_review_action, + capture_chief_of_staff_recommendation_outcome, + compile_chief_of_staff_priority_brief, +) +from alicebot_api.continuity_open_loops import ( + ContinuityOpenLoopNotFoundError, + ContinuityOpenLoopValidationError, + apply_continuity_open_loop_review_action, + compile_continuity_daily_brief, + compile_continuity_open_loop_dashboard, + compile_continuity_weekly_review, +) +from alicebot_api.continuity_objects import ContinuityObjectValidationError +from alicebot_api.memory import ( + MemoryAdmissionValidationError, + MemoryReviewNotFoundError, + OpenLoopNotFoundError, + OpenLoopValidationError, + admit_memory_candidate, + create_open_loop_record, + create_memory_review_label_record, + get_open_loop_record, + get_memory_evaluation_summary, + get_memory_quality_gate_summary, + get_memory_trust_dashboard_summary, + get_memory_review_record, + list_open_loop_records, + list_memory_review_queue_records, + list_memory_review_label_records, + list_memory_review_records, + list_memory_revision_review_records, + update_open_loop_status_record, +) +from alicebot_api.policy import ( + PolicyEvaluationValidationError, + PolicyNotFoundError, + PolicyValidationError, + create_policy_record, + evaluate_policy_request, + get_policy_record, + list_consent_records, + list_policy_records, + upsert_consent_record, +) +from alicebot_api.tools import ( + ToolAllowlistValidationError, + ToolNotFoundError, + ToolRoutingValidationError, + ToolValidationError, + create_tool_record, + evaluate_tool_allowlist, + get_tool_record, + list_tool_records, + route_tool_invocation, +) +from alicebot_api.semantic_retrieval import ( + SemanticArtifactChunkRetrievalValidationError, + SemanticMemoryRetrievalValidationError, + retrieve_artifact_scoped_semantic_artifact_chunk_records, + retrieve_semantic_memory_records, + retrieve_task_scoped_semantic_artifact_chunk_records, +) +from alicebot_api.response_generation import ( + ResponseFailure, + generate_response, +) +from alicebot_api.proxy_execution import ( + ProxyExecutionApprovalStateError, + ProxyExecutionHandlerNotFoundError, + ProxyExecutionIdempotencyError, + execute_approved_proxy_request, +) +from alicebot_api.store import ( + ContinuityStore, + ContinuityStoreInvariantError, + EventRow, + SessionRow, + ThreadRow, +) +from alicebot_api.traces import ( + TraceNotFoundError, + get_trace_record, + list_trace_event_records, + list_trace_records, +) + + +app = FastAPI(title="AliceBot API", version="0.1.0") +HealthStatus = Literal["ok", "degraded"] +ServiceStatus = Literal["ok", "unreachable", "not_checked"] + + +class DatabaseServicePayload(TypedDict): + status: Literal["ok", "unreachable"] + + +class RedisServicePayload(TypedDict): + status: Literal["not_checked"] + url: str + + +class ObjectStorageServicePayload(TypedDict): + status: Literal["not_checked"] + endpoint_url: str + + +class HealthServicesPayload(TypedDict): + database: DatabaseServicePayload + redis: RedisServicePayload + object_storage: ObjectStorageServicePayload + + +class HealthcheckPayload(TypedDict): + status: HealthStatus + environment: str + services: HealthServicesPayload + + +AUTH_USER_HEADER = "X-AliceBot-User-Id" + + +class ResponseRateLimiter: + def __init__(self) -> None: + self._events_by_key: dict[str, deque[float]] = defaultdict(deque) + self._lock = threading.Lock() + + def allow(self, *, key: str, max_requests: int, window_seconds: int) -> tuple[bool, int]: + now = time.monotonic() + + with self._lock: + events = self._events_by_key[key] + cutoff = now - window_seconds + while events and events[0] <= cutoff: + events.popleft() + + if len(events) >= max_requests: + retry_after_seconds = max(1, int(events[0] + window_seconds - now)) + return False, retry_after_seconds + + events.append(now) + return True, 0 + + def reset(self) -> None: + with self._lock: + self._events_by_key.clear() + + +response_rate_limiter = ResponseRateLimiter() + + +class EntrypointRateLimiterUnavailableError(RuntimeError): + """Raised when the configured entrypoint rate limiter backend is unavailable.""" + + +class EntrypointRateLimiter: + def __init__(self) -> None: + self._memory_fallback = ResponseRateLimiter() + self._redis_clients_by_url: dict[str, object] = {} + self._lock = threading.Lock() + + def _get_redis_client(self, redis_url: str): + with self._lock: + cached_client = self._redis_clients_by_url.get(redis_url) + if cached_client is not None: + return cached_client + + if redis is None: + raise EntrypointRateLimiterUnavailableError( + "redis backend is unavailable; install redis client dependency" + ) + + redis_client = redis.Redis.from_url( + redis_url, + decode_responses=True, + socket_connect_timeout=1, + socket_timeout=1, + ) + self._redis_clients_by_url[redis_url] = redis_client + return redis_client + + def allow( + self, + *, + settings: Settings, + key: str, + max_requests: int, + window_seconds: int, + ) -> tuple[bool, int]: + if settings.entrypoint_rate_limit_backend == "memory": + return self._memory_fallback.allow( + key=key, + max_requests=max_requests, + window_seconds=window_seconds, + ) + + try: + redis_client = self._get_redis_client(settings.redis_url) + redis_key = f"entrypoint_rate:{key}" + count = int(redis_client.incr(redis_key)) + ttl = int(redis_client.ttl(redis_key)) + + if count == 1 or ttl <= 0: + redis_client.expire(redis_key, window_seconds) + ttl = window_seconds + + if count > max_requests: + return False, max(1, ttl if ttl > 0 else window_seconds) + return True, 0 + except (RedisError, EntrypointRateLimiterUnavailableError) as exc: + # Local and test workflows can continue deterministically with in-memory fallback. + if settings.app_env in {"development", "test"}: + return self._memory_fallback.allow( + key=key, + max_requests=max_requests, + window_seconds=window_seconds, + ) + raise EntrypointRateLimiterUnavailableError( + "redis-backed entrypoint rate limiter is unavailable" + ) from exc + + def reset(self) -> None: + self._memory_fallback.reset() + with self._lock: + self._redis_clients_by_url.clear() + + +entrypoint_rate_limiter = EntrypointRateLimiter() + + +def _resolve_authenticated_user_id(settings: Settings, request: Request) -> UUID | None: + if settings.auth_user_id != "": + return UUID(settings.auth_user_id) + + header_value = request.headers.get(AUTH_USER_HEADER) + if header_value is None or header_value.strip() == "": + if settings.app_env in {"development", "test"}: + return None + raise ValueError( + "request authentication is not configured; set ALICEBOT_AUTH_USER_ID " + "or provide X-AliceBot-User-Id" + ) + + try: + return UUID(header_value) + except ValueError as exc: + raise ValueError("X-AliceBot-User-Id must be a valid UUID") from exc + + +def _rewrite_user_id_query_param(request: Request, authenticated_user_id: UUID) -> None: + raw_query = request.scope.get("query_string", b"") + query_items = parse_qsl(raw_query.decode("utf-8"), keep_blank_values=True) + expected_user_id = str(authenticated_user_id) + for key, value in query_items: + if key == "user_id" and value != expected_user_id: + raise ValueError("query user_id does not match authenticated user") + rewritten_items = [(key, value) for key, value in query_items if key != "user_id"] + rewritten_items.append(("user_id", expected_user_id)) + request.scope["query_string"] = urlencode(rewritten_items, doseq=True).encode("utf-8") + + +async def _rewrite_user_id_json_body(request: Request, authenticated_user_id: UUID) -> Request: + if request.method.upper() not in {"POST", "PUT", "PATCH", "DELETE"}: + return request + + content_type = request.headers.get("content-type", "").lower() + if "application/json" not in content_type: + return request + + raw_body = await request.body() + if raw_body == b"": + return request + + try: + parsed_body = json.loads(raw_body) + except json.JSONDecodeError: + return request + + if not isinstance(parsed_body, dict): + return request + + expected_user_id = str(authenticated_user_id) + existing_user_id = parsed_body.get("user_id") + if existing_user_id is not None and str(existing_user_id) != expected_user_id: + raise ValueError("request user_id does not match authenticated user") + parsed_body["user_id"] = expected_user_id + rewritten_body = json.dumps(parsed_body, separators=(",", ":"), ensure_ascii=True).encode("utf-8") + + async def receive() -> dict[str, object]: + return { + "type": "http.request", + "body": rewritten_body, + "more_body": False, + } + + return Request(request.scope, receive) + + +class CompileContextSemanticRequest(BaseModel): + embedding_config_id: UUID + query_vector: list[float] = Field(min_length=1, max_length=20000) + limit: int = Field( + default=DEFAULT_SEMANTIC_MEMORY_RETRIEVAL_LIMIT, + ge=1, + le=MAX_SEMANTIC_MEMORY_RETRIEVAL_LIMIT, + ) + + +class CompileContextTaskScopedArtifactRetrievalRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + kind: Literal["task"] + task_id: UUID + query: str = Field(min_length=1, max_length=4000) + limit: int = Field( + default=DEFAULT_ARTIFACT_CHUNK_RETRIEVAL_LIMIT, + ge=1, + le=MAX_ARTIFACT_CHUNK_RETRIEVAL_LIMIT, + ) + + +class CompileContextArtifactScopedArtifactRetrievalRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + kind: Literal["artifact"] + task_artifact_id: UUID + query: str = Field(min_length=1, max_length=4000) + limit: int = Field( + default=DEFAULT_ARTIFACT_CHUNK_RETRIEVAL_LIMIT, + ge=1, + le=MAX_ARTIFACT_CHUNK_RETRIEVAL_LIMIT, + ) + + +CompileContextArtifactRetrievalRequest = Annotated[ + CompileContextTaskScopedArtifactRetrievalRequest + | CompileContextArtifactScopedArtifactRetrievalRequest, + Field(discriminator="kind"), +] + + +class CompileContextTaskScopedSemanticArtifactRetrievalRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + kind: Literal["task"] + task_id: UUID + embedding_config_id: UUID + query_vector: list[float] = Field(min_length=1, max_length=20000) + limit: int = Field( + default=DEFAULT_ARTIFACT_CHUNK_RETRIEVAL_LIMIT, + ge=1, + le=MAX_ARTIFACT_CHUNK_RETRIEVAL_LIMIT, + ) + + +class CompileContextArtifactScopedSemanticArtifactRetrievalRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + kind: Literal["artifact"] + task_artifact_id: UUID + embedding_config_id: UUID + query_vector: list[float] = Field(min_length=1, max_length=20000) + limit: int = Field( + default=DEFAULT_ARTIFACT_CHUNK_RETRIEVAL_LIMIT, + ge=1, + le=MAX_ARTIFACT_CHUNK_RETRIEVAL_LIMIT, + ) + + +CompileContextSemanticArtifactRetrievalRequest = Annotated[ + CompileContextTaskScopedSemanticArtifactRetrievalRequest + | CompileContextArtifactScopedSemanticArtifactRetrievalRequest, + Field(discriminator="kind"), +] + + +class CompileContextRequest(BaseModel): + user_id: UUID + thread_id: UUID + max_sessions: int = Field(default=DEFAULT_MAX_SESSIONS, ge=0, le=25) + max_events: int = Field(default=DEFAULT_MAX_EVENTS, ge=0, le=200) + max_memories: int = Field(default=DEFAULT_MAX_MEMORIES, ge=0, le=50) + max_entities: int = Field(default=DEFAULT_MAX_ENTITIES, ge=0, le=50) + max_entity_edges: int = Field(default=DEFAULT_MAX_ENTITY_EDGES, ge=0, le=100) + semantic: CompileContextSemanticRequest | None = None + artifact_retrieval: CompileContextArtifactRetrievalRequest | None = None + semantic_artifact_retrieval: CompileContextSemanticArtifactRetrievalRequest | None = None + + +class GenerateResponseRequest(BaseModel): + user_id: UUID + thread_id: UUID + message: str = Field(min_length=1, max_length=8000) + max_sessions: int = Field(default=DEFAULT_MAX_SESSIONS, ge=0, le=25) + max_events: int = Field(default=DEFAULT_MAX_EVENTS, ge=0, le=200) + max_memories: int = Field(default=DEFAULT_MAX_MEMORIES, ge=0, le=50) + max_entities: int = Field(default=DEFAULT_MAX_ENTITIES, ge=0, le=50) + max_entity_edges: int = Field(default=DEFAULT_MAX_ENTITY_EDGES, ge=0, le=100) + + +class CreateThreadRequest(BaseModel): + user_id: UUID + title: str = Field(min_length=1, max_length=200) + agent_profile_id: str | None = Field(default=None, min_length=1, max_length=100) + + +class AdmitMemoryOpenLoopRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + title: str = Field(min_length=1, max_length=280) + due_at: datetime | None = None + + +class AdmitMemoryRequest(BaseModel): + user_id: UUID + memory_key: str = Field(min_length=1, max_length=200) + value: object | None = None + source_event_ids: list[UUID] = Field(min_length=1) + agent_profile_id: str | None = Field(default=None, min_length=1, max_length=100) + delete_requested: bool = False + memory_type: str | None = Field(default=None, min_length=1, max_length=100) + confidence: float | None = Field(default=None, ge=0.0, le=1.0) + salience: float | None = Field(default=None, ge=0.0, le=1.0) + confirmation_status: str | None = Field(default=None, min_length=1, max_length=100) + trust_class: str | None = Field(default=None, min_length=1, max_length=100) + promotion_eligibility: str | None = Field(default=None, min_length=1, max_length=100) + evidence_count: int | None = Field(default=None, ge=0) + independent_source_count: int | None = Field(default=None, ge=0) + extracted_by_model: str | None = Field(default=None, min_length=1, max_length=200) + trust_reason: str | None = Field(default=None, min_length=1, max_length=500) + valid_from: datetime | None = None + valid_to: datetime | None = None + last_confirmed_at: datetime | None = None + open_loop: AdmitMemoryOpenLoopRequest | None = None + + @model_validator(mode="after") + def validate_temporal_range(self) -> "AdmitMemoryRequest": + if self.valid_from is not None and self.valid_to is not None and self.valid_to < self.valid_from: + raise ValueError("valid_to must be greater than or equal to valid_from") + return self + + +class ExtractExplicitPreferencesRequest(BaseModel): + user_id: UUID + source_event_id: UUID + + +class ExtractExplicitCommitmentsRequest(BaseModel): + user_id: UUID + source_event_id: UUID + + +class CaptureExplicitSignalsRequest(BaseModel): + user_id: UUID + source_event_id: UUID + + +class ContinuityCaptureRequest(BaseModel): + user_id: UUID + raw_content: str = Field(min_length=1, max_length=4000) + explicit_signal: str | None = Field(default=None, min_length=1, max_length=100) + + +class ContinuityCorrectionRequest(BaseModel): + user_id: UUID + action: str = Field(min_length=1, max_length=40) + reason: str | None = Field(default=None, min_length=1, max_length=500) + title: str | None = Field(default=None, min_length=1, max_length=280) + body: dict[str, object] | None = None + provenance: dict[str, object] | None = None + confidence: float | None = Field(default=None, ge=0.0, le=1.0) + replacement_title: str | None = Field(default=None, min_length=1, max_length=280) + replacement_body: dict[str, object] | None = None + replacement_provenance: dict[str, object] | None = None + replacement_confidence: float | None = Field(default=None, ge=0.0, le=1.0) + + +class ContinuityOpenLoopReviewActionRequest(BaseModel): + user_id: UUID + action: str = Field(min_length=1, max_length=40) + note: str | None = Field(default=None, min_length=1, max_length=500) + + +class ChiefOfStaffRecommendationOutcomeCaptureRequest(BaseModel): + user_id: UUID + outcome: str = Field(min_length=1, max_length=40) + recommendation_action_type: str = Field(min_length=1, max_length=60) + recommendation_title: str = Field(min_length=1, max_length=280) + rationale: str | None = Field(default=None, min_length=1, max_length=500) + rewritten_title: str | None = Field(default=None, min_length=1, max_length=280) + target_priority_id: UUID | None = None + thread_id: UUID | None = None + task_id: UUID | None = None + project: str | None = Field(default=None, min_length=1, max_length=200) + person: str | None = Field(default=None, min_length=1, max_length=200) + + +class ChiefOfStaffHandoffReviewActionCaptureRequest(BaseModel): + user_id: UUID + handoff_item_id: str = Field(min_length=1, max_length=200) + review_action: str = Field(min_length=1, max_length=60) + note: str | None = Field(default=None, min_length=1, max_length=500) + thread_id: UUID | None = None + task_id: UUID | None = None + project: str | None = Field(default=None, min_length=1, max_length=200) + person: str | None = Field(default=None, min_length=1, max_length=200) + + +class ChiefOfStaffExecutionRoutingActionCaptureRequest(BaseModel): + user_id: UUID + handoff_item_id: str = Field(min_length=1, max_length=200) + route_target: str = Field(min_length=1, max_length=80) + note: str | None = Field(default=None, min_length=1, max_length=500) + thread_id: UUID | None = None + task_id: UUID | None = None + project: str | None = Field(default=None, min_length=1, max_length=200) + person: str | None = Field(default=None, min_length=1, max_length=200) + + +class ChiefOfStaffHandoffOutcomeCaptureRequest(BaseModel): + user_id: UUID + handoff_item_id: str = Field(min_length=1, max_length=200) + outcome_status: str = Field(min_length=1, max_length=60) + note: str | None = Field(default=None, min_length=1, max_length=500) + thread_id: UUID | None = None + task_id: UUID | None = None + project: str | None = Field(default=None, min_length=1, max_length=200) + person: str | None = Field(default=None, min_length=1, max_length=200) + + +class CreateMemoryReviewLabelRequest(BaseModel): + user_id: UUID + label: MemoryReviewLabelValue + note: str | None = Field(default=None, min_length=1, max_length=280) + + +class CreateOpenLoopRequest(BaseModel): + user_id: UUID + memory_id: UUID | None = None + title: str = Field(min_length=1, max_length=280) + due_at: datetime | None = None + + +class UpdateOpenLoopStatusRequest(BaseModel): + user_id: UUID + status: str = Field(min_length=1, max_length=100) + resolution_note: str | None = Field(default=None, min_length=1, max_length=2000) + + +class CreateEntityRequest(BaseModel): + user_id: UUID + entity_type: EntityType + name: str = Field(min_length=1, max_length=200) + source_memory_ids: list[UUID] = Field(min_length=1) + + +class CreateEntityEdgeRequest(BaseModel): + user_id: UUID + from_entity_id: UUID + to_entity_id: UUID + relationship_type: str = Field(min_length=1, max_length=100) + valid_from: datetime | None = None + valid_to: datetime | None = None + source_memory_ids: list[UUID] = Field(min_length=1) + + +class CreateEmbeddingConfigRequest(BaseModel): + user_id: UUID + provider: str = Field(min_length=1, max_length=100) + model: str = Field(min_length=1, max_length=200) + version: str = Field(min_length=1, max_length=100) + dimensions: int = Field(ge=1, le=20000) + status: EmbeddingConfigStatus = "active" + metadata: dict[str, object] = Field(default_factory=dict) + + +class UpsertMemoryEmbeddingRequest(BaseModel): + user_id: UUID + memory_id: UUID + embedding_config_id: UUID + vector: list[float] = Field(min_length=1, max_length=20000) + + +class UpsertTaskArtifactChunkEmbeddingRequest(BaseModel): + user_id: UUID + task_artifact_chunk_id: UUID + embedding_config_id: UUID + vector: list[float] = Field(min_length=1, max_length=20000) + + +class RetrieveSemanticMemoriesRequest(BaseModel): + user_id: UUID + embedding_config_id: UUID + query_vector: list[float] = Field(min_length=1, max_length=20000) + limit: int = Field( + default=DEFAULT_SEMANTIC_MEMORY_RETRIEVAL_LIMIT, + ge=1, + le=MAX_SEMANTIC_MEMORY_RETRIEVAL_LIMIT, + ) + + +class RetrieveSemanticArtifactChunksRequest(BaseModel): + user_id: UUID + embedding_config_id: UUID + query_vector: list[float] = Field(min_length=1, max_length=20000) + limit: int = Field( + default=DEFAULT_ARTIFACT_CHUNK_RETRIEVAL_LIMIT, + ge=1, + le=MAX_ARTIFACT_CHUNK_RETRIEVAL_LIMIT, + ) + + +class UpsertConsentRequest(BaseModel): + user_id: UUID + consent_key: str = Field(min_length=1, max_length=200) + status: ConsentStatus + metadata: dict[str, object] = Field(default_factory=dict) + + +class CreatePolicyRequest(BaseModel): + user_id: UUID + name: str = Field(min_length=1, max_length=200) + action: str = Field(min_length=1, max_length=100) + scope: str = Field(min_length=1, max_length=200) + effect: PolicyEffect + priority: int = Field(ge=0, le=1000000) + active: bool = True + conditions: dict[str, object] = Field(default_factory=dict) + required_consents: list[str] = Field(default_factory=list) + agent_profile_id: str | None = Field(default=None, min_length=1, max_length=100) + + +class EvaluatePolicyRequest(BaseModel): + user_id: UUID + thread_id: UUID + action: str = Field(min_length=1, max_length=100) + scope: str = Field(min_length=1, max_length=200) + attributes: dict[str, object] = Field(default_factory=dict) + + +class CreateToolRequest(BaseModel): + user_id: UUID + tool_key: str = Field(min_length=1, max_length=200) + name: str = Field(min_length=1, max_length=200) + description: str = Field(min_length=1, max_length=500) + version: str = Field(min_length=1, max_length=100) + metadata_version: str = Field(default=TOOL_METADATA_VERSION_V0, pattern=f"^{TOOL_METADATA_VERSION_V0}$") + active: bool = True + tags: list[str] = Field(default_factory=list) + action_hints: list[str] = Field(default_factory=list, min_length=1) + scope_hints: list[str] = Field(default_factory=list, min_length=1) + domain_hints: list[str] = Field(default_factory=list) + risk_hints: list[str] = Field(default_factory=list) + metadata: dict[str, object] = Field(default_factory=dict) + + +class EvaluateToolAllowlistRequest(BaseModel): + user_id: UUID + thread_id: UUID + action: str = Field(min_length=1, max_length=100) + scope: str = Field(min_length=1, max_length=200) + domain_hint: str | None = Field(default=None, min_length=1, max_length=200) + risk_hint: str | None = Field(default=None, min_length=1, max_length=100) + attributes: dict[str, object] = Field(default_factory=dict) + + +class RouteToolRequest(BaseModel): + user_id: UUID + thread_id: UUID + tool_id: UUID + action: str = Field(min_length=1, max_length=100) + scope: str = Field(min_length=1, max_length=200) + domain_hint: str | None = Field(default=None, min_length=1, max_length=200) + risk_hint: str | None = Field(default=None, min_length=1, max_length=100) + attributes: dict[str, object] = Field(default_factory=dict) + + +class CreateApprovalRequest(BaseModel): + user_id: UUID + thread_id: UUID + tool_id: UUID + task_run_id: UUID | None = None + action: str = Field(min_length=1, max_length=100) + scope: str = Field(min_length=1, max_length=200) + domain_hint: str | None = Field(default=None, min_length=1, max_length=200) + risk_hint: str | None = Field(default=None, min_length=1, max_length=100) + attributes: dict[str, object] = Field(default_factory=dict) + + +class ResolveApprovalRequest(BaseModel): + user_id: UUID + + +class ExecuteApprovedProxyRequest(BaseModel): + user_id: UUID + task_run_id: UUID | None = None + + +class ConnectGmailAccountRequest(BaseModel): + user_id: UUID + provider_account_id: str = Field(min_length=1, max_length=320) + email_address: str = Field(min_length=1, max_length=320) + display_name: str | None = Field(default=None, min_length=1, max_length=200) + scope: Literal["https://www.googleapis.com/auth/gmail.readonly"] = GMAIL_READONLY_SCOPE + access_token: str = Field(min_length=1, max_length=8000) + refresh_token: str | None = Field(default=None, min_length=1, max_length=8000) + client_id: str | None = Field(default=None, min_length=1, max_length=2000) + client_secret: str | None = Field(default=None, min_length=1, max_length=8000) + access_token_expires_at: datetime | None = None + + @model_validator(mode="after") + def validate_refresh_bundle(self) -> ConnectGmailAccountRequest: + refresh_bundle = ( + self.refresh_token, + self.client_id, + self.client_secret, + self.access_token_expires_at, + ) + if all(value is None for value in refresh_bundle): + return self + if any(value is None for value in refresh_bundle): + raise ValueError( + "gmail refresh credentials must include refresh_token, client_id, " + "client_secret, and access_token_expires_at" + ) + return self + + +class IngestGmailMessageRequest(BaseModel): + user_id: UUID + task_workspace_id: UUID + + +class ConnectCalendarAccountRequest(BaseModel): + user_id: UUID + provider_account_id: str = Field(min_length=1, max_length=320) + email_address: str = Field(min_length=1, max_length=320) + display_name: str | None = Field(default=None, min_length=1, max_length=200) + scope: Literal["https://www.googleapis.com/auth/calendar.readonly"] = CALENDAR_READONLY_SCOPE + access_token: str = Field(min_length=1, max_length=8000) + + +class IngestCalendarEventRequest(BaseModel): + user_id: UUID + task_workspace_id: UUID + + +class CreateTaskWorkspaceRequest(BaseModel): + user_id: UUID + + +class RegisterTaskArtifactRequest(BaseModel): + user_id: UUID + local_path: str = Field(min_length=1, max_length=4000) + media_type_hint: str | None = Field(default=None, min_length=1, max_length=200) + + +class IngestTaskArtifactRequest(BaseModel): + user_id: UUID + + +class RetrieveArtifactChunksRequest(BaseModel): + user_id: UUID + query: str = Field(min_length=1, max_length=1000) + + +class TaskStepRequestSnapshot(BaseModel): + thread_id: UUID + tool_id: UUID + action: str = Field(min_length=1, max_length=100) + scope: str = Field(min_length=1, max_length=200) + domain_hint: str | None = Field(default=None, min_length=1, max_length=200) + risk_hint: str | None = Field(default=None, min_length=1, max_length=100) + attributes: dict[str, object] = Field(default_factory=dict) + + +class TaskStepOutcomeRequest(BaseModel): + routing_decision: ToolRoutingDecision + approval_id: UUID | None = None + approval_status: ApprovalStatus | None = None + execution_id: UUID | None = None + execution_status: ProxyExecutionStatus | None = None + blocked_reason: str | None = Field(default=None, min_length=1, max_length=500) + + +class TaskStepLineageRequest(BaseModel): + parent_step_id: UUID + source_approval_id: UUID | None = None + source_execution_id: UUID | None = None + + +class CreateNextTaskStepRequest(BaseModel): + user_id: UUID + kind: TaskStepKind = "governed_request" + status: TaskStepStatus + request: TaskStepRequestSnapshot + outcome: TaskStepOutcomeRequest + lineage: TaskStepLineageRequest + + +class TransitionTaskStepRequest(BaseModel): + user_id: UUID + status: TaskStepStatus + outcome: TaskStepOutcomeRequest + + +class CreateTaskRunRequest(BaseModel): + user_id: UUID + max_ticks: int = Field(default=1, ge=1, le=1_000_000) + retry_cap: int | None = Field(default=None, ge=1, le=1_000_000) + checkpoint: dict[str, object] = Field(default_factory=dict) + + +class MutateTaskRunRequest(BaseModel): + user_id: UUID + + +class CreateExecutionBudgetRequest(BaseModel): + user_id: UUID + agent_profile_id: str | None = Field(default=None, min_length=1, max_length=100) + tool_key: str | None = Field(default=None, min_length=1, max_length=200) + domain_hint: str | None = Field(default=None, min_length=1, max_length=200) + max_completed_executions: int = Field(ge=1, le=1000000) + rolling_window_seconds: int | None = Field(default=None, ge=1) + + +class DeactivateExecutionBudgetRequest(BaseModel): + user_id: UUID + thread_id: UUID + + +class SupersedeExecutionBudgetRequest(BaseModel): + user_id: UUID + thread_id: UUID + max_completed_executions: int = Field(ge=1, le=1000000) + + +def _serialize_thread(thread: ThreadRow) -> ThreadRecord: + agent_profile_id = _thread_agent_profile_id(thread) + return { + "id": str(thread["id"]), + "title": thread["title"], + "agent_profile_id": agent_profile_id, + "created_at": thread["created_at"].isoformat(), + "updated_at": thread["updated_at"].isoformat(), + } + + +def _thread_agent_profile_id(thread: ThreadRow) -> str: + return str(thread.get("agent_profile_id", DEFAULT_AGENT_PROFILE_ID)) + + +def _serialize_thread_session(session: SessionRow) -> ThreadSessionRecord: + return { + "id": str(session["id"]), + "thread_id": str(session["thread_id"]), + "status": session["status"], + "started_at": None if session["started_at"] is None else session["started_at"].isoformat(), + "ended_at": None if session["ended_at"] is None else session["ended_at"].isoformat(), + "created_at": session["created_at"].isoformat(), + } + + +def _serialize_thread_event(event: EventRow) -> ThreadEventRecord: + return { + "id": str(event["id"]), + "thread_id": str(event["thread_id"]), + "session_id": None if event["session_id"] is None else str(event["session_id"]), + "sequence_no": event["sequence_no"], + "kind": event["kind"], + "payload": event["payload"], + "created_at": event["created_at"].isoformat(), + } + + +def redact_url_credentials(raw_url: str) -> str: + parsed = urlsplit(raw_url) + + if parsed.hostname is None or (parsed.username is None and parsed.password is None): + return raw_url + + hostname = parsed.hostname + if ":" in hostname and not hostname.startswith("["): + hostname = f"[{hostname}]" + + netloc = hostname + if parsed.port is not None: + netloc = f"{hostname}:{parsed.port}" + + return urlunsplit((parsed.scheme, netloc, parsed.path, parsed.query, parsed.fragment)) + + +def build_healthcheck_payload(settings: Settings, database_ok: bool) -> HealthcheckPayload: + status: HealthStatus = "ok" if database_ok else "degraded" + database_status: Literal["ok", "unreachable"] = "ok" if database_ok else "unreachable" + + return { + "status": status, + "environment": settings.app_env, + "services": { + "database": { + "status": database_status, + }, + "redis": { + "status": "not_checked", + "url": redact_url_credentials(settings.redis_url), + }, + "object_storage": { + "status": "not_checked", + "endpoint_url": settings.s3_endpoint_url, + }, + }, + } + + +def _response_rate_limit_error( + *, + max_requests: int, + window_seconds: int, + retry_after_seconds: int, +) -> JSONResponse: + return JSONResponse( + status_code=429, + headers={"Retry-After": str(retry_after_seconds)}, + content={ + "detail": { + "code": "response_rate_limit_exceeded", + "message": ( + "response generation rate limit exceeded; " + f"max {max_requests} requests per {window_seconds} seconds" + ), + "retry_after_seconds": retry_after_seconds, + } + }, + ) + + +def _enforce_response_rate_limit(settings: Settings, user_id: UUID) -> JSONResponse | None: + allowed, retry_after_seconds = response_rate_limiter.allow( + key=f"responses:{user_id}", + max_requests=settings.response_rate_limit_max_requests, + window_seconds=settings.response_rate_limit_window_seconds, + ) + if allowed: + return None + return _response_rate_limit_error( + max_requests=settings.response_rate_limit_max_requests, + window_seconds=settings.response_rate_limit_window_seconds, + retry_after_seconds=retry_after_seconds, + ) + + +def _request_client_identifier(request: Request, settings: Settings) -> str: + peer_host = "" + if request.client is not None: + peer_host = (request.client.host or "").strip() + + if ( + settings.trust_proxy_headers + and peer_host != "" + and peer_host in settings.trusted_proxy_ips + ): + forwarded_for = request.headers.get("x-forwarded-for", "").strip() + if forwarded_for != "": + first_hop = forwarded_for.split(",", maxsplit=1)[0].strip() + if first_hop != "": + return first_hop + + if peer_host == "": + return "unknown" + return peer_host + + +def _entrypoint_rate_limit_error( + *, + detail_code: str, + message: str, + max_requests: int, + window_seconds: int, + retry_after_seconds: int, +) -> JSONResponse: + return JSONResponse( + status_code=429, + headers={"Retry-After": str(retry_after_seconds)}, + content={ + "detail": { + "code": detail_code, + "message": message, + "retry_after_seconds": retry_after_seconds, + "window_seconds": window_seconds, + "max_requests": max_requests, + } + }, + ) + + +def _enforce_entrypoint_rate_limit( + *, + settings: Settings, + key: str, + max_requests: int, + window_seconds: int, + detail_code: str, + message: str, +) -> JSONResponse | None: + try: + allowed, retry_after_seconds = entrypoint_rate_limiter.allow( + settings=settings, + key=key, + max_requests=max_requests, + window_seconds=window_seconds, + ) + except EntrypointRateLimiterUnavailableError: + return JSONResponse( + status_code=503, + content={ + "detail": { + "code": "entrypoint_rate_limiter_unavailable", + "message": "entrypoint rate limiter backend is unavailable", + } + }, + ) + if allowed: + return None + return _entrypoint_rate_limit_error( + detail_code=detail_code, + message=message, + max_requests=max_requests, + window_seconds=window_seconds, + retry_after_seconds=retry_after_seconds, + ) + + +def _append_vary_header(response: Response, value: str) -> None: + existing = response.headers.get("Vary", "") + values = [item.strip() for item in existing.split(",") if item.strip() != ""] + if value not in values: + values.append(value) + response.headers["Vary"] = ", ".join(values) + + +def _cors_origin_allowed(origin: str, allowed_origins: tuple[str, ...]) -> bool: + if len(allowed_origins) == 0: + return False + if "*" in allowed_origins: + return True + return origin in allowed_origins + + +def _resolve_cors_allow_origin_value(settings: Settings, origin: str) -> str: + if "*" in settings.cors_allowed_origins and not settings.cors_allow_credentials: + return "*" + return origin + + +def _apply_cors_headers( + *, + response: Response, + settings: Settings, + origin: str, + preflight: bool, +) -> None: + allow_origin = _resolve_cors_allow_origin_value(settings, origin) + response.headers["Access-Control-Allow-Origin"] = allow_origin + if allow_origin != "*": + _append_vary_header(response, "Origin") + if settings.cors_allow_credentials: + response.headers["Access-Control-Allow-Credentials"] = "true" + + if not preflight: + return + + response.headers["Access-Control-Allow-Methods"] = ", ".join(settings.cors_allowed_methods) + response.headers["Access-Control-Allow-Headers"] = ", ".join(settings.cors_allowed_headers) + response.headers["Access-Control-Max-Age"] = str(settings.cors_preflight_max_age_seconds) + _append_vary_header(response, "Access-Control-Request-Method") + _append_vary_header(response, "Access-Control-Request-Headers") + + +def _apply_security_headers(*, response: Response, settings: Settings, request: Request) -> None: + if not settings.security_headers_enabled: + return + + response.headers.setdefault("X-Content-Type-Options", "nosniff") + response.headers.setdefault("X-Frame-Options", "DENY") + response.headers.setdefault("Referrer-Policy", "no-referrer") + response.headers.setdefault( + "Permissions-Policy", + ( + "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), " + "microphone=(), payment=(), usb=()" + ), + ) + + if request.url.scheme != "https" or settings.app_env in {"development", "test"}: + return + + hsts_value = f"max-age={settings.security_headers_hsts_max_age_seconds}" + if settings.security_headers_hsts_include_subdomains: + hsts_value += "; includeSubDomains" + response.headers.setdefault("Strict-Transport-Security", hsts_value) + + +@app.middleware("http") +async def apply_http_security_posture( + request: Request, + call_next: Callable[[Request], Awaitable[Response]], +) -> Response: + settings = get_settings() + origin = request.headers.get("origin", "").strip() + is_preflight = ( + request.method.upper() == "OPTIONS" + and request.headers.get("access-control-request-method", "").strip() != "" + ) + + if is_preflight: + if origin == "" or not _cors_origin_allowed(origin, settings.cors_allowed_origins): + response = JSONResponse(status_code=403, content={"detail": "CORS origin is not allowed"}) + _apply_security_headers(response=response, settings=settings, request=request) + return response + response = Response(status_code=204) + _apply_cors_headers(response=response, settings=settings, origin=origin, preflight=True) + _apply_security_headers(response=response, settings=settings, request=request) + return response + + response = await call_next(request) + if origin != "" and _cors_origin_allowed(origin, settings.cors_allowed_origins): + _apply_cors_headers(response=response, settings=settings, origin=origin, preflight=False) + _apply_security_headers(response=response, settings=settings, request=request) + return response + + +@app.middleware("http") +async def enforce_authenticated_user_identity( + request: Request, + call_next: Callable[[Request], Awaitable[Response]], +) -> Response: + if not request.url.path.startswith("/v0/"): + return await call_next(request) + + settings = get_settings() + + try: + authenticated_user_id = _resolve_authenticated_user_id(settings, request) + if authenticated_user_id is not None: + request.scope.setdefault("state", {})["authenticated_user_id"] = str(authenticated_user_id) + _rewrite_user_id_query_param(request, authenticated_user_id) + request = await _rewrite_user_id_json_body(request, authenticated_user_id) + except ValueError as exc: + return JSONResponse(status_code=401, content={"detail": str(exc)}) + + return await call_next(request) + + +class MagicLinkStartRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + email: str = Field(min_length=3, max_length=320) + + +class MagicLinkVerifyRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + challenge_token: str = Field(min_length=16, max_length=256) + device_label: str = Field(default="Primary device", min_length=1, max_length=120) + device_key: str | None = Field(default=None, min_length=1, max_length=160) + + +class HostedWorkspaceCreateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str = Field(min_length=1, max_length=160) + slug: str | None = Field(default=None, min_length=1, max_length=120) + + +class HostedWorkspaceBootstrapRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + workspace_id: UUID | None = None + + +class DeviceLinkStartRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + device_key: str = Field(min_length=1, max_length=160) + device_label: str = Field(min_length=1, max_length=120) + workspace_id: UUID | None = None + + +class DeviceLinkConfirmRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + challenge_token: str = Field(min_length=16, max_length=256) + + +class HostedPreferencesPatchRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + timezone: str | None = Field(default=None, min_length=1, max_length=120) + brief_preferences: dict[str, object] | None = None + quiet_hours: dict[str, object] | None = None + + +class TelegramLinkStartRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + workspace_id: UUID | None = None + + +class TelegramLinkConfirmRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + challenge_token: str = Field(min_length=16, max_length=256) + + +class TelegramUnlinkRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + workspace_id: UUID | None = None + + +class TelegramDispatchRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + text: str = Field(min_length=1, max_length=4096) + idempotency_key: str | None = Field(default=None, min_length=16, max_length=160) + + +class TelegramMessageHandleRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + intent_hint: str | None = Field(default=None, min_length=1, max_length=40) + + +class TelegramOpenLoopReviewActionBody(BaseModel): + model_config = ConfigDict(extra="forbid") + + action: str = Field(min_length=1, max_length=40) + note: str | None = Field(default=None, min_length=1, max_length=500) + + +class TelegramApprovalResolveBody(BaseModel): + model_config = ConfigDict(extra="forbid") + + note: str | None = Field(default=None, min_length=1, max_length=500) + + +class TelegramNotificationPreferencesPatchRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + notifications_enabled: bool | None = None + daily_brief_enabled: bool | None = None + daily_brief_window_start: str | None = Field(default=None, min_length=5, max_length=5) + open_loop_prompts_enabled: bool | None = None + waiting_for_prompts_enabled: bool | None = None + stale_prompts_enabled: bool | None = None + timezone: str | None = Field(default=None, min_length=1, max_length=120) + quiet_hours_enabled: bool | None = None + quiet_hours_start: str | None = Field(default=None, min_length=5, max_length=5) + quiet_hours_end: str | None = Field(default=None, min_length=5, max_length=5) + + +class TelegramScheduledDeliveryRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + force: bool = False + idempotency_key: str | None = Field(default=None, min_length=8, max_length=200) + + +class HostedRolloutFlagPatchItemRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + flag_key: str = Field(min_length=1, max_length=120) + enabled: bool + cohort_key: str | None = Field(default=None, min_length=1, max_length=120) + description: str | None = Field(default=None, min_length=1, max_length=500) + + +class HostedRolloutFlagsPatchRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + updates: list[HostedRolloutFlagPatchItemRequest] = Field(min_length=1, max_length=100) + + +def _extract_bearer_token(request: Request) -> str: + raw_authorization = request.headers.get("authorization", "").strip() + if raw_authorization == "": + raise AuthSessionInvalidError("authorization bearer token is required") + + scheme, _, token = raw_authorization.partition(" ") + if scheme.lower() != "bearer" or token.strip() == "": + raise AuthSessionInvalidError("authorization header must use Bearer token format") + return token.strip() + + +def _serialize_hosted_session_payload( + *, + session: dict[str, object], + user_account: dict[str, object], + workspace: dict[str, object] | None, + preferences: dict[str, object], + feature_flags: list[str], +) -> dict[str, object]: + return { + "session": session, + "user_account": user_account, + "workspace": workspace, + "preferences": preferences, + "feature_flags": feature_flags, + "telegram_state": "available_in_p10_s2_transport", + } + + +def _resolve_workspace_for_hosted_channel_request( + conn, + *, + user_account_id: UUID, + session_id: UUID, + preferred_workspace_id: UUID | None, + requested_workspace_id: UUID | None, +): + if requested_workspace_id is not None: + workspace = get_workspace_for_member( + conn, + workspace_id=requested_workspace_id, + user_account_id=user_account_id, + ) + if workspace is None: + raise HostedWorkspaceNotFoundError(f"workspace {requested_workspace_id} was not found") + if preferred_workspace_id != workspace["id"]: + set_session_workspace( + conn, + session_id=session_id, + user_account_id=user_account_id, + workspace_id=workspace["id"], + ) + return workspace + + workspace = get_current_workspace( + conn, + user_account_id=user_account_id, + preferred_workspace_id=preferred_workspace_id, + ) + if workspace is None: + return None + if preferred_workspace_id != workspace["id"]: + set_session_workspace( + conn, + session_id=session_id, + user_account_id=user_account_id, + workspace_id=workspace["id"], + ) + return workspace + + +def _ensure_hosted_admin_access(conn, *, user_account_id: UUID) -> None: + enabled_flags = set(list_feature_flags_for_user(conn, user_account_id=user_account_id)) + required_flags = {"hosted_admin_read", "hosted_admin_operator"} + missing_flags = sorted(required_flags - enabled_flags) + if missing_flags: + raise PermissionError( + "hosted admin access requires hosted_admin_read and hosted_admin_operator flags" + ) + + +def _record_workspace_onboarding_failure( + conn, + *, + workspace_id: UUID, + error_code: str, + error_detail: str, +) -> None: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE workspaces + SET support_status = CASE WHEN support_status = 'blocked' THEN support_status ELSE 'needs_attention' END, + onboarding_last_error_code = %s, + onboarding_last_error_detail = %s, + onboarding_last_error_at = clock_timestamp(), + onboarding_error_count = onboarding_error_count + 1, + support_notes = COALESCE(support_notes, '{}'::jsonb) || jsonb_build_object( + 'last_onboarding_error_code', %s::text, + 'last_onboarding_error_detail', %s::text, + 'last_onboarding_error_at', clock_timestamp() + ), + incident_evidence = COALESCE(incident_evidence, '{}'::jsonb) || jsonb_build_object( + 'last_onboarding_error_code', %s::text, + 'last_onboarding_error_detail', %s::text + ) + WHERE id = %s + """, + ( + error_code, + error_detail, + error_code, + error_detail, + error_code, + error_detail, + workspace_id, + ), + ) + + +def _hosted_rollout_block_error( + *, + flag_key: str, +) -> JSONResponse: + return JSONResponse( + status_code=403, + content={ + "detail": { + "code": "hosted_rollout_blocked", + "message": f"hosted flow is blocked by rollout flag {flag_key}", + "flag_key": flag_key, + } + }, + ) + + +def _hosted_rate_limit_error( + *, + detail_code: str, + message: str, + retry_after_seconds: int, + rate_limit_key: str, + window_seconds: int, + max_requests: int, + observed_requests: int, + abuse_signal: str | None, +) -> JSONResponse: + payload: dict[str, object] = { + "code": detail_code, + "message": message, + "retry_after_seconds": retry_after_seconds, + "rate_limit_key": rate_limit_key, + "window_seconds": window_seconds, + "max_requests": max_requests, + "observed_requests": observed_requests, + } + if abuse_signal is not None: + payload["abuse_signal"] = abuse_signal + + return JSONResponse( + status_code=429, + headers={"Retry-After": str(retry_after_seconds)}, + content={"detail": payload}, + ) + + +@app.get("/healthz") +def healthcheck() -> JSONResponse: + settings = get_settings() + database_ok = ping_database( + settings.database_url, + settings.healthcheck_timeout_seconds, + ) + payload = build_healthcheck_payload(settings, database_ok) + status_code = 200 if payload["status"] == "ok" else 503 + return JSONResponse( + status_code=status_code, + content=payload, + ) + + +@app.get("/v0/agent-profiles") +def list_agent_profiles() -> JSONResponse: + settings = get_settings() + with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: + items = list_registered_agent_profiles(ContinuityStore(conn)) + summary: AgentProfileListSummary = { + "total_count": len(items), + "order": list(AGENT_PROFILE_LIST_ORDER), + } + payload: AgentProfileListResponse = { + "items": items, + "summary": summary, + } + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.post("/v0/context/compile") +def compile_context(request: CompileContextRequest) -> JSONResponse: + settings = get_settings() + artifact_retrieval = None + semantic_artifact_retrieval = None + if isinstance(request.artifact_retrieval, CompileContextTaskScopedArtifactRetrievalRequest): + artifact_retrieval = CompileContextTaskScopedArtifactRetrievalInput( + task_id=request.artifact_retrieval.task_id, + query=request.artifact_retrieval.query, + limit=request.artifact_retrieval.limit, + ) + elif isinstance( + request.artifact_retrieval, + CompileContextArtifactScopedArtifactRetrievalRequest, + ): + artifact_retrieval = CompileContextArtifactScopedArtifactRetrievalInput( + task_artifact_id=request.artifact_retrieval.task_artifact_id, + query=request.artifact_retrieval.query, + limit=request.artifact_retrieval.limit, + ) + if isinstance( + request.semantic_artifact_retrieval, + CompileContextTaskScopedSemanticArtifactRetrievalRequest, + ): + semantic_artifact_retrieval = CompileContextTaskScopedSemanticArtifactRetrievalInput( + task_id=request.semantic_artifact_retrieval.task_id, + embedding_config_id=request.semantic_artifact_retrieval.embedding_config_id, + query_vector=tuple(request.semantic_artifact_retrieval.query_vector), + limit=request.semantic_artifact_retrieval.limit, + ) + elif isinstance( + request.semantic_artifact_retrieval, + CompileContextArtifactScopedSemanticArtifactRetrievalRequest, + ): + semantic_artifact_retrieval = ( + CompileContextArtifactScopedSemanticArtifactRetrievalInput( + task_artifact_id=request.semantic_artifact_retrieval.task_artifact_id, + embedding_config_id=request.semantic_artifact_retrieval.embedding_config_id, + query_vector=tuple(request.semantic_artifact_retrieval.query_vector), + limit=request.semantic_artifact_retrieval.limit, + ) + ) + + try: + with user_connection(settings.database_url, request.user_id) as conn: + store = ContinuityStore(conn) + thread = store.get_thread(request.thread_id) + result = compile_and_persist_trace( + store, + user_id=request.user_id, + thread_id=request.thread_id, + limits=ContextCompilerLimits( + max_sessions=request.max_sessions, + max_events=request.max_events, + max_memories=request.max_memories, + max_entities=request.max_entities, + max_entity_edges=request.max_entity_edges, + ), + semantic_retrieval=( + None + if request.semantic is None + else CompileContextSemanticRetrievalInput( + embedding_config_id=request.semantic.embedding_config_id, + query_vector=tuple(request.semantic.query_vector), + limit=request.semantic.limit, + ) + ), + artifact_retrieval=artifact_retrieval, + semantic_artifact_retrieval=semantic_artifact_retrieval, + ) + except TaskArtifactChunkRetrievalValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + except SemanticArtifactChunkRetrievalValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + except SemanticMemoryRetrievalValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + except (TaskNotFoundError, TaskArtifactNotFoundError) as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + except ContinuityStoreInvariantError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder( + { + "trace_id": result.trace_id, + "trace_event_count": result.trace_event_count, + "context_pack": result.context_pack, + "metadata": {"agent_profile_id": _thread_agent_profile_id(thread)}, + } + ), + ) + + +@app.post("/v0/responses") +def generate_assistant_response(request: GenerateResponseRequest) -> JSONResponse: + settings = get_settings() + rate_limit_error = _enforce_response_rate_limit(settings, request.user_id) + if rate_limit_error is not None: + return rate_limit_error + + try: + with user_connection(settings.database_url, request.user_id) as conn: + store = ContinuityStore(conn) + thread = store.get_thread(request.thread_id) + result = generate_response( + store=store, + settings=settings, + user_id=request.user_id, + thread_id=request.thread_id, + message_text=request.message, + limits=ContextCompilerLimits( + max_sessions=request.max_sessions, + max_events=request.max_events, + max_memories=request.max_memories, + max_entities=request.max_entities, + max_entity_edges=request.max_entity_edges, + ), + ) + except ContinuityStoreInvariantError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + if isinstance(result, ResponseFailure): + return JSONResponse( + status_code=502, + content=jsonable_encoder( + { + "detail": result.detail, + "trace": result.trace, + "metadata": {"agent_profile_id": _thread_agent_profile_id(thread)}, + } + ), + ) + + response_payload = dict(result) + response_payload["metadata"] = {"agent_profile_id": _thread_agent_profile_id(thread)} + return JSONResponse( + status_code=200, + content=jsonable_encoder(response_payload), + ) + + +@app.post("/v0/threads") +def create_thread(request: CreateThreadRequest) -> JSONResponse: + settings = get_settings() + agent_profile_id = ( + request.agent_profile_id + if request.agent_profile_id is not None + else DEFAULT_AGENT_PROFILE_ID + ) + thread_input = ThreadCreateInput( + title=request.title, + agent_profile_id=agent_profile_id, + ) + + with user_connection(settings.database_url, request.user_id) as conn: + store = ContinuityStore(conn) + if get_registered_agent_profile(store, agent_profile_id) is None: + allowed_agent_profile_ids = list_registered_agent_profile_ids(store) + return JSONResponse( + status_code=422, + content={ + "detail": { + "code": "invalid_agent_profile_id", + "message": ( + "agent_profile_id must be one of: " + + ", ".join(allowed_agent_profile_ids) + ), + "allowed_agent_profile_ids": allowed_agent_profile_ids, + } + }, + ) + + created = store.create_thread( + thread_input.title, + thread_input.agent_profile_id, + ) + + payload: ThreadCreateResponse = {"thread": _serialize_thread(created)} + return JSONResponse( + status_code=201, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/threads") +def list_threads(user_id: UUID) -> JSONResponse: + settings = get_settings() + + with user_connection(settings.database_url, user_id) as conn: + items = [_serialize_thread(thread) for thread in ContinuityStore(conn).list_threads()] + + summary: ThreadListSummary = { + "total_count": len(items), + "order": list(THREAD_LIST_ORDER), + } + payload: ThreadListResponse = { + "items": items, + "summary": summary, + } + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/threads/{thread_id}") +def get_thread(thread_id: UUID, user_id: UUID) -> JSONResponse: + settings = get_settings() + + with user_connection(settings.database_url, user_id) as conn: + thread = ContinuityStore(conn).get_thread_optional(thread_id) + + if thread is None: + return JSONResponse(status_code=404, content={"detail": f"thread {thread_id} was not found"}) + + payload: ThreadDetailResponse = {"thread": _serialize_thread(thread)} + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/threads/{thread_id}/sessions") +def list_thread_sessions(thread_id: UUID, user_id: UUID) -> JSONResponse: + settings = get_settings() + + with user_connection(settings.database_url, user_id) as conn: + store = ContinuityStore(conn) + thread = store.get_thread_optional(thread_id) + if thread is None: + return JSONResponse(status_code=404, content={"detail": f"thread {thread_id} was not found"}) + items = [_serialize_thread_session(session) for session in store.list_thread_sessions(thread_id)] + + summary: ThreadSessionListSummary = { + "thread_id": str(thread["id"]), + "total_count": len(items), + "order": list(THREAD_SESSION_LIST_ORDER), + } + payload: ThreadSessionListResponse = { + "items": items, + "summary": summary, + } + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/threads/{thread_id}/events") +def list_thread_events(thread_id: UUID, user_id: UUID) -> JSONResponse: + settings = get_settings() + + with user_connection(settings.database_url, user_id) as conn: + store = ContinuityStore(conn) + thread = store.get_thread_optional(thread_id) + if thread is None: + return JSONResponse(status_code=404, content={"detail": f"thread {thread_id} was not found"}) + items = [_serialize_thread_event(event) for event in store.list_thread_events(thread_id)] + + summary: ThreadEventListSummary = { + "thread_id": str(thread["id"]), + "total_count": len(items), + "order": list(THREAD_EVENT_LIST_ORDER), + } + payload: ThreadEventListResponse = { + "items": items, + "summary": summary, + } + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/threads/{thread_id}/resumption-brief") +def get_thread_resumption_brief( + thread_id: UUID, + user_id: UUID, + max_events: Annotated[ + int, + Query(ge=0, le=MAX_RESUMPTION_BRIEF_EVENT_LIMIT), + ] = DEFAULT_RESUMPTION_BRIEF_EVENT_LIMIT, + max_open_loops: Annotated[ + int, + Query( + ge=0, + le=MAX_RESUMPTION_BRIEF_OPEN_LOOP_LIMIT, + ), + ] = DEFAULT_RESUMPTION_BRIEF_OPEN_LOOP_LIMIT, + max_memories: Annotated[ + int, + Query(ge=0, le=MAX_RESUMPTION_BRIEF_MEMORY_LIMIT), + ] = DEFAULT_RESUMPTION_BRIEF_MEMORY_LIMIT, +) -> JSONResponse: + settings = get_settings() + request = ResumptionBriefRequestInput( + thread_id=thread_id, + max_events=max_events, + max_open_loops=max_open_loops, + max_memories=max_memories, + ) + + with user_connection(settings.database_url, user_id) as conn: + store = ContinuityStore(conn) + thread = store.get_thread_optional(thread_id) + if thread is None: + return JSONResponse(status_code=404, content={"detail": f"thread {thread_id} was not found"}) + brief = compile_resumption_brief( + store, + thread=thread, + event_limit=request.max_events, + open_loop_limit=request.max_open_loops, + memory_limit=request.max_memories, + ) + + payload: ResumptionBriefResponse = {"brief": brief} + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/traces") +def list_traces(user_id: UUID) -> JSONResponse: + settings = get_settings() + + with user_connection(settings.database_url, user_id) as conn: + payload = list_trace_records( + ContinuityStore(conn), + user_id=user_id, + ) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/traces/{trace_id}") +def get_trace(trace_id: UUID, user_id: UUID) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, user_id) as conn: + payload = get_trace_record( + ContinuityStore(conn), + user_id=user_id, + trace_id=trace_id, + ) + except TraceNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/traces/{trace_id}/events") +def list_trace_events(trace_id: UUID, user_id: UUID) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, user_id) as conn: + payload = list_trace_event_records( + ContinuityStore(conn), + user_id=user_id, + trace_id=trace_id, + ) + except TraceNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.post("/v0/memories/admit") +def admit_memory(request: AdmitMemoryRequest) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, request.user_id) as conn: + decision = admit_memory_candidate( + ContinuityStore(conn), + user_id=request.user_id, + candidate=MemoryCandidateInput( + memory_key=request.memory_key, + value=request.value, + source_event_ids=tuple(request.source_event_ids), + agent_profile_id=request.agent_profile_id, + delete_requested=request.delete_requested, + memory_type=request.memory_type, + confidence=request.confidence, + salience=request.salience, + confirmation_status=request.confirmation_status, + trust_class=request.trust_class, + promotion_eligibility=request.promotion_eligibility, + evidence_count=request.evidence_count, + independent_source_count=request.independent_source_count, + extracted_by_model=request.extracted_by_model, + trust_reason=request.trust_reason, + valid_from=request.valid_from, + valid_to=request.valid_to, + last_confirmed_at=request.last_confirmed_at, + open_loop=( + None + if request.open_loop is None + else OpenLoopCandidateInput( + title=request.open_loop.title, + due_at=request.open_loop.due_at, + ) + ), + ), + ) + except MemoryAdmissionValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + payload = { + "decision": decision.action, + "reason": decision.reason, + "memory": decision.memory, + "revision": decision.revision, + } + if decision.open_loop is not None: + payload["open_loop"] = decision.open_loop + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/open-loops") +def list_open_loops( + user_id: UUID, + status: OpenLoopStatusFilter = Query(default="open"), + limit: int = Query(default=DEFAULT_OPEN_LOOP_LIMIT, ge=1, le=MAX_OPEN_LOOP_LIMIT), +) -> JSONResponse: + settings = get_settings() + + with user_connection(settings.database_url, user_id) as conn: + payload = list_open_loop_records( + ContinuityStore(conn), + user_id=user_id, + status=status, + limit=limit, + ) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/open-loops/{open_loop_id}") +def get_open_loop( + open_loop_id: UUID, + user_id: UUID, +) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, user_id) as conn: + payload = get_open_loop_record( + ContinuityStore(conn), + user_id=user_id, + open_loop_id=open_loop_id, + ) + except OpenLoopNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.post("/v0/open-loops") +def create_open_loop(request: CreateOpenLoopRequest) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, request.user_id) as conn: + payload = create_open_loop_record( + ContinuityStore(conn), + user_id=request.user_id, + open_loop=OpenLoopCreateInput( + memory_id=request.memory_id, + title=request.title, + due_at=request.due_at, + ), + ) + except OpenLoopValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse( + status_code=201, + content=jsonable_encoder(payload), + ) + + +@app.post("/v0/open-loops/{open_loop_id}/status") +def update_open_loop_status( + open_loop_id: UUID, + request: UpdateOpenLoopStatusRequest, +) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, request.user_id) as conn: + payload = update_open_loop_status_record( + ContinuityStore(conn), + user_id=request.user_id, + open_loop_id=open_loop_id, + request=OpenLoopStatusUpdateInput( + status=request.status, # type: ignore[arg-type] + resolution_note=request.resolution_note, + ), + ) + except OpenLoopValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + except OpenLoopNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.post("/v0/consents") +def upsert_consent(request: UpsertConsentRequest) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, request.user_id) as conn: + payload = upsert_consent_record( + ContinuityStore(conn), + user_id=request.user_id, + consent=ConsentUpsertInput( + consent_key=request.consent_key, + status=request.status, + metadata=request.metadata, + ), + ) + except PolicyValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + status_code = 201 if payload["write_mode"] == "created" else 200 + return JSONResponse( + status_code=status_code, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/consents") +def list_consents(user_id: UUID) -> JSONResponse: + settings = get_settings() + + with user_connection(settings.database_url, user_id) as conn: + payload = list_consent_records( + ContinuityStore(conn), + user_id=user_id, + ) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.post("/v0/policies") +def create_policy(request: CreatePolicyRequest) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, request.user_id) as conn: + store = ContinuityStore(conn) + if ( + request.agent_profile_id is not None + and get_registered_agent_profile(store, request.agent_profile_id) is None + ): + allowed_agent_profile_ids = list_registered_agent_profile_ids(store) + return JSONResponse( + status_code=422, + content={ + "detail": { + "code": "invalid_agent_profile_id", + "message": ( + "agent_profile_id must be one of: " + + ", ".join(allowed_agent_profile_ids) + ), + "allowed_agent_profile_ids": allowed_agent_profile_ids, + } + }, + ) + + payload = create_policy_record( + store, + user_id=request.user_id, + policy=PolicyCreateInput( + name=request.name, + action=request.action, + scope=request.scope, + effect=request.effect, + priority=request.priority, + active=request.active, + conditions=request.conditions, + required_consents=tuple(request.required_consents), + agent_profile_id=request.agent_profile_id, + ), + ) + except PolicyValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse( + status_code=201, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/policies") +def list_policies(user_id: UUID) -> JSONResponse: + settings = get_settings() + + with user_connection(settings.database_url, user_id) as conn: + payload = list_policy_records( + ContinuityStore(conn), + user_id=user_id, + ) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/policies/{policy_id}") +def get_policy(policy_id: UUID, user_id: UUID) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, user_id) as conn: + payload = get_policy_record( + ContinuityStore(conn), + user_id=user_id, + policy_id=policy_id, + ) + except PolicyNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.post("/v0/policies/evaluate") +def evaluate_policy(request: EvaluatePolicyRequest) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, request.user_id) as conn: + payload = evaluate_policy_request( + ContinuityStore(conn), + user_id=request.user_id, + request=PolicyEvaluationRequestInput( + thread_id=request.thread_id, + action=request.action, + scope=request.scope, + attributes=request.attributes, + ), + ) + except PolicyEvaluationValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.post("/v0/tools") +def create_tool(request: CreateToolRequest) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, request.user_id) as conn: + payload = create_tool_record( + ContinuityStore(conn), + user_id=request.user_id, + tool=ToolCreateInput( + tool_key=request.tool_key, + name=request.name, + description=request.description, + version=request.version, + metadata_version=request.metadata_version, + active=request.active, + tags=tuple(request.tags), + action_hints=tuple(request.action_hints), + scope_hints=tuple(request.scope_hints), + domain_hints=tuple(request.domain_hints), + risk_hints=tuple(request.risk_hints), + metadata=request.metadata, + ), + ) + except ToolValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse( + status_code=201, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/tools") +def list_tools(user_id: UUID) -> JSONResponse: + settings = get_settings() + + with user_connection(settings.database_url, user_id) as conn: + payload = list_tool_records( + ContinuityStore(conn), + user_id=user_id, + ) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.post("/v0/tools/allowlist/evaluate") +def evaluate_tools_allowlist(request: EvaluateToolAllowlistRequest) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, request.user_id) as conn: + payload = evaluate_tool_allowlist( + ContinuityStore(conn), + user_id=request.user_id, + request=ToolAllowlistEvaluationRequestInput( + thread_id=request.thread_id, + action=request.action, + scope=request.scope, + domain_hint=request.domain_hint, + risk_hint=request.risk_hint, + attributes=request.attributes, + ), + ) + except ToolAllowlistValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.post("/v0/tools/route") +def route_tool(request: RouteToolRequest) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, request.user_id) as conn: + payload = route_tool_invocation( + ContinuityStore(conn), + user_id=request.user_id, + request=ToolRoutingRequestInput( + thread_id=request.thread_id, + tool_id=request.tool_id, + action=request.action, + scope=request.scope, + domain_hint=request.domain_hint, + risk_hint=request.risk_hint, + attributes=request.attributes, + ), + ) + except ToolRoutingValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.post("/v0/approvals/requests") +def create_approval_request(request: CreateApprovalRequest) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, request.user_id) as conn: + payload = submit_approval_request( + ContinuityStore(conn), + user_id=request.user_id, + request=ApprovalRequestCreateInput( + thread_id=request.thread_id, + tool_id=request.tool_id, + task_run_id=request.task_run_id, + action=request.action, + scope=request.scope, + domain_hint=request.domain_hint, + risk_hint=request.risk_hint, + attributes=request.attributes, + ), + ) + except ToolRoutingValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/approvals") +def list_approvals(user_id: UUID) -> JSONResponse: + settings = get_settings() + + with user_connection(settings.database_url, user_id) as conn: + payload = list_approval_records( + ContinuityStore(conn), + user_id=user_id, + ) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/approvals/{approval_id}") +def get_approval(approval_id: UUID, user_id: UUID) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, user_id) as conn: + payload = get_approval_record( + ContinuityStore(conn), + user_id=user_id, + approval_id=approval_id, + ) + except ApprovalNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.post("/v0/approvals/{approval_id}/approve") +def approve_approval(approval_id: UUID, request: ResolveApprovalRequest) -> JSONResponse: + settings = get_settings() + resolution_error: ( + ApprovalResolutionConflictError | TaskStepApprovalLinkageError | TaskStepLifecycleBoundaryError | None + ) = None + + try: + with user_connection(settings.database_url, request.user_id) as conn: + try: + payload = approve_approval_record( + ContinuityStore(conn), + user_id=request.user_id, + request=ApprovalApproveInput(approval_id=approval_id), + ) + except ( + ApprovalResolutionConflictError, + TaskStepApprovalLinkageError, + TaskStepLifecycleBoundaryError, + ) as exc: + resolution_error = exc + payload = None + except ApprovalNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + if resolution_error is not None: + return JSONResponse(status_code=409, content={"detail": str(resolution_error)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.post("/v0/approvals/{approval_id}/reject") +def reject_approval(approval_id: UUID, request: ResolveApprovalRequest) -> JSONResponse: + settings = get_settings() + resolution_error: ( + ApprovalResolutionConflictError | TaskStepApprovalLinkageError | TaskStepLifecycleBoundaryError | None + ) = None + + try: + with user_connection(settings.database_url, request.user_id) as conn: + try: + payload = reject_approval_record( + ContinuityStore(conn), + user_id=request.user_id, + request=ApprovalRejectInput(approval_id=approval_id), + ) + except ( + ApprovalResolutionConflictError, + TaskStepApprovalLinkageError, + TaskStepLifecycleBoundaryError, + ) as exc: + resolution_error = exc + payload = None + except ApprovalNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + if resolution_error is not None: + return JSONResponse(status_code=409, content={"detail": str(resolution_error)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.post("/v0/approvals/{approval_id}/execute") +def execute_approved_proxy(approval_id: UUID, request: ExecuteApprovedProxyRequest) -> JSONResponse: + settings = get_settings() + execution_error: ( + ProxyExecutionApprovalStateError + | ProxyExecutionHandlerNotFoundError + | ProxyExecutionIdempotencyError + | TaskStepApprovalLinkageError + | TaskStepExecutionLinkageError + | None + ) = None + + try: + with user_connection(settings.database_url, request.user_id) as conn: + try: + payload = execute_approved_proxy_request( + ContinuityStore(conn), + user_id=request.user_id, + request=ProxyExecutionRequestInput( + approval_id=approval_id, + task_run_id=request.task_run_id, + ), + ) + except ( + ProxyExecutionApprovalStateError, + ProxyExecutionHandlerNotFoundError, + ProxyExecutionIdempotencyError, + TaskStepApprovalLinkageError, + TaskStepExecutionLinkageError, + ) as exc: + execution_error = exc + payload = None + except ApprovalNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + if execution_error is not None: + return JSONResponse(status_code=409, content={"detail": str(execution_error)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/tasks") +def list_tasks(user_id: UUID) -> JSONResponse: + settings = get_settings() + + with user_connection(settings.database_url, user_id) as conn: + payload = list_task_records( + ContinuityStore(conn), + user_id=user_id, + ) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/tasks/{task_id}") +def get_task(task_id: UUID, user_id: UUID) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, user_id) as conn: + payload = get_task_record( + ContinuityStore(conn), + user_id=user_id, + task_id=task_id, + ) + except TaskNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.post("/v0/tasks/{task_id}/runs") +def create_task_run(task_id: UUID, request: CreateTaskRunRequest) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, request.user_id) as conn: + payload = create_task_run_record( + ContinuityStore(conn), + user_id=request.user_id, + request=TaskRunCreateInput( + task_id=task_id, + max_ticks=request.max_ticks, + retry_cap=request.retry_cap, + checkpoint=request.checkpoint, + ), + ) + except TaskNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + except TaskRunValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse( + status_code=201, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/tasks/{task_id}/runs") +def list_task_runs(task_id: UUID, user_id: UUID) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, user_id) as conn: + payload = list_task_run_records( + ContinuityStore(conn), + user_id=user_id, + task_id=task_id, + ) + except TaskNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/task-runs/{task_run_id}") +def get_task_run(task_run_id: UUID, user_id: UUID) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, user_id) as conn: + payload = get_task_run_record( + ContinuityStore(conn), + user_id=user_id, + task_run_id=task_run_id, + ) + except TaskRunNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +def _mutate_task_run( + *, + task_run_id: UUID, + request: MutateTaskRunRequest, + mutation_handler: Callable[..., object], + mutation_input_model: type[TaskRunTickInput] + | type[TaskRunPauseInput] + | type[TaskRunResumeInput] + | type[TaskRunCancelInput], +) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, request.user_id) as conn: + payload = mutation_handler( + ContinuityStore(conn), + user_id=request.user_id, + request=mutation_input_model(task_run_id=task_run_id), + ) + except TaskRunValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + except TaskRunNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + except TaskRunTransitionError as exc: + return JSONResponse(status_code=409, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.post("/v0/task-runs/{task_run_id}/tick") +def tick_task_run(task_run_id: UUID, request: MutateTaskRunRequest) -> JSONResponse: + return _mutate_task_run( + task_run_id=task_run_id, + request=request, + mutation_handler=tick_task_run_record, + mutation_input_model=TaskRunTickInput, + ) + + +@app.post("/v0/task-runs/{task_run_id}/pause") +def pause_task_run(task_run_id: UUID, request: MutateTaskRunRequest) -> JSONResponse: + return _mutate_task_run( + task_run_id=task_run_id, + request=request, + mutation_handler=pause_task_run_record, + mutation_input_model=TaskRunPauseInput, + ) + + +@app.post("/v0/task-runs/{task_run_id}/resume") +def resume_task_run(task_run_id: UUID, request: MutateTaskRunRequest) -> JSONResponse: + return _mutate_task_run( + task_run_id=task_run_id, + request=request, + mutation_handler=resume_task_run_record, + mutation_input_model=TaskRunResumeInput, + ) + + +@app.post("/v0/task-runs/{task_run_id}/cancel") +def cancel_task_run(task_run_id: UUID, request: MutateTaskRunRequest) -> JSONResponse: + return _mutate_task_run( + task_run_id=task_run_id, + request=request, + mutation_handler=cancel_task_run_record, + mutation_input_model=TaskRunCancelInput, + ) + + +@app.post("/v0/gmail-accounts") +def connect_gmail_account(request: ConnectGmailAccountRequest) -> JSONResponse: + settings = get_settings() + secret_manager = build_gmail_secret_manager(settings.gmail_secret_manager_url) + + try: + with user_connection(settings.database_url, request.user_id) as conn: + payload = create_gmail_account_record( + ContinuityStore(conn), + secret_manager, + user_id=request.user_id, + request=GmailAccountConnectInput( + provider_account_id=request.provider_account_id, + email_address=request.email_address, + display_name=request.display_name, + scope=request.scope, + access_token=request.access_token, + refresh_token=request.refresh_token, + client_id=request.client_id, + client_secret=request.client_secret, + access_token_expires_at=request.access_token_expires_at, + ), + ) + except GmailCredentialValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + except GmailCredentialPersistenceError as exc: + return JSONResponse(status_code=409, content={"detail": str(exc)}) + except GmailAccountAlreadyExistsError as exc: + return JSONResponse(status_code=409, content={"detail": str(exc)}) + + return JSONResponse( + status_code=201, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/gmail-accounts") +def list_gmail_accounts(user_id: UUID) -> JSONResponse: + settings = get_settings() + + with user_connection(settings.database_url, user_id) as conn: + payload = list_gmail_account_records( + ContinuityStore(conn), + user_id=user_id, + ) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/gmail-accounts/{gmail_account_id}") +def get_gmail_account(gmail_account_id: UUID, user_id: UUID) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, user_id) as conn: + payload = get_gmail_account_record( + ContinuityStore(conn), + user_id=user_id, + gmail_account_id=gmail_account_id, + ) + except GmailAccountNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.post("/v0/gmail-accounts/{gmail_account_id}/messages/{provider_message_id}/ingest") +def ingest_gmail_message( + gmail_account_id: UUID, + provider_message_id: str, + request: IngestGmailMessageRequest, +) -> JSONResponse: + settings = get_settings() + secret_manager = build_gmail_secret_manager(settings.gmail_secret_manager_url) + + try: + with user_connection(settings.database_url, request.user_id) as conn: + payload = ingest_gmail_message_record( + ContinuityStore(conn), + secret_manager, + user_id=request.user_id, + request=GmailMessageIngestInput( + gmail_account_id=gmail_account_id, + task_workspace_id=request.task_workspace_id, + provider_message_id=provider_message_id, + ), + ) + except GmailAccountNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + except TaskWorkspaceNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + except GmailMessageNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + except GmailMessageUnsupportedError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + except ( + GmailCredentialNotFoundError, + GmailCredentialInvalidError, + GmailCredentialPersistenceError, + ) as exc: + return JSONResponse(status_code=409, content={"detail": str(exc)}) + except TaskArtifactValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + except (GmailMessageFetchError, GmailCredentialRefreshError) as exc: + return JSONResponse(status_code=502, content={"detail": str(exc)}) + except TaskArtifactAlreadyExistsError as exc: + return JSONResponse(status_code=409, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.post("/v0/calendar-accounts") +def connect_calendar_account(request: ConnectCalendarAccountRequest) -> JSONResponse: + settings = get_settings() + secret_manager = build_calendar_secret_manager(settings.calendar_secret_manager_url) + + try: + with user_connection(settings.database_url, request.user_id) as conn: + payload = create_calendar_account_record( + ContinuityStore(conn), + secret_manager, + user_id=request.user_id, + request=CalendarAccountConnectInput( + provider_account_id=request.provider_account_id, + email_address=request.email_address, + display_name=request.display_name, + scope=request.scope, + access_token=request.access_token, + ), + ) + except CalendarCredentialValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + except CalendarCredentialPersistenceError as exc: + return JSONResponse(status_code=409, content={"detail": str(exc)}) + except CalendarAccountAlreadyExistsError as exc: + return JSONResponse(status_code=409, content={"detail": str(exc)}) + + return JSONResponse( + status_code=201, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/calendar-accounts") +def list_calendar_accounts(user_id: UUID) -> JSONResponse: + settings = get_settings() + + with user_connection(settings.database_url, user_id) as conn: + payload = list_calendar_account_records( + ContinuityStore(conn), + user_id=user_id, + ) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/calendar-accounts/{calendar_account_id}") +def get_calendar_account(calendar_account_id: UUID, user_id: UUID) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, user_id) as conn: + payload = get_calendar_account_record( + ContinuityStore(conn), + user_id=user_id, + calendar_account_id=calendar_account_id, + ) + except CalendarAccountNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/calendar-accounts/{calendar_account_id}/events") +def list_calendar_events( + calendar_account_id: UUID, + user_id: UUID, + limit: int = Query(default=DEFAULT_CALENDAR_EVENT_LIST_LIMIT, ge=1, le=MAX_CALENDAR_EVENT_LIST_LIMIT), + time_min: datetime | None = Query(default=None), + time_max: datetime | None = Query(default=None), +) -> JSONResponse: + settings = get_settings() + secret_manager = build_calendar_secret_manager(settings.calendar_secret_manager_url) + + try: + with user_connection(settings.database_url, user_id) as conn: + payload = list_calendar_event_records( + ContinuityStore(conn), + secret_manager, + user_id=user_id, + request=CalendarEventListInput( + calendar_account_id=calendar_account_id, + limit=limit, + time_min=time_min, + time_max=time_max, + ), + ) + except CalendarAccountNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + except ( + CalendarCredentialNotFoundError, + CalendarCredentialInvalidError, + CalendarCredentialPersistenceError, + ) as exc: + return JSONResponse(status_code=409, content={"detail": str(exc)}) + except CalendarEventListValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + except CalendarEventFetchError as exc: + return JSONResponse(status_code=502, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.post("/v0/calendar-accounts/{calendar_account_id}/events/{provider_event_id}/ingest") +def ingest_calendar_event( + calendar_account_id: UUID, + provider_event_id: str, + request: IngestCalendarEventRequest, +) -> JSONResponse: + settings = get_settings() + secret_manager = build_calendar_secret_manager(settings.calendar_secret_manager_url) + + try: + with user_connection(settings.database_url, request.user_id) as conn: + payload = ingest_calendar_event_record( + ContinuityStore(conn), + secret_manager, + user_id=request.user_id, + request=CalendarEventIngestInput( + calendar_account_id=calendar_account_id, + task_workspace_id=request.task_workspace_id, + provider_event_id=provider_event_id, + ), + ) + except CalendarAccountNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + except TaskWorkspaceNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + except CalendarEventNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + except CalendarEventUnsupportedError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + except ( + CalendarCredentialNotFoundError, + CalendarCredentialInvalidError, + CalendarCredentialPersistenceError, + ) as exc: + return JSONResponse(status_code=409, content={"detail": str(exc)}) + except TaskArtifactValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + except CalendarEventFetchError as exc: + return JSONResponse(status_code=502, content={"detail": str(exc)}) + except TaskArtifactAlreadyExistsError as exc: + return JSONResponse(status_code=409, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.post("/v0/tasks/{task_id}/workspace") +def create_task_workspace(task_id: UUID, request: CreateTaskWorkspaceRequest) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, request.user_id) as conn: + payload = create_task_workspace_record( + ContinuityStore(conn), + settings=settings, + user_id=request.user_id, + request=TaskWorkspaceCreateInput( + task_id=task_id, + status="active", + ), + ) + except TaskNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + except (TaskWorkspaceAlreadyExistsError, TaskWorkspaceProvisioningError) as exc: + return JSONResponse(status_code=409, content={"detail": str(exc)}) + + return JSONResponse( + status_code=201, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/task-workspaces") +def list_task_workspaces(user_id: UUID) -> JSONResponse: + settings = get_settings() + + with user_connection(settings.database_url, user_id) as conn: + payload = list_task_workspace_records( + ContinuityStore(conn), + user_id=user_id, + ) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/task-workspaces/{task_workspace_id}") +def get_task_workspace(task_workspace_id: UUID, user_id: UUID) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, user_id) as conn: + payload = get_task_workspace_record( + ContinuityStore(conn), + user_id=user_id, + task_workspace_id=task_workspace_id, + ) + except TaskWorkspaceNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/tasks/{task_id}/steps") +def list_task_steps(task_id: UUID, user_id: UUID) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, user_id) as conn: + payload = list_task_step_records( + ContinuityStore(conn), + user_id=user_id, + task_id=task_id, + ) + except TaskNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/task-steps/{task_step_id}") +def get_task_step(task_step_id: UUID, user_id: UUID) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, user_id) as conn: + payload = get_task_step_record( + ContinuityStore(conn), + user_id=user_id, + task_step_id=task_step_id, + ) + except TaskStepNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.post("/v0/task-workspaces/{task_workspace_id}/artifacts") +def register_task_artifact( + task_workspace_id: UUID, + request: RegisterTaskArtifactRequest, +) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, request.user_id) as conn: + payload = register_task_artifact_record( + ContinuityStore(conn), + user_id=request.user_id, + request=TaskArtifactRegisterInput( + task_workspace_id=task_workspace_id, + local_path=request.local_path, + media_type_hint=request.media_type_hint, + ), + ) + except TaskWorkspaceNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + except TaskArtifactValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + except TaskArtifactAlreadyExistsError as exc: + return JSONResponse(status_code=409, content={"detail": str(exc)}) + + return JSONResponse( + status_code=201, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/task-artifacts") +def list_task_artifacts(user_id: UUID) -> JSONResponse: + settings = get_settings() + + with user_connection(settings.database_url, user_id) as conn: + payload = list_task_artifact_records( + ContinuityStore(conn), + user_id=user_id, + ) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/task-artifacts/{task_artifact_id}") +def get_task_artifact(task_artifact_id: UUID, user_id: UUID) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, user_id) as conn: + payload = get_task_artifact_record( + ContinuityStore(conn), + user_id=user_id, + task_artifact_id=task_artifact_id, + ) + except TaskArtifactNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.post("/v0/task-artifacts/{task_artifact_id}/ingest") +def ingest_task_artifact( + task_artifact_id: UUID, + request: IngestTaskArtifactRequest, +) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, request.user_id) as conn: + payload = ingest_task_artifact_record( + ContinuityStore(conn), + user_id=request.user_id, + request=TaskArtifactIngestInput(task_artifact_id=task_artifact_id), + ) + except TaskArtifactNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + except TaskWorkspaceNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + except TaskArtifactValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/task-artifacts/{task_artifact_id}/chunks") +def list_task_artifact_chunks(task_artifact_id: UUID, user_id: UUID) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, user_id) as conn: + payload = list_task_artifact_chunk_records( + ContinuityStore(conn), + user_id=user_id, + task_artifact_id=task_artifact_id, + ) + except TaskArtifactNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.post("/v0/tasks/{task_id}/artifact-chunks/retrieve") +def retrieve_task_artifact_chunks( + task_id: UUID, + request: RetrieveArtifactChunksRequest, +) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, request.user_id) as conn: + payload = retrieve_task_scoped_artifact_chunk_records( + ContinuityStore(conn), + user_id=request.user_id, + request=TaskScopedArtifactChunkRetrievalInput( + task_id=task_id, + query=request.query, + ), + ) + except TaskNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + except TaskArtifactChunkRetrievalValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.post("/v0/task-artifacts/{task_artifact_id}/chunks/retrieve") +def retrieve_task_artifact_chunks_for_artifact( + task_artifact_id: UUID, + request: RetrieveArtifactChunksRequest, +) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, request.user_id) as conn: + payload = retrieve_artifact_scoped_artifact_chunk_records( + ContinuityStore(conn), + user_id=request.user_id, + request=ArtifactScopedArtifactChunkRetrievalInput( + task_artifact_id=task_artifact_id, + query=request.query, + ), + ) + except TaskArtifactNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + except TaskArtifactChunkRetrievalValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.post("/v0/tasks/{task_id}/artifact-chunks/semantic-retrieval") +def retrieve_semantic_task_artifact_chunks( + task_id: UUID, + request: RetrieveSemanticArtifactChunksRequest, +) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, request.user_id) as conn: + payload = retrieve_task_scoped_semantic_artifact_chunk_records( + ContinuityStore(conn), + user_id=request.user_id, + request=TaskScopedSemanticArtifactChunkRetrievalInput( + task_id=task_id, + embedding_config_id=request.embedding_config_id, + query_vector=tuple(request.query_vector), + limit=request.limit, + ), + ) + except TaskNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + except SemanticArtifactChunkRetrievalValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.post("/v0/task-artifacts/{task_artifact_id}/chunks/semantic-retrieval") +def retrieve_semantic_artifact_chunks_for_artifact( + task_artifact_id: UUID, + request: RetrieveSemanticArtifactChunksRequest, +) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, request.user_id) as conn: + payload = retrieve_artifact_scoped_semantic_artifact_chunk_records( + ContinuityStore(conn), + user_id=request.user_id, + request=ArtifactScopedSemanticArtifactChunkRetrievalInput( + task_artifact_id=task_artifact_id, + embedding_config_id=request.embedding_config_id, + query_vector=tuple(request.query_vector), + limit=request.limit, + ), + ) + except TaskArtifactNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + except SemanticArtifactChunkRetrievalValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.post("/v0/tasks/{task_id}/steps") +def create_next_task_step(task_id: UUID, request: CreateNextTaskStepRequest) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, request.user_id) as conn: + payload = create_next_task_step_record( + ContinuityStore(conn), + user_id=request.user_id, + request=TaskStepNextCreateInput( + task_id=task_id, + kind=request.kind, + status=request.status, + request=request.request.model_dump(mode="json"), + outcome=request.outcome.model_dump(mode="json"), + lineage=TaskStepLineageInput( + parent_step_id=request.lineage.parent_step_id, + source_approval_id=request.lineage.source_approval_id, + source_execution_id=request.lineage.source_execution_id, + ), + ), + ) + except TaskNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + except TaskStepSequenceError as exc: + return JSONResponse(status_code=409, content={"detail": str(exc)}) + + return JSONResponse( + status_code=201, + content=jsonable_encoder(payload), + ) + + +@app.post("/v0/task-steps/{task_step_id}/transition") +def transition_task_step(task_step_id: UUID, request: TransitionTaskStepRequest) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, request.user_id) as conn: + payload = transition_task_step_record( + ContinuityStore(conn), + user_id=request.user_id, + request=TaskStepTransitionInput( + task_step_id=task_step_id, + status=request.status, + outcome=request.outcome.model_dump(mode="json"), + ), + ) + except TaskStepNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + except TaskStepTransitionError as exc: + return JSONResponse(status_code=409, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.post("/v0/execution-budgets") +def create_execution_budget(request: CreateExecutionBudgetRequest) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, request.user_id) as conn: + payload = create_execution_budget_record( + ContinuityStore(conn), + user_id=request.user_id, + request=ExecutionBudgetCreateInput( + agent_profile_id=request.agent_profile_id, + tool_key=request.tool_key, + domain_hint=request.domain_hint, + max_completed_executions=request.max_completed_executions, + rolling_window_seconds=request.rolling_window_seconds, + ), + ) + except ExecutionBudgetValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse( + status_code=201, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/execution-budgets") +def list_execution_budgets(user_id: UUID) -> JSONResponse: + settings = get_settings() + + with user_connection(settings.database_url, user_id) as conn: + payload = list_execution_budget_records( + ContinuityStore(conn), + user_id=user_id, + ) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/execution-budgets/{execution_budget_id}") +def get_execution_budget(execution_budget_id: UUID, user_id: UUID) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, user_id) as conn: + payload = get_execution_budget_record( + ContinuityStore(conn), + user_id=user_id, + execution_budget_id=execution_budget_id, + ) + except ExecutionBudgetNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.post("/v0/execution-budgets/{execution_budget_id}/deactivate") +def deactivate_execution_budget( + execution_budget_id: UUID, + request: DeactivateExecutionBudgetRequest, +) -> JSONResponse: + settings = get_settings() + lifecycle_error: ExecutionBudgetLifecycleError | None = None + + try: + with user_connection(settings.database_url, request.user_id) as conn: + try: + payload = deactivate_execution_budget_record( + ContinuityStore(conn), + user_id=request.user_id, + request=ExecutionBudgetDeactivateInput( + thread_id=request.thread_id, + execution_budget_id=execution_budget_id, + ), + ) + except ExecutionBudgetLifecycleError as exc: + lifecycle_error = exc + payload = None + except ExecutionBudgetValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + except ExecutionBudgetNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + if lifecycle_error is not None: + return JSONResponse(status_code=409, content={"detail": str(lifecycle_error)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.post("/v0/execution-budgets/{execution_budget_id}/supersede") +def supersede_execution_budget( + execution_budget_id: UUID, + request: SupersedeExecutionBudgetRequest, +) -> JSONResponse: + settings = get_settings() + lifecycle_error: ExecutionBudgetLifecycleError | None = None + + try: + with user_connection(settings.database_url, request.user_id) as conn: + try: + payload = supersede_execution_budget_record( + ContinuityStore(conn), + user_id=request.user_id, + request=ExecutionBudgetSupersedeInput( + thread_id=request.thread_id, + execution_budget_id=execution_budget_id, + max_completed_executions=request.max_completed_executions, + ), + ) + except ExecutionBudgetLifecycleError as exc: + lifecycle_error = exc + payload = None + except ExecutionBudgetValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + except ExecutionBudgetNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + if lifecycle_error is not None: + return JSONResponse(status_code=409, content={"detail": str(lifecycle_error)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/tool-executions") +def list_tool_executions(user_id: UUID) -> JSONResponse: + settings = get_settings() + + with user_connection(settings.database_url, user_id) as conn: + payload = list_tool_execution_records( + ContinuityStore(conn), + user_id=user_id, + ) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/tool-executions/{execution_id}") +def get_tool_execution(execution_id: UUID, user_id: UUID) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, user_id) as conn: + payload = get_tool_execution_record( + ContinuityStore(conn), + user_id=user_id, + execution_id=execution_id, + ) + except ToolExecutionNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/tools/{tool_id}") +def get_tool(tool_id: UUID, user_id: UUID) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, user_id) as conn: + payload = get_tool_record( + ContinuityStore(conn), + user_id=user_id, + tool_id=tool_id, + ) + except ToolNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.post("/v0/memories/extract-explicit-preferences") +def extract_explicit_preferences(request: ExtractExplicitPreferencesRequest) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, request.user_id) as conn: + payload = extract_and_admit_explicit_preferences( + ContinuityStore(conn), + user_id=request.user_id, + request=ExplicitPreferenceExtractionRequestInput( + source_event_id=request.source_event_id, + ), + ) + except ExplicitPreferenceExtractionValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + except MemoryAdmissionValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.post("/v0/open-loops/extract-explicit-commitments") +def extract_explicit_commitments(request: ExtractExplicitCommitmentsRequest) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, request.user_id) as conn: + payload = extract_and_admit_explicit_commitments( + ContinuityStore(conn), + user_id=request.user_id, + request=ExplicitCommitmentExtractionRequestInput( + source_event_id=request.source_event_id, + ), + ) + except ExplicitCommitmentExtractionValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + except MemoryAdmissionValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.post("/v0/memories/capture-explicit-signals") +def capture_explicit_signals(request: CaptureExplicitSignalsRequest) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, request.user_id) as conn: + payload = extract_and_admit_explicit_signals( + ContinuityStore(conn), + user_id=request.user_id, + request=ExplicitSignalCaptureRequestInput( + source_event_id=request.source_event_id, + ), + ) + except ExplicitSignalCaptureValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + except MemoryAdmissionValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.post("/v0/continuity/captures") +def create_continuity_capture(request: ContinuityCaptureRequest) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, request.user_id) as conn: + payload = capture_continuity_input( + ContinuityStore(conn), + user_id=request.user_id, + request=ContinuityCaptureCreateInput( + raw_content=request.raw_content, + explicit_signal=request.explicit_signal, + ), + ) + except (ContinuityCaptureValidationError, ContinuityObjectValidationError) as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse( + status_code=201, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/continuity/captures") +def list_continuity_captures( + user_id: UUID, + limit: int = Query(default=DEFAULT_CONTINUITY_CAPTURE_LIMIT, ge=1, le=MAX_CONTINUITY_CAPTURE_LIMIT), +) -> JSONResponse: + settings = get_settings() + + with user_connection(settings.database_url, user_id) as conn: + payload = list_continuity_capture_inbox( + ContinuityStore(conn), + user_id=user_id, + limit=limit, + ) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/continuity/captures/{capture_event_id}") +def get_continuity_capture(capture_event_id: UUID, user_id: UUID) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, user_id) as conn: + payload = get_continuity_capture_detail( + ContinuityStore(conn), + user_id=user_id, + capture_event_id=capture_event_id, + ) + except ContinuityCaptureNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/admin/debug/continuity/lifecycle") +def list_continuity_lifecycle_endpoint( + user_id: UUID, + limit: int = Query( + default=DEFAULT_CONTINUITY_LIFECYCLE_LIMIT, + ge=1, + le=MAX_CONTINUITY_LIFECYCLE_LIMIT, + ), +) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, user_id) as conn: + payload: ContinuityLifecycleListResponse = list_continuity_lifecycle_state( + ContinuityStore(conn), + user_id=user_id, + request=ContinuityLifecycleQueryInput(limit=limit), + ) + except ContinuityLifecycleValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/admin/debug/continuity/lifecycle/{continuity_object_id}") +def get_continuity_lifecycle_endpoint( + continuity_object_id: UUID, + user_id: UUID, +) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, user_id) as conn: + payload: ContinuityLifecycleDetailResponse = get_continuity_lifecycle_state( + ContinuityStore(conn), + user_id=user_id, + continuity_object_id=continuity_object_id, + ) + except ContinuityLifecycleNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/continuity/review-queue") +def list_continuity_review_queue_endpoint( + user_id: UUID, + status: str = Query(default="correction_ready", min_length=1, max_length=40), + limit: int = Query( + default=DEFAULT_CONTINUITY_REVIEW_LIMIT, + ge=1, + le=MAX_CONTINUITY_REVIEW_LIMIT, + ), +) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, user_id) as conn: + payload: ContinuityReviewQueueResponse = list_continuity_review_queue( + ContinuityStore(conn), + user_id=user_id, + request=ContinuityReviewQueueQueryInput( + status=status, # type: ignore[arg-type] + limit=limit, + ), + ) + except ContinuityReviewValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/continuity/review-queue/{continuity_object_id}") +def get_continuity_review_detail_endpoint( + continuity_object_id: UUID, + user_id: UUID, +) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, user_id) as conn: + payload: ContinuityReviewDetailResponse = get_continuity_review_detail( + ContinuityStore(conn), + user_id=user_id, + continuity_object_id=continuity_object_id, + ) + except ContinuityReviewNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.post("/v0/continuity/review-queue/{continuity_object_id}/corrections") +def apply_continuity_correction_endpoint( + continuity_object_id: UUID, + request: ContinuityCorrectionRequest, +) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, request.user_id) as conn: + payload = apply_continuity_correction( + ContinuityStore(conn), + user_id=request.user_id, + continuity_object_id=continuity_object_id, + request=ContinuityCorrectionInput( + action=request.action, # type: ignore[arg-type] + reason=request.reason, + title=request.title, + body=request.body, # type: ignore[arg-type] + provenance=request.provenance, # type: ignore[arg-type] + confidence=request.confidence, + replacement_title=request.replacement_title, + replacement_body=request.replacement_body, # type: ignore[arg-type] + replacement_provenance=request.replacement_provenance, # type: ignore[arg-type] + replacement_confidence=request.replacement_confidence, + ), + ) + except ContinuityReviewValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + except ContinuityReviewNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/continuity/open-loops") +def get_continuity_open_loop_dashboard( + user_id: UUID, + query_text: str | None = Query(default=None, alias="query", min_length=1, max_length=4000), + thread_id: UUID | None = None, + task_id: UUID | None = None, + project: str | None = Query(default=None, min_length=1, max_length=200), + person: str | None = Query(default=None, min_length=1, max_length=200), + since: datetime | None = None, + until: datetime | None = None, + limit: int = Query( + default=DEFAULT_CONTINUITY_OPEN_LOOP_LIMIT, + ge=0, + le=MAX_CONTINUITY_OPEN_LOOP_LIMIT, + ), +) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, user_id) as conn: + payload: ContinuityOpenLoopDashboardResponse = compile_continuity_open_loop_dashboard( + ContinuityStore(conn), + user_id=user_id, + request=ContinuityOpenLoopDashboardQueryInput( + query=query_text, + thread_id=thread_id, + task_id=task_id, + project=project, + person=person, + since=since, + until=until, + limit=limit, + ), + ) + except ContinuityOpenLoopValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + except ContinuityRecallValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/continuity/daily-brief") +def get_continuity_daily_brief( + user_id: UUID, + query_text: str | None = Query(default=None, alias="query", min_length=1, max_length=4000), + thread_id: UUID | None = None, + task_id: UUID | None = None, + project: str | None = Query(default=None, min_length=1, max_length=200), + person: str | None = Query(default=None, min_length=1, max_length=200), + since: datetime | None = None, + until: datetime | None = None, + limit: int = Query( + default=DEFAULT_CONTINUITY_DAILY_BRIEF_LIMIT, + ge=0, + le=MAX_CONTINUITY_DAILY_BRIEF_LIMIT, + ), +) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, user_id) as conn: + payload: ContinuityDailyBriefResponse = compile_continuity_daily_brief( + ContinuityStore(conn), + user_id=user_id, + request=ContinuityDailyBriefRequestInput( + query=query_text, + thread_id=thread_id, + task_id=task_id, + project=project, + person=person, + since=since, + until=until, + limit=limit, + ), + ) + except ContinuityOpenLoopValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + except ContinuityRecallValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/continuity/weekly-review") +def get_continuity_weekly_review( + user_id: UUID, + query_text: str | None = Query(default=None, alias="query", min_length=1, max_length=4000), + thread_id: UUID | None = None, + task_id: UUID | None = None, + project: str | None = Query(default=None, min_length=1, max_length=200), + person: str | None = Query(default=None, min_length=1, max_length=200), + since: datetime | None = None, + until: datetime | None = None, + limit: int = Query( + default=DEFAULT_CONTINUITY_WEEKLY_REVIEW_LIMIT, + ge=0, + le=MAX_CONTINUITY_WEEKLY_REVIEW_LIMIT, + ), +) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, user_id) as conn: + payload: ContinuityWeeklyReviewResponse = compile_continuity_weekly_review( + ContinuityStore(conn), + user_id=user_id, + request=ContinuityWeeklyReviewRequestInput( + query=query_text, + thread_id=thread_id, + task_id=task_id, + project=project, + person=person, + since=since, + until=until, + limit=limit, + ), + ) + except ContinuityOpenLoopValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + except ContinuityRecallValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.post("/v0/continuity/open-loops/{continuity_object_id}/review-action") +def apply_continuity_open_loop_review_action_endpoint( + continuity_object_id: UUID, + request: ContinuityOpenLoopReviewActionRequest, +) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, request.user_id) as conn: + payload: ContinuityOpenLoopReviewActionResponse = apply_continuity_open_loop_review_action( + ContinuityStore(conn), + user_id=request.user_id, + continuity_object_id=continuity_object_id, + request=ContinuityOpenLoopReviewActionInput( + action=request.action, # type: ignore[arg-type] + note=request.note, + ), + ) + except ContinuityOpenLoopValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + except ContinuityOpenLoopNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/continuity/recall") +def list_continuity_recall( + user_id: UUID, + query_text: str | None = Query(default=None, alias="query", min_length=1, max_length=4000), + thread_id: UUID | None = None, + task_id: UUID | None = None, + project: str | None = Query(default=None, min_length=1, max_length=200), + person: str | None = Query(default=None, min_length=1, max_length=200), + since: datetime | None = None, + until: datetime | None = None, + limit: int = Query( + default=DEFAULT_CONTINUITY_RECALL_LIMIT, + ge=1, + le=MAX_CONTINUITY_RECALL_LIMIT, + ), +) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, user_id) as conn: + payload: ContinuityRecallResponse = query_continuity_recall( + ContinuityStore(conn), + user_id=user_id, + request=ContinuityRecallQueryInput( + query=query_text, + thread_id=thread_id, + task_id=task_id, + project=project, + person=person, + since=since, + until=until, + limit=limit, + ), + ) + except ContinuityRecallValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/continuity/retrieval-evaluation") +def get_continuity_retrieval_evaluation(user_id: UUID) -> JSONResponse: + settings = get_settings() + + with user_connection(settings.database_url, user_id) as conn: + payload: RetrievalEvaluationResponse = get_retrieval_evaluation_summary( + ContinuityStore(conn), + user_id=user_id, + ) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/continuity/resumption-brief") +def get_continuity_resumption_brief( + user_id: UUID, + query_text: str | None = Query(default=None, alias="query", min_length=1, max_length=4000), + thread_id: UUID | None = None, + task_id: UUID | None = None, + project: str | None = Query(default=None, min_length=1, max_length=200), + person: str | None = Query(default=None, min_length=1, max_length=200), + since: datetime | None = None, + until: datetime | None = None, + max_recent_changes: int = Query( + default=DEFAULT_CONTINUITY_RESUMPTION_RECENT_CHANGES_LIMIT, + ge=0, + le=MAX_CONTINUITY_RESUMPTION_RECENT_CHANGES_LIMIT, + ), + max_open_loops: int = Query( + default=DEFAULT_CONTINUITY_RESUMPTION_OPEN_LOOP_LIMIT, + ge=0, + le=MAX_CONTINUITY_RESUMPTION_OPEN_LOOP_LIMIT, + ), + include_non_promotable_facts: bool = False, +) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, user_id) as conn: + payload: ContinuityResumptionBriefResponse = compile_continuity_resumption_brief( + ContinuityStore(conn), + user_id=user_id, + request=ContinuityResumptionBriefRequestInput( + query=query_text, + thread_id=thread_id, + task_id=task_id, + project=project, + person=person, + since=since, + until=until, + max_recent_changes=max_recent_changes, + max_open_loops=max_open_loops, + include_non_promotable_facts=include_non_promotable_facts, + ), + ) + except ContinuityResumptionValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + except ContinuityRecallValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/chief-of-staff") +def get_chief_of_staff_priority_brief( + user_id: UUID, + query_text: str | None = Query(default=None, alias="query", min_length=1, max_length=4000), + thread_id: UUID | None = None, + task_id: UUID | None = None, + project: str | None = Query(default=None, min_length=1, max_length=200), + person: str | None = Query(default=None, min_length=1, max_length=200), + since: datetime | None = None, + until: datetime | None = None, + limit: int = Query( + default=DEFAULT_CHIEF_OF_STAFF_PRIORITY_LIMIT, + ge=0, + le=MAX_CHIEF_OF_STAFF_PRIORITY_LIMIT, + ), +) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, user_id) as conn: + payload: ChiefOfStaffPriorityBriefResponse = compile_chief_of_staff_priority_brief( + ContinuityStore(conn), + user_id=user_id, + request=ChiefOfStaffPriorityBriefRequestInput( + query=query_text, + thread_id=thread_id, + task_id=task_id, + project=project, + person=person, + since=since, + until=until, + limit=limit, + ), + ) + except ChiefOfStaffValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + except ContinuityRecallValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.post("/v0/chief-of-staff/recommendation-outcomes") +def capture_chief_of_staff_recommendation_outcome_endpoint( + request: ChiefOfStaffRecommendationOutcomeCaptureRequest, +) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, request.user_id) as conn: + payload: ChiefOfStaffRecommendationOutcomeCaptureResponse = ( + capture_chief_of_staff_recommendation_outcome( + ContinuityStore(conn), + user_id=request.user_id, + request=ChiefOfStaffRecommendationOutcomeCaptureInput( + outcome=request.outcome, # type: ignore[arg-type] + recommendation_action_type=request.recommendation_action_type, # type: ignore[arg-type] + recommendation_title=request.recommendation_title, + rationale=request.rationale, + rewritten_title=request.rewritten_title, + target_priority_id=request.target_priority_id, + thread_id=request.thread_id, + task_id=request.task_id, + project=request.project, + person=request.person, + ), + ) + ) + except ChiefOfStaffValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.post("/v0/chief-of-staff/handoff-review-actions") +def capture_chief_of_staff_handoff_review_action_endpoint( + request: ChiefOfStaffHandoffReviewActionCaptureRequest, +) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, request.user_id) as conn: + payload: ChiefOfStaffHandoffReviewActionCaptureResponse = ( + capture_chief_of_staff_handoff_review_action( + ContinuityStore(conn), + user_id=request.user_id, + request=ChiefOfStaffHandoffReviewActionInput( + handoff_item_id=request.handoff_item_id, + review_action=request.review_action, # type: ignore[arg-type] + note=request.note, + thread_id=request.thread_id, + task_id=request.task_id, + project=request.project, + person=request.person, + ), + ) + ) + except ChiefOfStaffValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.post("/v0/chief-of-staff/execution-routing-actions") +def capture_chief_of_staff_execution_routing_action_endpoint( + request: ChiefOfStaffExecutionRoutingActionCaptureRequest, +) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, request.user_id) as conn: + payload: ChiefOfStaffExecutionRoutingActionCaptureResponse = ( + capture_chief_of_staff_execution_routing_action( + ContinuityStore(conn), + user_id=request.user_id, + request=ChiefOfStaffExecutionRoutingActionInput( + handoff_item_id=request.handoff_item_id, + route_target=request.route_target, # type: ignore[arg-type] + note=request.note, + thread_id=request.thread_id, + task_id=request.task_id, + project=request.project, + person=request.person, + ), + ) + ) + except ChiefOfStaffValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.post("/v0/chief-of-staff/handoff-outcomes") +def capture_chief_of_staff_handoff_outcome_endpoint( + request: ChiefOfStaffHandoffOutcomeCaptureRequest, +) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, request.user_id) as conn: + payload: ChiefOfStaffHandoffOutcomeCaptureResponse = ( + capture_chief_of_staff_handoff_outcome( + ContinuityStore(conn), + user_id=request.user_id, + request=ChiefOfStaffHandoffOutcomeCaptureInput( + handoff_item_id=request.handoff_item_id, + outcome_status=request.outcome_status, # type: ignore[arg-type] + note=request.note, + thread_id=request.thread_id, + task_id=request.task_id, + project=request.project, + person=request.person, + ), + ) + ) + except ChiefOfStaffValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/memories") +def list_memories( + user_id: UUID, + status: MemoryReviewStatusFilter = Query(default="active"), + limit: int = Query(default=DEFAULT_MEMORY_REVIEW_LIMIT, ge=1, le=MAX_MEMORY_REVIEW_LIMIT), +) -> JSONResponse: + settings = get_settings() + + with user_connection(settings.database_url, user_id) as conn: + payload = list_memory_review_records( + ContinuityStore(conn), + user_id=user_id, + status=status, + limit=limit, + ) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/memories/review-queue") +def list_memory_review_queue( + user_id: UUID, + limit: int = Query(default=DEFAULT_MEMORY_REVIEW_LIMIT, ge=1, le=MAX_MEMORY_REVIEW_LIMIT), + priority_mode: MemoryReviewQueuePriorityMode = Query( + default=DEFAULT_MEMORY_REVIEW_QUEUE_PRIORITY_MODE + ), +) -> JSONResponse: + settings = get_settings() + + with user_connection(settings.database_url, user_id) as conn: + payload = list_memory_review_queue_records( + ContinuityStore(conn), + user_id=user_id, + limit=limit, + priority_mode=priority_mode, + ) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/memories/quality-gate") +def get_memories_quality_gate(user_id: UUID) -> JSONResponse: + settings = get_settings() + + with user_connection(settings.database_url, user_id) as conn: + payload = get_memory_quality_gate_summary( + ContinuityStore(conn), + user_id=user_id, + ) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/memories/trust-dashboard") +def get_memories_trust_dashboard(user_id: UUID) -> JSONResponse: + settings = get_settings() + + with user_connection(settings.database_url, user_id) as conn: + payload: MemoryTrustDashboardResponse = get_memory_trust_dashboard_summary( + ContinuityStore(conn), + user_id=user_id, + ) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/memories/evaluation-summary") +def get_memories_evaluation_summary(user_id: UUID) -> JSONResponse: + settings = get_settings() + + with user_connection(settings.database_url, user_id) as conn: + payload = get_memory_evaluation_summary( + ContinuityStore(conn), + user_id=user_id, + ) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.post("/v0/memories/semantic-retrieval") +def retrieve_semantic_memories(request: RetrieveSemanticMemoriesRequest) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, request.user_id) as conn: + payload = retrieve_semantic_memory_records( + ContinuityStore(conn), + user_id=request.user_id, + request=SemanticMemoryRetrievalRequestInput( + embedding_config_id=request.embedding_config_id, + query_vector=tuple(request.query_vector), + limit=request.limit, + ), + ) + except SemanticMemoryRetrievalValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/memories/{memory_id}") +def get_memory( + memory_id: UUID, + user_id: UUID, +) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, user_id) as conn: + payload = get_memory_review_record( + ContinuityStore(conn), + user_id=user_id, + memory_id=memory_id, + ) + except MemoryReviewNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/memories/{memory_id}/revisions") +def list_memory_revisions( + memory_id: UUID, + user_id: UUID, + limit: int = Query(default=DEFAULT_MEMORY_REVIEW_LIMIT, ge=1, le=MAX_MEMORY_REVIEW_LIMIT), +) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, user_id) as conn: + payload = list_memory_revision_review_records( + ContinuityStore(conn), + user_id=user_id, + memory_id=memory_id, + limit=limit, + ) + except MemoryReviewNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.post("/v0/memories/{memory_id}/labels") +def create_memory_review_label( + memory_id: UUID, + request: CreateMemoryReviewLabelRequest, +) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, request.user_id) as conn: + payload = create_memory_review_label_record( + ContinuityStore(conn), + user_id=request.user_id, + memory_id=memory_id, + label=request.label, + note=request.note, + ) + except MemoryReviewNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + return JSONResponse( + status_code=201, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/memories/{memory_id}/labels") +def list_memory_review_labels( + memory_id: UUID, + user_id: UUID, +) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, user_id) as conn: + payload = list_memory_review_label_records( + ContinuityStore(conn), + user_id=user_id, + memory_id=memory_id, + ) + except MemoryReviewNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.post("/v0/embedding-configs") +def create_embedding_config(request: CreateEmbeddingConfigRequest) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, request.user_id) as conn: + payload = create_embedding_config_record( + ContinuityStore(conn), + user_id=request.user_id, + config=EmbeddingConfigCreateInput( + provider=request.provider, + model=request.model, + version=request.version, + dimensions=request.dimensions, + status=request.status, + metadata=request.metadata, + ), + ) + except EmbeddingConfigValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse( + status_code=201, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/embedding-configs") +def list_embedding_configs(user_id: UUID) -> JSONResponse: + settings = get_settings() + + with user_connection(settings.database_url, user_id) as conn: + payload = list_embedding_config_records( + ContinuityStore(conn), + user_id=user_id, + ) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.post("/v0/memory-embeddings") +def upsert_memory_embedding(request: UpsertMemoryEmbeddingRequest) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, request.user_id) as conn: + payload = upsert_memory_embedding_record( + ContinuityStore(conn), + user_id=request.user_id, + request=MemoryEmbeddingUpsertInput( + memory_id=request.memory_id, + embedding_config_id=request.embedding_config_id, + vector=tuple(request.vector), + ), + ) + except MemoryEmbeddingValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse( + status_code=201, + content=jsonable_encoder(payload), + ) + + +@app.post("/v0/task-artifact-chunk-embeddings") +def upsert_task_artifact_chunk_embedding( + request: UpsertTaskArtifactChunkEmbeddingRequest, +) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, request.user_id) as conn: + payload = upsert_task_artifact_chunk_embedding_record( + ContinuityStore(conn), + user_id=request.user_id, + request=TaskArtifactChunkEmbeddingUpsertInput( + task_artifact_chunk_id=request.task_artifact_chunk_id, + embedding_config_id=request.embedding_config_id, + vector=tuple(request.vector), + ), + ) + except TaskArtifactChunkEmbeddingValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse( + status_code=201, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/memories/{memory_id}/embeddings") +def list_memory_embeddings(memory_id: UUID, user_id: UUID) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, user_id) as conn: + payload = list_memory_embedding_records( + ContinuityStore(conn), + user_id=user_id, + memory_id=memory_id, + ) + except MemoryEmbeddingNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/task-artifacts/{task_artifact_id}/chunk-embeddings") +def list_task_artifact_chunk_embeddings_for_artifact( + task_artifact_id: UUID, + user_id: UUID, +) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, user_id) as conn: + payload = list_task_artifact_chunk_embedding_records_for_artifact( + ContinuityStore(conn), + user_id=user_id, + task_artifact_id=task_artifact_id, + ) + except TaskArtifactNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/task-artifact-chunks/{task_artifact_chunk_id}/embeddings") +def list_task_artifact_chunk_embeddings( + task_artifact_chunk_id: UUID, + user_id: UUID, +) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, user_id) as conn: + payload = list_task_artifact_chunk_embedding_records_for_chunk( + ContinuityStore(conn), + user_id=user_id, + task_artifact_chunk_id=task_artifact_chunk_id, + ) + except TaskArtifactChunkEmbeddingNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/memory-embeddings/{memory_embedding_id}") +def get_memory_embedding(memory_embedding_id: UUID, user_id: UUID) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, user_id) as conn: + payload = get_memory_embedding_record( + ContinuityStore(conn), + user_id=user_id, + memory_embedding_id=memory_embedding_id, + ) + except MemoryEmbeddingNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/task-artifact-chunk-embeddings/{task_artifact_chunk_embedding_id}") +def get_task_artifact_chunk_embedding( + task_artifact_chunk_embedding_id: UUID, + user_id: UUID, +) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, user_id) as conn: + payload = get_task_artifact_chunk_embedding_record( + ContinuityStore(conn), + user_id=user_id, + task_artifact_chunk_embedding_id=task_artifact_chunk_embedding_id, + ) + except TaskArtifactChunkEmbeddingNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.post("/v0/entities") +def create_entity(request: CreateEntityRequest) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, request.user_id) as conn: + payload = create_entity_record( + ContinuityStore(conn), + user_id=request.user_id, + entity=EntityCreateInput( + entity_type=request.entity_type, + name=request.name, + source_memory_ids=tuple(request.source_memory_ids), + ), + ) + except EntityValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse( + status_code=201, + content=jsonable_encoder(payload), + ) + + +@app.post("/v0/entity-edges") +def create_entity_edge(request: CreateEntityEdgeRequest) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, request.user_id) as conn: + payload = create_entity_edge_record( + ContinuityStore(conn), + user_id=request.user_id, + edge=EntityEdgeCreateInput( + from_entity_id=request.from_entity_id, + to_entity_id=request.to_entity_id, + relationship_type=request.relationship_type, + valid_from=request.valid_from, + valid_to=request.valid_to, + source_memory_ids=tuple(request.source_memory_ids), + ), + ) + except EntityEdgeValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse( + status_code=201, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/entities") +def list_entities(user_id: UUID) -> JSONResponse: + settings = get_settings() + + with user_connection(settings.database_url, user_id) as conn: + payload = list_entity_records( + ContinuityStore(conn), + user_id=user_id, + ) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/entities/{entity_id}/edges") +def list_entity_edges(entity_id: UUID, user_id: UUID) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, user_id) as conn: + payload = list_entity_edge_records( + ContinuityStore(conn), + user_id=user_id, + entity_id=entity_id, + ) + except EntityNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.get("/v0/entities/{entity_id}") +def get_entity(entity_id: UUID, user_id: UUID) -> JSONResponse: + settings = get_settings() + + try: + with user_connection(settings.database_url, user_id) as conn: + payload = get_entity_record( + ContinuityStore(conn), + user_id=user_id, + entity_id=entity_id, + ) + except EntityNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder(payload), + ) + + +@app.post("/v1/auth/magic-link/start") +def start_v1_magic_link(http_request: Request, request: MagicLinkStartRequest) -> JSONResponse: + settings = get_settings() + email_fingerprint = hashlib.sha256(request.email.strip().lower().encode("utf-8")).hexdigest()[:20] + rate_limit_error = _enforce_entrypoint_rate_limit( + settings=settings, + key=( + "auth_magic_link_start:" + f"{_request_client_identifier(http_request, settings)}:{email_fingerprint}" + ), + max_requests=settings.magic_link_start_rate_limit_max_requests, + window_seconds=settings.magic_link_start_rate_limit_window_seconds, + detail_code="magic_link_start_rate_limit_exceeded", + message="magic-link start rate limit exceeded", + ) + if rate_limit_error is not None: + return rate_limit_error + + try: + with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: + with conn.transaction(): + challenge = start_magic_link_challenge( + conn, + email=request.email, + ttl_seconds=settings.magic_link_ttl_seconds, + ) + except ValueError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + challenge_payload = serialize_magic_link_challenge(challenge) + delivery_payload = { + "kind": "simulated_magic_link", + "posture": "builder_visible_only", + } + if settings.app_env not in {"development", "test"}: + challenge_payload.pop("challenge_token", None) + delivery_payload = { + "kind": "magic_link", + "posture": "out_of_band_delivery_required", + } + + payload = { + "challenge": challenge_payload, + "delivery": delivery_payload, + } + return JSONResponse(status_code=200, content=jsonable_encoder(payload)) + + +@app.post("/v1/auth/magic-link/verify") +def verify_v1_magic_link(http_request: Request, request: MagicLinkVerifyRequest) -> JSONResponse: + settings = get_settings() + challenge_fingerprint = hashlib.sha256(request.challenge_token.strip().encode("utf-8")).hexdigest()[:20] + rate_limit_error = _enforce_entrypoint_rate_limit( + settings=settings, + key=( + "auth_magic_link_verify:" + f"{_request_client_identifier(http_request, settings)}:{challenge_fingerprint}" + ), + max_requests=settings.magic_link_verify_rate_limit_max_requests, + window_seconds=settings.magic_link_verify_rate_limit_window_seconds, + detail_code="magic_link_verify_rate_limit_exceeded", + message="magic-link verify rate limit exceeded", + ) + if rate_limit_error is not None: + return rate_limit_error + + try: + with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: + with conn.transaction(): + user_account, session, session_token, _device = verify_magic_link_challenge( + conn, + challenge_token=request.challenge_token, + session_ttl_seconds=settings.auth_session_ttl_seconds, + device_label=request.device_label, + device_key=request.device_key, + ) + ensure_user_preferences_row(conn, user_account_id=user_account["id"]) + preferences = ensure_user_preferences(conn, user_account_id=user_account["id"]) + workspace = None + if session["workspace_id"] is not None: + workspace = get_workspace_for_member( + conn, + workspace_id=session["workspace_id"], + user_account_id=user_account["id"], + ) + feature_flags = list_feature_flags_for_user(conn, user_account_id=user_account["id"]) + except MagicLinkTokenExpiredError as exc: + return JSONResponse(status_code=401, content={"detail": str(exc)}) + except (MagicLinkTokenInvalidError, ValueError) as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + payload = _serialize_hosted_session_payload( + session=serialize_auth_session(session), + user_account=serialize_user_account(user_account), + workspace=None if workspace is None else serialize_workspace(workspace), + preferences=serialize_user_preferences(preferences), + feature_flags=feature_flags, + ) + payload["session_token"] = session_token + return JSONResponse(status_code=200, content=jsonable_encoder(payload)) + + +@app.post("/v1/auth/logout") +def logout_v1_auth_session(request: Request) -> JSONResponse: + settings = get_settings() + + try: + session_token = _extract_bearer_token(request) + with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: + with conn.transaction(): + logout_auth_session(conn, session_token=session_token) + except (AuthSessionInvalidError, ValueError) as exc: + return JSONResponse(status_code=401, content={"detail": str(exc)}) + + return JSONResponse(status_code=200, content={"status": "logged_out"}) + + +@app.get("/v1/auth/session") +def get_v1_auth_session(request: Request) -> JSONResponse: + settings = get_settings() + + try: + session_token = _extract_bearer_token(request) + with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: + with conn.transaction(): + resolution = resolve_auth_session(conn, session_token=session_token) + user_account_id = resolution["user_account"]["id"] + workspace = get_current_workspace( + conn, + user_account_id=user_account_id, + preferred_workspace_id=resolution["session"]["workspace_id"], + ) + if workspace is not None and resolution["session"]["workspace_id"] != workspace["id"]: + set_session_workspace( + conn, + session_id=resolution["session"]["id"], + user_account_id=user_account_id, + workspace_id=workspace["id"], + ) + resolution["session"]["workspace_id"] = workspace["id"] + preferences = ensure_user_preferences(conn, user_account_id=user_account_id) + feature_flags = list_feature_flags_for_user(conn, user_account_id=user_account_id) + except (AuthSessionInvalidError, AuthSessionExpiredError, AuthSessionRevokedDeviceError) as exc: + return JSONResponse(status_code=401, content={"detail": str(exc)}) + + payload = _serialize_hosted_session_payload( + session=serialize_auth_session(resolution["session"]), + user_account=serialize_user_account(resolution["user_account"]), + workspace=None if workspace is None else serialize_workspace(workspace), + preferences=serialize_user_preferences(preferences), + feature_flags=feature_flags, + ) + return JSONResponse(status_code=200, content=jsonable_encoder(payload)) + + +@app.post("/v1/workspaces") +def create_v1_workspace(request: Request, body: HostedWorkspaceCreateRequest) -> JSONResponse: + settings = get_settings() + + try: + session_token = _extract_bearer_token(request) + with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: + with conn.transaction(): + resolution = resolve_auth_session(conn, session_token=session_token) + workspace = create_workspace( + conn, + user_account_id=resolution["user_account"]["id"], + name=body.name, + slug=body.slug, + ) + set_session_workspace( + conn, + session_id=resolution["session"]["id"], + user_account_id=resolution["user_account"]["id"], + workspace_id=workspace["id"], + ) + except (AuthSessionInvalidError, AuthSessionExpiredError, AuthSessionRevokedDeviceError) as exc: + return JSONResponse(status_code=401, content={"detail": str(exc)}) + except ValueError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse( + status_code=201, + content=jsonable_encoder({"workspace": serialize_workspace(workspace)}), + ) + + +@app.get("/v1/workspaces/current") +def get_v1_current_workspace(request: Request) -> JSONResponse: + settings = get_settings() + + try: + session_token = _extract_bearer_token(request) + with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: + with conn.transaction(): + resolution = resolve_auth_session(conn, session_token=session_token) + workspace = get_current_workspace( + conn, + user_account_id=resolution["user_account"]["id"], + preferred_workspace_id=resolution["session"]["workspace_id"], + ) + if workspace is None: + return JSONResponse(status_code=404, content={"detail": "no workspace is currently selected"}) + if resolution["session"]["workspace_id"] != workspace["id"]: + set_session_workspace( + conn, + session_id=resolution["session"]["id"], + user_account_id=resolution["user_account"]["id"], + workspace_id=workspace["id"], + ) + except (AuthSessionInvalidError, AuthSessionExpiredError, AuthSessionRevokedDeviceError) as exc: + return JSONResponse(status_code=401, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder({"workspace": serialize_workspace(workspace)}), + ) + + +@app.post("/v1/workspaces/bootstrap") +def bootstrap_v1_workspace( + request: Request, + body: HostedWorkspaceBootstrapRequest, +) -> JSONResponse: + settings = get_settings() + resolved_workspace_id: UUID | None = None + + try: + session_token = _extract_bearer_token(request) + with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: + with conn.transaction(): + resolution = resolve_auth_session(conn, session_token=session_token) + user_account_id = resolution["user_account"]["id"] + workspace = None + if body.workspace_id is not None: + workspace = get_workspace_for_member( + conn, + workspace_id=body.workspace_id, + user_account_id=user_account_id, + ) + if workspace is None: + raise HostedWorkspaceNotFoundError(f"workspace {body.workspace_id} was not found") + resolved_workspace_id = workspace["id"] + set_session_workspace( + conn, + session_id=resolution["session"]["id"], + user_account_id=user_account_id, + workspace_id=workspace["id"], + ) + else: + workspace = get_current_workspace( + conn, + user_account_id=user_account_id, + preferred_workspace_id=resolution["session"]["workspace_id"], + ) + if workspace is not None: + resolved_workspace_id = workspace["id"] + if workspace is None: + return JSONResponse(status_code=404, content={"detail": "no workspace is currently selected"}) + + bootstrapped_workspace = complete_workspace_bootstrap( + conn, + workspace_id=workspace["id"], + user_account_id=user_account_id, + ) + preferences = ensure_user_preferences(conn, user_account_id=user_account_id) + status_payload = get_bootstrap_status( + conn, + workspace_id=workspace["id"], + user_account_id=user_account_id, + ) + feature_flags = list_feature_flags_for_user(conn, user_account_id=user_account_id) + except (AuthSessionInvalidError, AuthSessionExpiredError, AuthSessionRevokedDeviceError) as exc: + return JSONResponse(status_code=401, content={"detail": str(exc)}) + except HostedWorkspaceNotFoundError as exc: + if resolved_workspace_id is not None: + with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: + with conn.transaction(): + _record_workspace_onboarding_failure( + conn, + workspace_id=resolved_workspace_id, + error_code="workspace_not_found", + error_detail=str(exc), + ) + return JSONResponse(status_code=404, content={"detail": str(exc)}) + except HostedWorkspaceBootstrapConflictError as exc: + if resolved_workspace_id is not None: + with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: + with conn.transaction(): + _record_workspace_onboarding_failure( + conn, + workspace_id=resolved_workspace_id, + error_code="bootstrap_conflict", + error_detail=str(exc), + ) + return JSONResponse(status_code=409, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder( + { + "workspace": serialize_workspace(bootstrapped_workspace), + "bootstrap": status_payload, + "preferences": serialize_user_preferences(preferences), + "feature_flags": feature_flags, + "telegram_state": "available_in_p10_s2_transport", + } + ), + ) + + +@app.get("/v1/workspaces/bootstrap/status") +def get_v1_workspace_bootstrap_status(request: Request) -> JSONResponse: + settings = get_settings() + + try: + session_token = _extract_bearer_token(request) + with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: + with conn.transaction(): + resolution = resolve_auth_session(conn, session_token=session_token) + workspace = get_current_workspace( + conn, + user_account_id=resolution["user_account"]["id"], + preferred_workspace_id=resolution["session"]["workspace_id"], + ) + if workspace is None: + return JSONResponse(status_code=404, content={"detail": "no workspace is currently selected"}) + status_payload = get_bootstrap_status( + conn, + workspace_id=workspace["id"], + user_account_id=resolution["user_account"]["id"], + ) + feature_flags = list_feature_flags_for_user( + conn, + user_account_id=resolution["user_account"]["id"], + ) + except (AuthSessionInvalidError, AuthSessionExpiredError, AuthSessionRevokedDeviceError) as exc: + return JSONResponse(status_code=401, content={"detail": str(exc)}) + except HostedWorkspaceNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder( + { + "workspace": serialize_workspace(workspace), + "bootstrap": status_payload, + "feature_flags": feature_flags, + "telegram_state": "available_in_p10_s2_transport", + } + ), + ) + + +@app.post("/v1/devices/link/start") +def start_v1_device_link(request: Request, body: DeviceLinkStartRequest) -> JSONResponse: + settings = get_settings() + + try: + session_token = _extract_bearer_token(request) + with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: + with conn.transaction(): + resolution = resolve_auth_session(conn, session_token=session_token) + user_account_id = resolution["user_account"]["id"] + workspace_id = body.workspace_id or resolution["session"]["workspace_id"] + if body.workspace_id is not None: + workspace = get_workspace_for_member( + conn, + workspace_id=body.workspace_id, + user_account_id=user_account_id, + ) + if workspace is None: + raise HostedWorkspaceNotFoundError(f"workspace {body.workspace_id} was not found") + workspace_id = workspace["id"] + challenge = start_device_link_challenge( + conn, + user_account_id=user_account_id, + workspace_id=workspace_id, + device_key=body.device_key, + device_label=body.device_label, + ttl_seconds=settings.device_link_ttl_seconds, + ) + except (AuthSessionInvalidError, AuthSessionExpiredError, AuthSessionRevokedDeviceError) as exc: + return JSONResponse(status_code=401, content={"detail": str(exc)}) + except HostedWorkspaceNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + except ValueError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder({"challenge": serialize_device_link_challenge(challenge)}), + ) + + +@app.post("/v1/devices/link/confirm") +def confirm_v1_device_link(request: Request, body: DeviceLinkConfirmRequest) -> JSONResponse: + settings = get_settings() + + try: + session_token = _extract_bearer_token(request) + with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: + with conn.transaction(): + resolution = resolve_auth_session(conn, session_token=session_token) + device = confirm_device_link_challenge( + conn, + user_account_id=resolution["user_account"]["id"], + challenge_token=body.challenge_token, + ) + except (AuthSessionInvalidError, AuthSessionExpiredError, AuthSessionRevokedDeviceError) as exc: + return JSONResponse(status_code=401, content={"detail": str(exc)}) + except DeviceLinkTokenExpiredError as exc: + return JSONResponse(status_code=401, content={"detail": str(exc)}) + except DeviceLinkTokenInvalidError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse( + status_code=201, + content=jsonable_encoder({"device": serialize_device(device)}), + ) + + +@app.get("/v1/devices") +def list_v1_devices(request: Request) -> JSONResponse: + settings = get_settings() + + try: + session_token = _extract_bearer_token(request) + with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: + with conn.transaction(): + resolution = resolve_auth_session(conn, session_token=session_token) + devices = list_hosted_devices( + conn, + user_account_id=resolution["user_account"]["id"], + workspace_id=resolution["session"]["workspace_id"], + ) + except (AuthSessionInvalidError, AuthSessionExpiredError, AuthSessionRevokedDeviceError) as exc: + return JSONResponse(status_code=401, content={"detail": str(exc)}) + + items = [serialize_device(device) for device in devices] + return JSONResponse( + status_code=200, + content=jsonable_encoder( + { + "items": items, + "summary": { + "total_count": len(items), + "active_count": sum(1 for item in items if item["status"] == "active"), + "revoked_count": sum(1 for item in items if item["status"] == "revoked"), + "order": ["created_at_desc", "id_desc"], + }, + } + ), + ) + + +@app.delete("/v1/devices/{device_id}") +def delete_v1_device(device_id: UUID, request: Request) -> JSONResponse: + settings = get_settings() + + try: + session_token = _extract_bearer_token(request) + with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: + with conn.transaction(): + resolution = resolve_auth_session(conn, session_token=session_token) + device = revoke_hosted_device( + conn, + user_account_id=resolution["user_account"]["id"], + device_id=device_id, + ) + except (AuthSessionInvalidError, AuthSessionExpiredError, AuthSessionRevokedDeviceError) as exc: + return JSONResponse(status_code=401, content={"detail": str(exc)}) + except HostedDeviceNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder({"device": serialize_device(device)}), + ) + + +@app.get("/v1/preferences") +def get_v1_preferences(request: Request) -> JSONResponse: + settings = get_settings() + + try: + session_token = _extract_bearer_token(request) + with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: + with conn.transaction(): + resolution = resolve_auth_session(conn, session_token=session_token) + preferences = ensure_user_preferences( + conn, + user_account_id=resolution["user_account"]["id"], + ) + except (AuthSessionInvalidError, AuthSessionExpiredError, AuthSessionRevokedDeviceError) as exc: + return JSONResponse(status_code=401, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder({"preferences": serialize_user_preferences(preferences)}), + ) + + +@app.patch("/v1/preferences") +def patch_v1_preferences( + request: Request, + body: HostedPreferencesPatchRequest, +) -> JSONResponse: + settings = get_settings() + + try: + session_token = _extract_bearer_token(request) + with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: + with conn.transaction(): + resolution = resolve_auth_session(conn, session_token=session_token) + preferences = patch_user_preferences( + conn, + user_account_id=resolution["user_account"]["id"], + timezone=body.timezone, + brief_preferences=body.brief_preferences, + quiet_hours=body.quiet_hours, + ) + except (AuthSessionInvalidError, AuthSessionExpiredError, AuthSessionRevokedDeviceError) as exc: + return JSONResponse(status_code=401, content={"detail": str(exc)}) + except HostedPreferencesValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder({"preferences": serialize_user_preferences(preferences)}), + ) + + +@app.get("/v1/admin/hosted/overview") +def get_v1_admin_hosted_overview( + request: Request, + window_hours: int = Query(default=24, ge=1, le=168), +) -> JSONResponse: + settings = get_settings() + + try: + session_token = _extract_bearer_token(request) + with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: + with conn.transaction(): + resolution = resolve_auth_session(conn, session_token=session_token) + user_account_id = resolution["user_account"]["id"] + _ensure_hosted_admin_access(conn, user_account_id=user_account_id) + payload = get_hosted_overview_for_admin(conn, window_hours=window_hours) + except (AuthSessionInvalidError, AuthSessionExpiredError, AuthSessionRevokedDeviceError) as exc: + return JSONResponse(status_code=401, content={"detail": str(exc)}) + except PermissionError as exc: + return JSONResponse(status_code=403, content={"detail": str(exc)}) + + return JSONResponse(status_code=200, content=jsonable_encoder(payload)) + + +@app.get("/v1/admin/hosted/workspaces") +def get_v1_admin_hosted_workspaces( + request: Request, + limit: int = Query(default=50, ge=1, le=200), +) -> JSONResponse: + settings = get_settings() + + try: + session_token = _extract_bearer_token(request) + with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: + with conn.transaction(): + resolution = resolve_auth_session(conn, session_token=session_token) + user_account_id = resolution["user_account"]["id"] + _ensure_hosted_admin_access(conn, user_account_id=user_account_id) + items = list_hosted_workspaces_for_admin(conn, limit=limit) + except (AuthSessionInvalidError, AuthSessionExpiredError, AuthSessionRevokedDeviceError) as exc: + return JSONResponse(status_code=401, content={"detail": str(exc)}) + except PermissionError as exc: + return JSONResponse(status_code=403, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder( + { + "items": items, + "summary": { + "total_count": len(items), + "returned_count": len(items), + "order": ["updated_at_desc", "id_desc"], + }, + } + ), + ) + + +@app.get("/v1/admin/hosted/delivery-receipts") +def get_v1_admin_hosted_delivery_receipts( + request: Request, + limit: int = Query(default=100, ge=1, le=400), + workspace_id: UUID | None = None, +) -> JSONResponse: + settings = get_settings() + + try: + session_token = _extract_bearer_token(request) + with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: + with conn.transaction(): + resolution = resolve_auth_session(conn, session_token=session_token) + user_account_id = resolution["user_account"]["id"] + _ensure_hosted_admin_access(conn, user_account_id=user_account_id) + items = list_hosted_delivery_receipts_for_admin( + conn, + limit=limit, + workspace_id=workspace_id, + ) + except (AuthSessionInvalidError, AuthSessionExpiredError, AuthSessionRevokedDeviceError) as exc: + return JSONResponse(status_code=401, content={"detail": str(exc)}) + except PermissionError as exc: + return JSONResponse(status_code=403, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder( + { + "items": items, + "summary": { + "total_count": len(items), + "returned_count": len(items), + "order": ["recorded_at_desc", "id_desc"], + }, + } + ), + ) + + +@app.get("/v1/admin/hosted/incidents") +def get_v1_admin_hosted_incidents( + request: Request, + status: str = Query(default="open", min_length=1, max_length=20), + limit: int = Query(default=100, ge=1, le=500), + workspace_id: UUID | None = None, +) -> JSONResponse: + settings = get_settings() + normalized_status = status.strip().casefold() + if normalized_status not in {"open", "resolved", "all"}: + return JSONResponse(status_code=400, content={"detail": "status must be one of: open, resolved, all"}) + + try: + session_token = _extract_bearer_token(request) + with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: + with conn.transaction(): + resolution = resolve_auth_session(conn, session_token=session_token) + user_account_id = resolution["user_account"]["id"] + _ensure_hosted_admin_access(conn, user_account_id=user_account_id) + items = list_hosted_incidents_for_admin( + conn, + limit=limit, + status_filter=normalized_status, # type: ignore[arg-type] + workspace_id=workspace_id, + ) + except (AuthSessionInvalidError, AuthSessionExpiredError, AuthSessionRevokedDeviceError) as exc: + return JSONResponse(status_code=401, content={"detail": str(exc)}) + except PermissionError as exc: + return JSONResponse(status_code=403, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder( + { + "items": items, + "summary": { + "total_count": len(items), + "returned_count": len(items), + "status_filter": normalized_status, + "order": ["occurred_at_desc", "incident_id_desc"], + }, + } + ), + ) + + +@app.get("/v1/admin/hosted/rollout-flags") +def get_v1_admin_hosted_rollout_flags(request: Request) -> JSONResponse: + settings = get_settings() + + try: + session_token = _extract_bearer_token(request) + with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: + with conn.transaction(): + resolution = resolve_auth_session(conn, session_token=session_token) + user_account_id = resolution["user_account"]["id"] + _ensure_hosted_admin_access(conn, user_account_id=user_account_id) + flags = list_rollout_flags_for_admin(conn, user_account_id=user_account_id) + except (AuthSessionInvalidError, AuthSessionExpiredError, AuthSessionRevokedDeviceError) as exc: + return JSONResponse(status_code=401, content={"detail": str(exc)}) + except PermissionError as exc: + return JSONResponse(status_code=403, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder( + { + "items": flags, + "summary": { + "total_count": len(flags), + "enabled_count": sum(1 for flag in flags if flag["enabled"]), + "disabled_count": sum(1 for flag in flags if not flag["enabled"]), + "order": ["flag_key_asc"], + }, + } + ), + ) + + +@app.patch("/v1/admin/hosted/rollout-flags") +def patch_v1_admin_hosted_rollout_flags( + request: Request, + body: HostedRolloutFlagsPatchRequest, +) -> JSONResponse: + settings = get_settings() + + try: + session_token = _extract_bearer_token(request) + with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: + with conn.transaction(): + resolution = resolve_auth_session(conn, session_token=session_token) + user_account_id = resolution["user_account"]["id"] + _ensure_hosted_admin_access(conn, user_account_id=user_account_id) + updated_flags = patch_rollout_flags( + conn, + patches=[ + { + "flag_key": item.flag_key, + "enabled": item.enabled, + "cohort_key": item.cohort_key, + "description": item.description, + } + for item in body.updates + ], + allowed_cohort_key=resolution["user_account"]["beta_cohort_key"], + ) + flags = list_rollout_flags_for_admin(conn, user_account_id=user_account_id) + except (AuthSessionInvalidError, AuthSessionExpiredError, AuthSessionRevokedDeviceError) as exc: + return JSONResponse(status_code=401, content={"detail": str(exc)}) + except PermissionError as exc: + return JSONResponse(status_code=403, content={"detail": str(exc)}) + except ValueError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder( + { + "updated": updated_flags, + "items": flags, + "summary": { + "total_count": len(flags), + "enabled_count": sum(1 for flag in flags if flag["enabled"]), + "disabled_count": sum(1 for flag in flags if not flag["enabled"]), + }, + } + ), + ) + + +@app.get("/v1/admin/hosted/analytics") +def get_v1_admin_hosted_analytics( + request: Request, + window_hours: int = Query(default=24, ge=1, le=168), +) -> JSONResponse: + settings = get_settings() + + try: + session_token = _extract_bearer_token(request) + with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: + with conn.transaction(): + resolution = resolve_auth_session(conn, session_token=session_token) + user_account_id = resolution["user_account"]["id"] + _ensure_hosted_admin_access(conn, user_account_id=user_account_id) + telemetry = aggregate_chat_telemetry(conn, window_hours=window_hours) + except (AuthSessionInvalidError, AuthSessionExpiredError, AuthSessionRevokedDeviceError) as exc: + return JSONResponse(status_code=401, content={"detail": str(exc)}) + except PermissionError as exc: + return JSONResponse(status_code=403, content={"detail": str(exc)}) + + return JSONResponse(status_code=200, content=jsonable_encoder({"analytics": telemetry})) + + +@app.get("/v1/admin/hosted/rate-limits") +def get_v1_admin_hosted_rate_limits( + request: Request, + window_hours: int = Query(default=24, ge=1, le=168), + limit: int = Query(default=100, ge=1, le=200), +) -> JSONResponse: + settings = get_settings() + + try: + session_token = _extract_bearer_token(request) + with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: + with conn.transaction(): + resolution = resolve_auth_session(conn, session_token=session_token) + user_account_id = resolution["user_account"]["id"] + _ensure_hosted_admin_access(conn, user_account_id=user_account_id) + payload = get_hosted_rate_limits_for_admin( + conn, + window_hours=window_hours, + limit=limit, + ) + except (AuthSessionInvalidError, AuthSessionExpiredError, AuthSessionRevokedDeviceError) as exc: + return JSONResponse(status_code=401, content={"detail": str(exc)}) + except PermissionError as exc: + return JSONResponse(status_code=403, content={"detail": str(exc)}) + + return JSONResponse(status_code=200, content=jsonable_encoder(payload)) + + +@app.post("/v1/channels/telegram/link/start") +def start_v1_telegram_link(request: Request, body: TelegramLinkStartRequest) -> JSONResponse: + settings = get_settings() + + try: + session_token = _extract_bearer_token(request) + with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: + with conn.transaction(): + resolution = resolve_auth_session(conn, session_token=session_token) + user_account_id = resolution["user_account"]["id"] + workspace = _resolve_workspace_for_hosted_channel_request( + conn, + user_account_id=user_account_id, + session_id=resolution["session"]["id"], + preferred_workspace_id=resolution["session"]["workspace_id"], + requested_workspace_id=body.workspace_id, + ) + if workspace is None: + return JSONResponse(status_code=404, content={"detail": "no workspace is currently selected"}) + + challenge = start_telegram_link_challenge( + conn, + user_account_id=user_account_id, + workspace_id=workspace["id"], + ttl_seconds=settings.telegram_link_ttl_seconds, + ) + except (AuthSessionInvalidError, AuthSessionExpiredError, AuthSessionRevokedDeviceError) as exc: + return JSONResponse(status_code=401, content={"detail": str(exc)}) + except HostedWorkspaceNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + payload = { + "workspace_id": str(workspace["id"]), + "challenge": serialize_channel_link_challenge(challenge, include_token=True), + "instructions": { + "bot_username": settings.telegram_bot_username, + "command": f"/link {challenge['link_code']}", + "posture": "send the link code to the configured telegram bot, then confirm in hosted settings", + }, + } + return JSONResponse(status_code=200, content=jsonable_encoder(payload)) + + +@app.post("/v1/channels/telegram/link/confirm") +def confirm_v1_telegram_link(request: Request, body: TelegramLinkConfirmRequest) -> JSONResponse: + settings = get_settings() + + try: + session_token = _extract_bearer_token(request) + with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: + with conn.transaction(): + resolution = resolve_auth_session(conn, session_token=session_token) + challenge, identity = confirm_telegram_link_challenge( + conn, + user_account_id=resolution["user_account"]["id"], + challenge_token=body.challenge_token, + ) + except (AuthSessionInvalidError, AuthSessionExpiredError, AuthSessionRevokedDeviceError) as exc: + return JSONResponse(status_code=401, content={"detail": str(exc)}) + except TelegramLinkPendingError as exc: + return JSONResponse(status_code=409, content={"detail": str(exc)}) + except TelegramLinkTokenExpiredError as exc: + return JSONResponse(status_code=401, content={"detail": str(exc)}) + except TelegramLinkTokenInvalidError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse( + status_code=201, + content=jsonable_encoder( + { + "identity": serialize_channel_identity(identity), + "challenge": serialize_channel_link_challenge(challenge, include_token=False), + } + ), + ) + + +@app.post("/v1/channels/telegram/unlink") +def unlink_v1_telegram(request: Request, body: TelegramUnlinkRequest) -> JSONResponse: + settings = get_settings() + + try: + session_token = _extract_bearer_token(request) + with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: + with conn.transaction(): + resolution = resolve_auth_session(conn, session_token=session_token) + user_account_id = resolution["user_account"]["id"] + workspace = _resolve_workspace_for_hosted_channel_request( + conn, + user_account_id=user_account_id, + session_id=resolution["session"]["id"], + preferred_workspace_id=resolution["session"]["workspace_id"], + requested_workspace_id=body.workspace_id, + ) + if workspace is None: + return JSONResponse(status_code=404, content={"detail": "no workspace is currently selected"}) + identity = unlink_telegram_identity( + conn, + user_account_id=user_account_id, + workspace_id=workspace["id"], + ) + except (AuthSessionInvalidError, AuthSessionExpiredError, AuthSessionRevokedDeviceError) as exc: + return JSONResponse(status_code=401, content={"detail": str(exc)}) + except HostedWorkspaceNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + except TelegramIdentityNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + return JSONResponse(status_code=200, content=jsonable_encoder({"identity": serialize_channel_identity(identity)})) + + +@app.get("/v1/channels/telegram/status") +def get_v1_telegram_status( + request: Request, + workspace_id: UUID | None = None, +) -> JSONResponse: + settings = get_settings() + + try: + session_token = _extract_bearer_token(request) + with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: + with conn.transaction(): + resolution = resolve_auth_session(conn, session_token=session_token) + user_account_id = resolution["user_account"]["id"] + workspace = _resolve_workspace_for_hosted_channel_request( + conn, + user_account_id=user_account_id, + session_id=resolution["session"]["id"], + preferred_workspace_id=resolution["session"]["workspace_id"], + requested_workspace_id=workspace_id, + ) + if workspace is None: + return JSONResponse(status_code=404, content={"detail": "no workspace is currently selected"}) + + payload = get_telegram_link_status( + conn, + user_account_id=user_account_id, + workspace_id=workspace["id"], + ) + except (AuthSessionInvalidError, AuthSessionExpiredError, AuthSessionRevokedDeviceError) as exc: + return JSONResponse(status_code=401, content={"detail": str(exc)}) + except HostedWorkspaceNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + return JSONResponse(status_code=200, content=jsonable_encoder(payload)) + + +@app.post("/v1/channels/telegram/webhook") +async def ingest_v1_telegram_webhook(request: Request) -> JSONResponse: + settings = get_settings() + if settings.app_env not in {"development", "test"} and settings.telegram_webhook_secret == "": + return JSONResponse( + status_code=503, + content={"detail": "telegram webhook ingress is not configured"}, + ) + + rate_limit_error = _enforce_entrypoint_rate_limit( + settings=settings, + key=f"telegram_webhook:{_request_client_identifier(request, settings)}", + max_requests=settings.telegram_webhook_rate_limit_max_requests, + window_seconds=settings.telegram_webhook_rate_limit_window_seconds, + detail_code="telegram_webhook_rate_limit_exceeded", + message="telegram webhook rate limit exceeded", + ) + if rate_limit_error is not None: + return rate_limit_error + + if settings.telegram_webhook_secret != "": + header_secret = request.headers.get("x-telegram-bot-api-secret-token", "").strip() + if not hmac.compare_digest(header_secret, settings.telegram_webhook_secret): + return JSONResponse(status_code=401, content={"detail": "telegram webhook secret is invalid"}) + + try: + payload = await request.json() + except ValueError: + return JSONResponse(status_code=400, content={"detail": "telegram webhook payload must be valid json"}) + + if not isinstance(payload, dict): + return JSONResponse(status_code=400, content={"detail": "telegram webhook payload must be an object"}) + + try: + with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: + with conn.transaction(): + ingest_result = ingest_telegram_webhook( + conn, + payload=payload, + bot_username=settings.telegram_bot_username, + ) + except TelegramWebhookValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder( + { + "status": "accepted", + "ingest": serialize_webhook_ingest_result(ingest_result), + } + ), + ) + + +@app.get("/v1/channels/telegram/messages") +def list_v1_telegram_messages( + request: Request, + limit: int = Query(default=50, ge=1, le=200), +) -> JSONResponse: + settings = get_settings() + + try: + session_token = _extract_bearer_token(request) + with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: + with conn.transaction(): + resolution = resolve_auth_session(conn, session_token=session_token) + workspace = _resolve_workspace_for_hosted_channel_request( + conn, + user_account_id=resolution["user_account"]["id"], + session_id=resolution["session"]["id"], + preferred_workspace_id=resolution["session"]["workspace_id"], + requested_workspace_id=None, + ) + if workspace is None: + return JSONResponse(status_code=404, content={"detail": "no workspace is currently selected"}) + rows = list_workspace_telegram_messages( + conn, + user_account_id=resolution["user_account"]["id"], + workspace_id=workspace["id"], + limit=limit, + ) + except (AuthSessionInvalidError, AuthSessionExpiredError, AuthSessionRevokedDeviceError) as exc: + return JSONResponse(status_code=401, content={"detail": str(exc)}) + + items = [serialize_channel_message(row) for row in rows] + return JSONResponse( + status_code=200, + content=jsonable_encoder( + { + "items": items, + "summary": { + "workspace_id": str(workspace["id"]), + "total_count": len(items), + "order": ["created_at_desc", "id_desc"], + }, + } + ), + ) + + +@app.get("/v1/channels/telegram/threads") +def list_v1_telegram_threads( + request: Request, + limit: int = Query(default=50, ge=1, le=200), +) -> JSONResponse: + settings = get_settings() + + try: + session_token = _extract_bearer_token(request) + with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: + with conn.transaction(): + resolution = resolve_auth_session(conn, session_token=session_token) + workspace = _resolve_workspace_for_hosted_channel_request( + conn, + user_account_id=resolution["user_account"]["id"], + session_id=resolution["session"]["id"], + preferred_workspace_id=resolution["session"]["workspace_id"], + requested_workspace_id=None, + ) + if workspace is None: + return JSONResponse(status_code=404, content={"detail": "no workspace is currently selected"}) + rows = list_workspace_telegram_threads( + conn, + user_account_id=resolution["user_account"]["id"], + workspace_id=workspace["id"], + limit=limit, + ) + except (AuthSessionInvalidError, AuthSessionExpiredError, AuthSessionRevokedDeviceError) as exc: + return JSONResponse(status_code=401, content={"detail": str(exc)}) + + items = [serialize_channel_thread(row) for row in rows] + return JSONResponse( + status_code=200, + content=jsonable_encoder( + { + "items": items, + "summary": { + "workspace_id": str(workspace["id"]), + "total_count": len(items), + "order": ["last_message_at_desc", "id_desc"], + }, + } + ), + ) + + +@app.post("/v1/channels/telegram/messages/{message_id}/dispatch") +def dispatch_v1_telegram_message( + message_id: UUID, + request: Request, + body: TelegramDispatchRequest, +) -> JSONResponse: + settings = get_settings() + + try: + session_token = _extract_bearer_token(request) + with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: + with conn.transaction(): + resolution = resolve_auth_session(conn, session_token=session_token) + workspace = _resolve_workspace_for_hosted_channel_request( + conn, + user_account_id=resolution["user_account"]["id"], + session_id=resolution["session"]["id"], + preferred_workspace_id=resolution["session"]["workspace_id"], + requested_workspace_id=None, + ) + if workspace is None: + return JSONResponse(status_code=404, content={"detail": "no workspace is currently selected"}) + outbound_message, receipt = dispatch_telegram_message( + conn, + user_account_id=resolution["user_account"]["id"], + workspace_id=workspace["id"], + source_message_id=message_id, + text=body.text, + dispatch_idempotency_key=body.idempotency_key, + bot_token=settings.telegram_bot_token, + ) + except (AuthSessionInvalidError, AuthSessionExpiredError, AuthSessionRevokedDeviceError) as exc: + return JSONResponse(status_code=401, content={"detail": str(exc)}) + except TelegramMessageNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + except TelegramRoutingError as exc: + return JSONResponse(status_code=409, content={"detail": str(exc)}) + except ValueError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse( + status_code=201, + content=jsonable_encoder( + { + "message": serialize_channel_message(outbound_message), + "receipt": serialize_delivery_receipt(receipt), + } + ), + ) + + +@app.get("/v1/channels/telegram/delivery-receipts") +def list_v1_telegram_delivery_receipts( + request: Request, + limit: int = Query(default=50, ge=1, le=200), +) -> JSONResponse: + settings = get_settings() + + try: + session_token = _extract_bearer_token(request) + with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: + with conn.transaction(): + resolution = resolve_auth_session(conn, session_token=session_token) + workspace = _resolve_workspace_for_hosted_channel_request( + conn, + user_account_id=resolution["user_account"]["id"], + session_id=resolution["session"]["id"], + preferred_workspace_id=resolution["session"]["workspace_id"], + requested_workspace_id=None, + ) + if workspace is None: + return JSONResponse(status_code=404, content={"detail": "no workspace is currently selected"}) + rows = list_workspace_telegram_delivery_receipts( + conn, + user_account_id=resolution["user_account"]["id"], + workspace_id=workspace["id"], + limit=limit, + ) + except (AuthSessionInvalidError, AuthSessionExpiredError, AuthSessionRevokedDeviceError) as exc: + return JSONResponse(status_code=401, content={"detail": str(exc)}) + + items = [serialize_delivery_receipt(row) for row in rows] + return JSONResponse( + status_code=200, + content=jsonable_encoder( + { + "items": items, + "summary": { + "workspace_id": str(workspace["id"]), + "total_count": len(items), + "order": ["recorded_at_desc", "id_desc"], + }, + } + ), + ) + + +@app.get("/v1/channels/telegram/notification-preferences") +def get_v1_telegram_notification_preferences( + request: Request, +) -> JSONResponse: + settings = get_settings() + + try: + session_token = _extract_bearer_token(request) + with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: + with conn.transaction(): + resolution = resolve_auth_session(conn, session_token=session_token) + user_account_id = resolution["user_account"]["id"] + workspace = _resolve_workspace_for_hosted_channel_request( + conn, + user_account_id=user_account_id, + session_id=resolution["session"]["id"], + preferred_workspace_id=resolution["session"]["workspace_id"], + requested_workspace_id=None, + ) + if workspace is None: + return JSONResponse(status_code=404, content={"detail": "no workspace is currently selected"}) + + payload = get_workspace_notification_preferences( + conn, + user_account_id=user_account_id, + workspace_id=workspace["id"], + ) + except (AuthSessionInvalidError, AuthSessionExpiredError, AuthSessionRevokedDeviceError) as exc: + return JSONResponse(status_code=401, content={"detail": str(exc)}) + except TelegramIdentityNotFoundError as exc: + return JSONResponse(status_code=409, content={"detail": str(exc)}) + except TelegramNotificationPreferenceValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse(status_code=200, content=jsonable_encoder(payload)) + + +@app.patch("/v1/channels/telegram/notification-preferences") +def patch_v1_telegram_notification_preferences( + request: Request, + body: TelegramNotificationPreferencesPatchRequest, +) -> JSONResponse: + settings = get_settings() + + try: + session_token = _extract_bearer_token(request) + with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: + with conn.transaction(): + resolution = resolve_auth_session(conn, session_token=session_token) + user_account_id = resolution["user_account"]["id"] + workspace = _resolve_workspace_for_hosted_channel_request( + conn, + user_account_id=user_account_id, + session_id=resolution["session"]["id"], + preferred_workspace_id=resolution["session"]["workspace_id"], + requested_workspace_id=None, + ) + if workspace is None: + return JSONResponse(status_code=404, content={"detail": "no workspace is currently selected"}) + + patch_payload = body.model_dump(exclude_none=True) + patch_workspace_notification_subscription( + conn, + user_account_id=user_account_id, + workspace_id=workspace["id"], + patch=patch_payload, + ) + payload = get_workspace_notification_preferences( + conn, + user_account_id=user_account_id, + workspace_id=workspace["id"], + ) + except (AuthSessionInvalidError, AuthSessionExpiredError, AuthSessionRevokedDeviceError) as exc: + return JSONResponse(status_code=401, content={"detail": str(exc)}) + except TelegramIdentityNotFoundError as exc: + return JSONResponse(status_code=409, content={"detail": str(exc)}) + except TelegramNotificationPreferenceValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse(status_code=200, content=jsonable_encoder(payload)) + + +@app.get("/v1/channels/telegram/daily-brief") +def get_v1_telegram_daily_brief( + request: Request, +) -> JSONResponse: + settings = get_settings() + + try: + session_token = _extract_bearer_token(request) + with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: + with conn.transaction(): + resolution = resolve_auth_session(conn, session_token=session_token) + user_account_id = resolution["user_account"]["id"] + workspace = _resolve_workspace_for_hosted_channel_request( + conn, + user_account_id=user_account_id, + session_id=resolution["session"]["id"], + preferred_workspace_id=resolution["session"]["workspace_id"], + requested_workspace_id=None, + ) + if workspace is None: + return JSONResponse(status_code=404, content={"detail": "no workspace is currently selected"}) + + payload = get_workspace_daily_brief_preview( + conn, + user_account_id=user_account_id, + workspace_id=workspace["id"], + ) + except (AuthSessionInvalidError, AuthSessionExpiredError, AuthSessionRevokedDeviceError) as exc: + return JSONResponse(status_code=401, content={"detail": str(exc)}) + except TelegramIdentityNotFoundError as exc: + return JSONResponse(status_code=409, content={"detail": str(exc)}) + except TelegramNotificationPreferenceValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse(status_code=200, content=jsonable_encoder(payload)) + + +@app.post("/v1/channels/telegram/daily-brief/deliver") +def post_v1_telegram_daily_brief_deliver( + request: Request, + body: TelegramScheduledDeliveryRequest, +) -> JSONResponse: + settings = get_settings() + + try: + session_token = _extract_bearer_token(request) + with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: + with conn.transaction(): + resolution = resolve_auth_session(conn, session_token=session_token) + user_account_id = resolution["user_account"]["id"] + workspace = _resolve_workspace_for_hosted_channel_request( + conn, + user_account_id=user_account_id, + session_id=resolution["session"]["id"], + preferred_workspace_id=resolution["session"]["workspace_id"], + requested_workspace_id=None, + ) + if workspace is None: + return JSONResponse(status_code=404, content={"detail": "no workspace is currently selected"}) + + rollout_resolution = resolve_rollout_flag( + conn, + user_account_id=user_account_id, + flag_key="hosted_scheduler_delivery_enabled", + ) + if not rollout_resolution["enabled"]: + record_chat_telemetry( + conn, + user_account_id=user_account_id, + workspace_id=workspace["id"], + flow_kind="scheduler_daily_brief", + event_kind="rollout_block", + status="blocked_rollout", + route_path="/v1/channels/telegram/daily-brief/deliver", + rollout_flag_key=rollout_resolution["flag_key"], + rollout_flag_state="blocked", + evidence={ + "force": body.force, + "idempotency_key": body.idempotency_key, + }, + ) + return _hosted_rollout_block_error(flag_key=rollout_resolution["flag_key"]) + + rate_limit_rollout = resolve_rollout_flag( + conn, + user_account_id=user_account_id, + flag_key="hosted_rate_limits_enabled", + ) + abuse_rollout = resolve_rollout_flag( + conn, + user_account_id=user_account_id, + flag_key="hosted_abuse_controls_enabled", + ) + if rate_limit_rollout["enabled"]: + decision = evaluate_hosted_flow_limits( + conn, + settings=settings, + user_account_id=user_account_id, + workspace_id=workspace["id"], + flow_kind="scheduler_daily_brief", + ) + if decision["code"] == "hosted_abuse_limit_exceeded" and not abuse_rollout["enabled"]: + decision = { + **decision, + "allowed": True, + "code": None, + "message": "abuse controls disabled by rollout", + "retry_after_seconds": 0, + "abuse_signal": None, + } + + if not decision["allowed"]: + blocked_status = "abuse_blocked" if decision["code"] == "hosted_abuse_limit_exceeded" else "rate_limited" + blocked_event = "abuse_block" if blocked_status == "abuse_blocked" else "rate_limited" + record_chat_telemetry( + conn, + user_account_id=user_account_id, + workspace_id=workspace["id"], + flow_kind="scheduler_daily_brief", + event_kind=blocked_event, # type: ignore[arg-type] + status=blocked_status, # type: ignore[arg-type] + route_path="/v1/channels/telegram/daily-brief/deliver", + rollout_flag_key=rate_limit_rollout["flag_key"], + rollout_flag_state="enabled", + rate_limit_key=decision["rate_limit_key"], + rate_limit_window_seconds=decision["window_seconds"], + rate_limit_max_requests=decision["max_requests"], + retry_after_seconds=decision["retry_after_seconds"], + abuse_signal=decision["abuse_signal"], + evidence={ + "force": body.force, + "idempotency_key": body.idempotency_key, + }, + ) + return _hosted_rate_limit_error( + detail_code=decision["code"] or "hosted_rate_limit_exceeded", + message=decision["message"], + retry_after_seconds=decision["retry_after_seconds"], + rate_limit_key=decision["rate_limit_key"], + window_seconds=decision["window_seconds"], + max_requests=decision["max_requests"], + observed_requests=decision["observed_requests"], + abuse_signal=decision["abuse_signal"], + ) + + record_chat_telemetry( + conn, + user_account_id=user_account_id, + workspace_id=workspace["id"], + flow_kind="scheduler_daily_brief", + event_kind="attempt", + status="ok", + route_path="/v1/channels/telegram/daily-brief/deliver", + rollout_flag_key=rollout_resolution["flag_key"], + rollout_flag_state="enabled", + evidence={ + "force": body.force, + "idempotency_key": body.idempotency_key, + }, + ) + + payload = deliver_workspace_daily_brief( + conn, + user_account_id=user_account_id, + workspace_id=workspace["id"], + bot_token=settings.telegram_bot_token, + force=body.force, + idempotency_key=body.idempotency_key, + ) + delivery_receipt = payload.get("delivery_receipt") + delivery_receipt_id: UUID | None = None + if isinstance(delivery_receipt, dict) and isinstance(delivery_receipt.get("id"), str): + delivery_receipt_id = UUID(delivery_receipt["id"]) + + status_value: str = "ok" + if isinstance(payload.get("job"), dict): + job_status = str(payload["job"].get("status", "ok")) + if job_status in {"failed"}: + status_value = "failed" + elif job_status.startswith("suppressed"): + status_value = "suppressed" + elif job_status in {"simulated", "delivered"}: + status_value = job_status + record_chat_telemetry( + conn, + user_account_id=user_account_id, + workspace_id=workspace["id"], + flow_kind="scheduler_daily_brief", + event_kind="result", + status=status_value, # type: ignore[arg-type] + route_path="/v1/channels/telegram/daily-brief/deliver", + rollout_flag_key=rollout_resolution["flag_key"], + rollout_flag_state="enabled", + delivery_receipt_id=delivery_receipt_id, + evidence={ + "idempotent_replay": bool(payload.get("idempotent_replay")), + "force": body.force, + }, + ) + except (AuthSessionInvalidError, AuthSessionExpiredError, AuthSessionRevokedDeviceError) as exc: + return JSONResponse(status_code=401, content={"detail": str(exc)}) + except TelegramIdentityNotFoundError as exc: + return JSONResponse(status_code=409, content={"detail": str(exc)}) + except TelegramNotificationPreferenceValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + status_code = 200 if bool(payload.get("idempotent_replay")) else 201 + return JSONResponse(status_code=status_code, content=jsonable_encoder(payload)) + + +@app.get("/v1/channels/telegram/open-loop-prompts") +def list_v1_telegram_open_loop_prompts( + request: Request, + limit: int = Query(default=20, ge=1, le=100), +) -> JSONResponse: + settings = get_settings() + + try: + session_token = _extract_bearer_token(request) + with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: + with conn.transaction(): + resolution = resolve_auth_session(conn, session_token=session_token) + user_account_id = resolution["user_account"]["id"] + workspace = _resolve_workspace_for_hosted_channel_request( + conn, + user_account_id=user_account_id, + session_id=resolution["session"]["id"], + preferred_workspace_id=resolution["session"]["workspace_id"], + requested_workspace_id=None, + ) + if workspace is None: + return JSONResponse(status_code=404, content={"detail": "no workspace is currently selected"}) + + payload = list_workspace_open_loop_prompts( + conn, + user_account_id=user_account_id, + workspace_id=workspace["id"], + limit=limit, + ) + except (AuthSessionInvalidError, AuthSessionExpiredError, AuthSessionRevokedDeviceError) as exc: + return JSONResponse(status_code=401, content={"detail": str(exc)}) + except TelegramIdentityNotFoundError as exc: + return JSONResponse(status_code=409, content={"detail": str(exc)}) + except TelegramNotificationPreferenceValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse(status_code=200, content=jsonable_encoder(payload)) + + +@app.post("/v1/channels/telegram/open-loop-prompts/{prompt_id}/deliver") +def post_v1_telegram_open_loop_prompt_deliver( + prompt_id: str, + request: Request, + body: TelegramScheduledDeliveryRequest, +) -> JSONResponse: + settings = get_settings() + + try: + session_token = _extract_bearer_token(request) + with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: + with conn.transaction(): + resolution = resolve_auth_session(conn, session_token=session_token) + user_account_id = resolution["user_account"]["id"] + workspace = _resolve_workspace_for_hosted_channel_request( + conn, + user_account_id=user_account_id, + session_id=resolution["session"]["id"], + preferred_workspace_id=resolution["session"]["workspace_id"], + requested_workspace_id=None, + ) + if workspace is None: + return JSONResponse(status_code=404, content={"detail": "no workspace is currently selected"}) + + rollout_resolution = resolve_rollout_flag( + conn, + user_account_id=user_account_id, + flag_key="hosted_scheduler_delivery_enabled", + ) + if not rollout_resolution["enabled"]: + record_chat_telemetry( + conn, + user_account_id=user_account_id, + workspace_id=workspace["id"], + flow_kind="scheduler_open_loop_prompt", + event_kind="rollout_block", + status="blocked_rollout", + route_path=f"/v1/channels/telegram/open-loop-prompts/{prompt_id}/deliver", + rollout_flag_key=rollout_resolution["flag_key"], + rollout_flag_state="blocked", + evidence={ + "prompt_id": prompt_id, + "force": body.force, + "idempotency_key": body.idempotency_key, + }, + ) + return _hosted_rollout_block_error(flag_key=rollout_resolution["flag_key"]) + + rate_limit_rollout = resolve_rollout_flag( + conn, + user_account_id=user_account_id, + flag_key="hosted_rate_limits_enabled", + ) + abuse_rollout = resolve_rollout_flag( + conn, + user_account_id=user_account_id, + flag_key="hosted_abuse_controls_enabled", + ) + if rate_limit_rollout["enabled"]: + decision = evaluate_hosted_flow_limits( + conn, + settings=settings, + user_account_id=user_account_id, + workspace_id=workspace["id"], + flow_kind="scheduler_open_loop_prompt", + ) + if decision["code"] == "hosted_abuse_limit_exceeded" and not abuse_rollout["enabled"]: + decision = { + **decision, + "allowed": True, + "code": None, + "message": "abuse controls disabled by rollout", + "retry_after_seconds": 0, + "abuse_signal": None, + } + + if not decision["allowed"]: + blocked_status = "abuse_blocked" if decision["code"] == "hosted_abuse_limit_exceeded" else "rate_limited" + blocked_event = "abuse_block" if blocked_status == "abuse_blocked" else "rate_limited" + record_chat_telemetry( + conn, + user_account_id=user_account_id, + workspace_id=workspace["id"], + flow_kind="scheduler_open_loop_prompt", + event_kind=blocked_event, # type: ignore[arg-type] + status=blocked_status, # type: ignore[arg-type] + route_path=f"/v1/channels/telegram/open-loop-prompts/{prompt_id}/deliver", + rollout_flag_key=rate_limit_rollout["flag_key"], + rollout_flag_state="enabled", + rate_limit_key=decision["rate_limit_key"], + rate_limit_window_seconds=decision["window_seconds"], + rate_limit_max_requests=decision["max_requests"], + retry_after_seconds=decision["retry_after_seconds"], + abuse_signal=decision["abuse_signal"], + evidence={ + "prompt_id": prompt_id, + "force": body.force, + "idempotency_key": body.idempotency_key, + }, + ) + return _hosted_rate_limit_error( + detail_code=decision["code"] or "hosted_rate_limit_exceeded", + message=decision["message"], + retry_after_seconds=decision["retry_after_seconds"], + rate_limit_key=decision["rate_limit_key"], + window_seconds=decision["window_seconds"], + max_requests=decision["max_requests"], + observed_requests=decision["observed_requests"], + abuse_signal=decision["abuse_signal"], + ) + + record_chat_telemetry( + conn, + user_account_id=user_account_id, + workspace_id=workspace["id"], + flow_kind="scheduler_open_loop_prompt", + event_kind="attempt", + status="ok", + route_path=f"/v1/channels/telegram/open-loop-prompts/{prompt_id}/deliver", + rollout_flag_key=rollout_resolution["flag_key"], + rollout_flag_state="enabled", + evidence={ + "prompt_id": prompt_id, + "force": body.force, + "idempotency_key": body.idempotency_key, + }, + ) + + payload = deliver_workspace_open_loop_prompt( + conn, + user_account_id=user_account_id, + workspace_id=workspace["id"], + prompt_id=prompt_id, + bot_token=settings.telegram_bot_token, + force=body.force, + idempotency_key=body.idempotency_key, + ) + delivery_receipt = payload.get("delivery_receipt") + delivery_receipt_id: UUID | None = None + if isinstance(delivery_receipt, dict) and isinstance(delivery_receipt.get("id"), str): + delivery_receipt_id = UUID(delivery_receipt["id"]) + + status_value: str = "ok" + if isinstance(payload.get("job"), dict): + job_status = str(payload["job"].get("status", "ok")) + if job_status in {"failed"}: + status_value = "failed" + elif job_status.startswith("suppressed"): + status_value = "suppressed" + elif job_status in {"simulated", "delivered"}: + status_value = job_status + record_chat_telemetry( + conn, + user_account_id=user_account_id, + workspace_id=workspace["id"], + flow_kind="scheduler_open_loop_prompt", + event_kind="result", + status=status_value, # type: ignore[arg-type] + route_path=f"/v1/channels/telegram/open-loop-prompts/{prompt_id}/deliver", + rollout_flag_key=rollout_resolution["flag_key"], + rollout_flag_state="enabled", + delivery_receipt_id=delivery_receipt_id, + evidence={ + "idempotent_replay": bool(payload.get("idempotent_replay")), + "prompt_id": prompt_id, + "force": body.force, + }, + ) + except (AuthSessionInvalidError, AuthSessionExpiredError, AuthSessionRevokedDeviceError) as exc: + return JSONResponse(status_code=401, content={"detail": str(exc)}) + except TelegramIdentityNotFoundError as exc: + return JSONResponse(status_code=409, content={"detail": str(exc)}) + except TelegramOpenLoopPromptNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + except TelegramNotificationPreferenceValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + status_code = 200 if bool(payload.get("idempotent_replay")) else 201 + return JSONResponse(status_code=status_code, content=jsonable_encoder(payload)) + + +@app.get("/v1/channels/telegram/scheduler/jobs") +def list_v1_telegram_scheduler_jobs( + request: Request, + limit: int = Query(default=50, ge=1, le=200), +) -> JSONResponse: + settings = get_settings() + + try: + session_token = _extract_bearer_token(request) + with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: + with conn.transaction(): + resolution = resolve_auth_session(conn, session_token=session_token) + user_account_id = resolution["user_account"]["id"] + workspace = _resolve_workspace_for_hosted_channel_request( + conn, + user_account_id=user_account_id, + session_id=resolution["session"]["id"], + preferred_workspace_id=resolution["session"]["workspace_id"], + requested_workspace_id=None, + ) + if workspace is None: + return JSONResponse(status_code=404, content={"detail": "no workspace is currently selected"}) + + payload = list_workspace_scheduler_jobs( + conn, + user_account_id=user_account_id, + workspace_id=workspace["id"], + limit=limit, + ) + except (AuthSessionInvalidError, AuthSessionExpiredError, AuthSessionRevokedDeviceError) as exc: + return JSONResponse(status_code=401, content={"detail": str(exc)}) + except TelegramIdentityNotFoundError as exc: + return JSONResponse(status_code=409, content={"detail": str(exc)}) + except TelegramNotificationPreferenceValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse(status_code=200, content=jsonable_encoder(payload)) + + +@app.post("/v1/channels/telegram/messages/{message_id}/handle") +def handle_v1_telegram_message( + message_id: UUID, + request: Request, + body: TelegramMessageHandleRequest, +) -> JSONResponse: + settings = get_settings() + + try: + session_token = _extract_bearer_token(request) + with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: + with conn.transaction(): + resolution = resolve_auth_session(conn, session_token=session_token) + user_account_id = resolution["user_account"]["id"] + workspace = _resolve_workspace_for_hosted_channel_request( + conn, + user_account_id=user_account_id, + session_id=resolution["session"]["id"], + preferred_workspace_id=resolution["session"]["workspace_id"], + requested_workspace_id=None, + ) + if workspace is None: + return JSONResponse(status_code=404, content={"detail": "no workspace is currently selected"}) + + rollout_resolution = resolve_rollout_flag( + conn, + user_account_id=user_account_id, + flag_key="hosted_chat_handle_enabled", + ) + if not rollout_resolution["enabled"]: + record_chat_telemetry( + conn, + user_account_id=user_account_id, + workspace_id=workspace["id"], + flow_kind="chat_handle", + event_kind="rollout_block", + status="blocked_rollout", + route_path="/v1/channels/telegram/messages/{message_id}/handle", + channel_message_id=message_id, + rollout_flag_key=rollout_resolution["flag_key"], + rollout_flag_state="blocked", + evidence={"intent_hint": body.intent_hint}, + ) + return _hosted_rollout_block_error(flag_key=rollout_resolution["flag_key"]) + + rate_limit_rollout = resolve_rollout_flag( + conn, + user_account_id=user_account_id, + flag_key="hosted_rate_limits_enabled", + ) + abuse_rollout = resolve_rollout_flag( + conn, + user_account_id=user_account_id, + flag_key="hosted_abuse_controls_enabled", + ) + if rate_limit_rollout["enabled"]: + decision = evaluate_hosted_flow_limits( + conn, + settings=settings, + user_account_id=user_account_id, + workspace_id=workspace["id"], + flow_kind="chat_handle", + ) + if decision["code"] == "hosted_abuse_limit_exceeded" and not abuse_rollout["enabled"]: + decision = { + **decision, + "allowed": True, + "code": None, + "message": "abuse controls disabled by rollout", + "retry_after_seconds": 0, + "abuse_signal": None, + } + + if not decision["allowed"]: + blocked_status = "abuse_blocked" if decision["code"] == "hosted_abuse_limit_exceeded" else "rate_limited" + blocked_event = "abuse_block" if blocked_status == "abuse_blocked" else "rate_limited" + record_chat_telemetry( + conn, + user_account_id=user_account_id, + workspace_id=workspace["id"], + flow_kind="chat_handle", + event_kind=blocked_event, # type: ignore[arg-type] + status=blocked_status, # type: ignore[arg-type] + route_path="/v1/channels/telegram/messages/{message_id}/handle", + channel_message_id=message_id, + rollout_flag_key=rate_limit_rollout["flag_key"], + rollout_flag_state="enabled", + rate_limit_key=decision["rate_limit_key"], + rate_limit_window_seconds=decision["window_seconds"], + rate_limit_max_requests=decision["max_requests"], + retry_after_seconds=decision["retry_after_seconds"], + abuse_signal=decision["abuse_signal"], + evidence={"intent_hint": body.intent_hint}, + ) + return _hosted_rate_limit_error( + detail_code=decision["code"] or "hosted_rate_limit_exceeded", + message=decision["message"], + retry_after_seconds=decision["retry_after_seconds"], + rate_limit_key=decision["rate_limit_key"], + window_seconds=decision["window_seconds"], + max_requests=decision["max_requests"], + observed_requests=decision["observed_requests"], + abuse_signal=decision["abuse_signal"], + ) + + record_chat_telemetry( + conn, + user_account_id=user_account_id, + workspace_id=workspace["id"], + flow_kind="chat_handle", + event_kind="attempt", + status="ok", + route_path="/v1/channels/telegram/messages/{message_id}/handle", + channel_message_id=message_id, + rollout_flag_key=rollout_resolution["flag_key"], + rollout_flag_state="enabled", + evidence={"intent_hint": body.intent_hint}, + ) + + prepare_telegram_continuity_context(conn, user_account_id=user_account_id) + payload = handle_telegram_message( + conn, + user_account_id=user_account_id, + workspace_id=workspace["id"], + message_id=message_id, + bot_token=settings.telegram_bot_token, + intent_hint=body.intent_hint, + ) + intent_status = str(payload["intent"].get("status", "handled")) + telemetry_status = "ok" if intent_status == "handled" else "failed" + delivery_receipt = payload.get("delivery_receipt") + delivery_receipt_id: UUID | None = None + if isinstance(delivery_receipt, dict) and isinstance(delivery_receipt.get("id"), str): + delivery_receipt_id = UUID(delivery_receipt["id"]) + record_chat_telemetry( + conn, + user_account_id=user_account_id, + workspace_id=workspace["id"], + flow_kind="chat_handle", + event_kind="result", + status=telemetry_status, # type: ignore[arg-type] + route_path="/v1/channels/telegram/messages/{message_id}/handle", + channel_message_id=message_id, + delivery_receipt_id=delivery_receipt_id, + rollout_flag_key=rollout_resolution["flag_key"], + rollout_flag_state="enabled", + evidence={ + "intent_status": intent_status, + "intent_kind": payload["intent"].get("intent_kind"), + }, + ) + except (AuthSessionInvalidError, AuthSessionExpiredError, AuthSessionRevokedDeviceError) as exc: + return JSONResponse(status_code=401, content={"detail": str(exc)}) + except HostedUserAccountNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + except TelegramMessageNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + except TelegramRoutingError as exc: + return JSONResponse(status_code=409, content={"detail": str(exc)}) + except ValueError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse(status_code=200, content=jsonable_encoder(payload)) + + +@app.get("/v1/channels/telegram/messages/{message_id}/result") +def get_v1_telegram_message_result( + message_id: UUID, + request: Request, +) -> JSONResponse: + settings = get_settings() + + try: + session_token = _extract_bearer_token(request) + with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: + with conn.transaction(): + resolution = resolve_auth_session(conn, session_token=session_token) + user_account_id = resolution["user_account"]["id"] + workspace = _resolve_workspace_for_hosted_channel_request( + conn, + user_account_id=user_account_id, + session_id=resolution["session"]["id"], + preferred_workspace_id=resolution["session"]["workspace_id"], + requested_workspace_id=None, + ) + if workspace is None: + return JSONResponse(status_code=404, content={"detail": "no workspace is currently selected"}) + + prepare_telegram_continuity_context(conn, user_account_id=user_account_id) + payload = get_telegram_message_result( + conn, + user_account_id=user_account_id, + workspace_id=workspace["id"], + message_id=message_id, + ) + except (AuthSessionInvalidError, AuthSessionExpiredError, AuthSessionRevokedDeviceError) as exc: + return JSONResponse(status_code=401, content={"detail": str(exc)}) + except HostedUserAccountNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + except TelegramMessageResultNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + return JSONResponse(status_code=200, content=jsonable_encoder(payload)) + + +@app.get("/v1/channels/telegram/recall") +def list_v1_telegram_recall( + request: Request, + query_text: str | None = Query(default=None, alias="query", min_length=1, max_length=4000), + thread_id: UUID | None = None, + task_id: UUID | None = None, + project: str | None = Query(default=None, min_length=1, max_length=200), + person: str | None = Query(default=None, min_length=1, max_length=200), + since: datetime | None = None, + until: datetime | None = None, + limit: int = Query( + default=DEFAULT_CONTINUITY_RECALL_LIMIT, + ge=1, + le=MAX_CONTINUITY_RECALL_LIMIT, + ), +) -> JSONResponse: + settings = get_settings() + + try: + session_token = _extract_bearer_token(request) + with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: + with conn.transaction(): + resolution = resolve_auth_session(conn, session_token=session_token) + user_account_id = resolution["user_account"]["id"] + workspace = _resolve_workspace_for_hosted_channel_request( + conn, + user_account_id=user_account_id, + session_id=resolution["session"]["id"], + preferred_workspace_id=resolution["session"]["workspace_id"], + requested_workspace_id=None, + ) + if workspace is None: + return JSONResponse(status_code=404, content={"detail": "no workspace is currently selected"}) + + prepare_telegram_continuity_context(conn, user_account_id=user_account_id) + payload = query_continuity_recall( + ContinuityStore(conn), + user_id=user_account_id, + request=ContinuityRecallQueryInput( + query=query_text, + thread_id=thread_id, + task_id=task_id, + project=project, + person=person, + since=since, + until=until, + limit=limit, + ), + ) + except (AuthSessionInvalidError, AuthSessionExpiredError, AuthSessionRevokedDeviceError) as exc: + return JSONResponse(status_code=401, content={"detail": str(exc)}) + except HostedUserAccountNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + except ContinuityRecallValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder( + { + "workspace_id": str(workspace["id"]), + "recall": payload, + } + ), + ) + + +@app.get("/v1/channels/telegram/resume") +def get_v1_telegram_resumption_brief( + request: Request, + query_text: str | None = Query(default=None, alias="query", min_length=1, max_length=4000), + thread_id: UUID | None = None, + task_id: UUID | None = None, + project: str | None = Query(default=None, min_length=1, max_length=200), + person: str | None = Query(default=None, min_length=1, max_length=200), + since: datetime | None = None, + until: datetime | None = None, + max_recent_changes: int = Query( + default=DEFAULT_CONTINUITY_RESUMPTION_RECENT_CHANGES_LIMIT, + ge=0, + le=MAX_CONTINUITY_RESUMPTION_RECENT_CHANGES_LIMIT, + ), + max_open_loops: int = Query( + default=DEFAULT_CONTINUITY_RESUMPTION_OPEN_LOOP_LIMIT, + ge=0, + le=MAX_CONTINUITY_RESUMPTION_OPEN_LOOP_LIMIT, + ), +) -> JSONResponse: + settings = get_settings() + + try: + session_token = _extract_bearer_token(request) + with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: + with conn.transaction(): + resolution = resolve_auth_session(conn, session_token=session_token) + user_account_id = resolution["user_account"]["id"] + workspace = _resolve_workspace_for_hosted_channel_request( + conn, + user_account_id=user_account_id, + session_id=resolution["session"]["id"], + preferred_workspace_id=resolution["session"]["workspace_id"], + requested_workspace_id=None, + ) + if workspace is None: + return JSONResponse(status_code=404, content={"detail": "no workspace is currently selected"}) + + prepare_telegram_continuity_context(conn, user_account_id=user_account_id) + payload = compile_continuity_resumption_brief( + ContinuityStore(conn), + user_id=user_account_id, + request=ContinuityResumptionBriefRequestInput( + query=query_text, + thread_id=thread_id, + task_id=task_id, + project=project, + person=person, + since=since, + until=until, + max_recent_changes=max_recent_changes, + max_open_loops=max_open_loops, + ), + ) + except (AuthSessionInvalidError, AuthSessionExpiredError, AuthSessionRevokedDeviceError) as exc: + return JSONResponse(status_code=401, content={"detail": str(exc)}) + except HostedUserAccountNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + except ContinuityResumptionValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + except ContinuityRecallValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder( + { + "workspace_id": str(workspace["id"]), + "resume": payload, + } + ), + ) + + +@app.get("/v1/channels/telegram/open-loops") +def get_v1_telegram_open_loops( + request: Request, + query_text: str | None = Query(default=None, alias="query", min_length=1, max_length=4000), + thread_id: UUID | None = None, + task_id: UUID | None = None, + project: str | None = Query(default=None, min_length=1, max_length=200), + person: str | None = Query(default=None, min_length=1, max_length=200), + since: datetime | None = None, + until: datetime | None = None, + limit: int = Query( + default=DEFAULT_CONTINUITY_OPEN_LOOP_LIMIT, + ge=0, + le=MAX_CONTINUITY_OPEN_LOOP_LIMIT, + ), +) -> JSONResponse: + settings = get_settings() + + try: + session_token = _extract_bearer_token(request) + with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: + with conn.transaction(): + resolution = resolve_auth_session(conn, session_token=session_token) + user_account_id = resolution["user_account"]["id"] + workspace = _resolve_workspace_for_hosted_channel_request( + conn, + user_account_id=user_account_id, + session_id=resolution["session"]["id"], + preferred_workspace_id=resolution["session"]["workspace_id"], + requested_workspace_id=None, + ) + if workspace is None: + return JSONResponse(status_code=404, content={"detail": "no workspace is currently selected"}) + + prepare_telegram_continuity_context(conn, user_account_id=user_account_id) + payload = compile_continuity_open_loop_dashboard( + ContinuityStore(conn), + user_id=user_account_id, + request=ContinuityOpenLoopDashboardQueryInput( + query=query_text, + thread_id=thread_id, + task_id=task_id, + project=project, + person=person, + since=since, + until=until, + limit=limit, + ), + ) + except (AuthSessionInvalidError, AuthSessionExpiredError, AuthSessionRevokedDeviceError) as exc: + return JSONResponse(status_code=401, content={"detail": str(exc)}) + except HostedUserAccountNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + except ContinuityOpenLoopValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + except ContinuityRecallValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse( + status_code=200, + content=jsonable_encoder( + { + "workspace_id": str(workspace["id"]), + "open_loops": payload, + } + ), + ) + + +@app.post("/v1/channels/telegram/open-loops/{open_loop_id}/review-action") +def review_action_v1_telegram_open_loop( + open_loop_id: UUID, + request: Request, + body: TelegramOpenLoopReviewActionBody, +) -> JSONResponse: + settings = get_settings() + + try: + session_token = _extract_bearer_token(request) + with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: + with conn.transaction(): + resolution = resolve_auth_session(conn, session_token=session_token) + user_account_id = resolution["user_account"]["id"] + workspace = _resolve_workspace_for_hosted_channel_request( + conn, + user_account_id=user_account_id, + session_id=resolution["session"]["id"], + preferred_workspace_id=resolution["session"]["workspace_id"], + requested_workspace_id=None, + ) + if workspace is None: + return JSONResponse(status_code=404, content={"detail": "no workspace is currently selected"}) + + prepare_telegram_continuity_context(conn, user_account_id=user_account_id) + payload = apply_telegram_open_loop_review_with_log( + conn, + user_account_id=user_account_id, + workspace_id=workspace["id"], + continuity_object_id=open_loop_id, + action=body.action, + note=body.note, + ) + except (AuthSessionInvalidError, AuthSessionExpiredError, AuthSessionRevokedDeviceError) as exc: + return JSONResponse(status_code=401, content={"detail": str(exc)}) + except HostedUserAccountNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + except ContinuityOpenLoopNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + except ContinuityOpenLoopValidationError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + return JSONResponse(status_code=200, content=jsonable_encoder(payload)) + + +@app.get("/v1/channels/telegram/approvals") +def list_v1_telegram_approvals( + request: Request, + status: str = Query(default="pending", min_length=1, max_length=20), +) -> JSONResponse: + settings = get_settings() + status_filter = status.casefold().strip() + if status_filter not in {"pending", "all"}: + return JSONResponse(status_code=400, content={"detail": "status must be one of: pending, all"}) + + try: + session_token = _extract_bearer_token(request) + with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: + with conn.transaction(): + resolution = resolve_auth_session(conn, session_token=session_token) + user_account_id = resolution["user_account"]["id"] + workspace = _resolve_workspace_for_hosted_channel_request( + conn, + user_account_id=user_account_id, + session_id=resolution["session"]["id"], + preferred_workspace_id=resolution["session"]["workspace_id"], + requested_workspace_id=None, + ) + if workspace is None: + return JSONResponse(status_code=404, content={"detail": "no workspace is currently selected"}) + + prepare_telegram_continuity_context(conn, user_account_id=user_account_id) + payload = list_telegram_approvals( + conn, + user_account_id=user_account_id, + workspace_id=workspace["id"], + status_filter=status_filter, # type: ignore[arg-type] + ) + except (AuthSessionInvalidError, AuthSessionExpiredError, AuthSessionRevokedDeviceError) as exc: + return JSONResponse(status_code=401, content={"detail": str(exc)}) + except HostedUserAccountNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + return JSONResponse(status_code=200, content=jsonable_encoder(payload)) + + +@app.post("/v1/channels/telegram/approvals/{approval_id}/approve") +def approve_v1_telegram_approval( + approval_id: UUID, + request: Request, + body: TelegramApprovalResolveBody | None = None, +) -> JSONResponse: + del body + settings = get_settings() + resolution_error: ( + ApprovalResolutionConflictError | TaskStepApprovalLinkageError | TaskStepLifecycleBoundaryError | None + ) = None + + try: + session_token = _extract_bearer_token(request) + with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: + with conn.transaction(): + resolution = resolve_auth_session(conn, session_token=session_token) + user_account_id = resolution["user_account"]["id"] + workspace = _resolve_workspace_for_hosted_channel_request( + conn, + user_account_id=user_account_id, + session_id=resolution["session"]["id"], + preferred_workspace_id=resolution["session"]["workspace_id"], + requested_workspace_id=None, + ) + if workspace is None: + return JSONResponse(status_code=404, content={"detail": "no workspace is currently selected"}) + prepare_telegram_continuity_context(conn, user_account_id=user_account_id) + try: + payload = approve_telegram_approval( + conn, + user_account_id=user_account_id, + workspace_id=workspace["id"], + approval_id=approval_id, + ) + except ( + ApprovalResolutionConflictError, + TaskStepApprovalLinkageError, + TaskStepLifecycleBoundaryError, + ) as exc: + resolution_error = exc + payload = None + except (AuthSessionInvalidError, AuthSessionExpiredError, AuthSessionRevokedDeviceError) as exc: + return JSONResponse(status_code=401, content={"detail": str(exc)}) + except HostedUserAccountNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + except ApprovalNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + if resolution_error is not None: + return JSONResponse(status_code=409, content={"detail": str(resolution_error)}) + + return JSONResponse(status_code=200, content=jsonable_encoder(payload)) + + +@app.post("/v1/channels/telegram/approvals/{approval_id}/reject") +def reject_v1_telegram_approval( + approval_id: UUID, + request: Request, + body: TelegramApprovalResolveBody | None = None, +) -> JSONResponse: + del body + settings = get_settings() + resolution_error: ( + ApprovalResolutionConflictError | TaskStepApprovalLinkageError | TaskStepLifecycleBoundaryError | None + ) = None + + try: + session_token = _extract_bearer_token(request) + with psycopg.connect(settings.database_url, row_factory=dict_row) as conn: + with conn.transaction(): + resolution = resolve_auth_session(conn, session_token=session_token) + user_account_id = resolution["user_account"]["id"] + workspace = _resolve_workspace_for_hosted_channel_request( + conn, + user_account_id=user_account_id, + session_id=resolution["session"]["id"], + preferred_workspace_id=resolution["session"]["workspace_id"], + requested_workspace_id=None, + ) + if workspace is None: + return JSONResponse(status_code=404, content={"detail": "no workspace is currently selected"}) + prepare_telegram_continuity_context(conn, user_account_id=user_account_id) + try: + payload = reject_telegram_approval( + conn, + user_account_id=user_account_id, + workspace_id=workspace["id"], + approval_id=approval_id, + ) + except ( + ApprovalResolutionConflictError, + TaskStepApprovalLinkageError, + TaskStepLifecycleBoundaryError, + ) as exc: + resolution_error = exc + payload = None + except (AuthSessionInvalidError, AuthSessionExpiredError, AuthSessionRevokedDeviceError) as exc: + return JSONResponse(status_code=401, content={"detail": str(exc)}) + except HostedUserAccountNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + except ApprovalNotFoundError as exc: + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + if resolution_error is not None: + return JSONResponse(status_code=409, content={"detail": str(resolution_error)}) + + return JSONResponse(status_code=200, content=jsonable_encoder(payload)) diff --git a/apps/api/src/alicebot_api/markdown_import.py b/apps/api/src/alicebot_api/markdown_import.py new file mode 100644 index 0000000..c8634e3 --- /dev/null +++ b/apps/api/src/alicebot_api/markdown_import.py @@ -0,0 +1,335 @@ +from __future__ import annotations + +from pathlib import Path +import re +from uuid import UUID + +from alicebot_api.importer_models import ( + ImporterNormalizedBatch, + ImporterNormalizedItem, + ImporterValidationError, + ImporterWorkspaceContext, + OBJECT_TYPE_TO_BODY_KEY, + OBJECT_TYPE_TO_PREFIX, + dedupe_key_for_payload, + merge_json_objects, + normalize_object_type, + normalize_optional_text, + parse_optional_confidence, + parse_optional_status, +) +from alicebot_api.importers.common import ImportPersistenceConfig, import_normalized_batch +from alicebot_api.store import ContinuityStore, JsonObject + + +_DEFAULT_CONFIDENCE = 0.84 +_DEFAULT_DEDUPE_POSTURE = "workspace_and_line_fingerprint" +_PREFIX_TO_OBJECT_TYPE: tuple[tuple[str, str], ...] = ( + ("decision:", "Decision"), + ("next action:", "NextAction"), + ("next:", "NextAction"), + ("task:", "NextAction"), + ("commitment:", "Commitment"), + ("waiting for:", "WaitingFor"), + ("blocker:", "Blocker"), + ("fact:", "MemoryFact"), + ("remember:", "MemoryFact"), + ("note:", "Note"), +) + + +class MarkdownImportValidationError(ImporterValidationError): + """Raised when a markdown import payload is invalid.""" + + +def _truncate(value: str, *, max_length: int) -> str: + if len(value) <= max_length: + return value + return value[: max_length - 3].rstrip() + "..." + + +def _build_title(*, object_type: str, text: str, explicit_title: str | None) -> str: + if explicit_title is not None: + return _truncate(explicit_title, max_length=280) + prefix = OBJECT_TYPE_TO_PREFIX[object_type] + return _truncate(f"{prefix}: {text}", max_length=280) + + +def _build_raw_content(*, object_type: str, text: str) -> str: + prefix = OBJECT_TYPE_TO_PREFIX[object_type] + return f"{prefix}: {text}" + + +def _strip_list_prefix(line: str) -> str: + stripped = line.strip() + if stripped.startswith("- ") or stripped.startswith("* "): + return stripped[2:].strip() + numbered = re.match(r"^\d+\.\s+(.*)$", stripped) + if numbered: + return numbered.group(1).strip() + return stripped + + +def _parse_frontmatter(raw_text: str) -> tuple[dict[str, str], list[str]]: + lines = raw_text.splitlines() + if not lines or lines[0].strip() != "---": + return {}, lines + + metadata: dict[str, str] = {} + closing_index = -1 + for index in range(1, len(lines)): + line = lines[index].strip() + if line == "---": + closing_index = index + break + if line == "" or line.startswith("#"): + continue + if ":" not in line: + raise MarkdownImportValidationError("frontmatter lines must use key: value format") + key, value = line.split(":", 1) + normalized_key = normalize_optional_text(key) + normalized_value = normalize_optional_text(value) + if normalized_key is None or normalized_value is None: + continue + metadata[normalized_key.casefold().replace("-", "_")] = normalized_value + + if closing_index == -1: + raise MarkdownImportValidationError("markdown frontmatter must be closed with ---") + + return metadata, lines[closing_index + 1 :] + + +def _read_markdown_source(source: str | Path) -> tuple[Path, list[Path]]: + source_path = Path(source).expanduser().resolve() + if not source_path.exists(): + raise MarkdownImportValidationError(f"markdown source path does not exist: {source_path}") + + if source_path.is_file(): + if source_path.suffix.casefold() != ".md": + raise MarkdownImportValidationError("markdown source file must end with .md") + return source_path, [source_path] + + files = sorted( + path + for path in source_path.rglob("*.md") + if path.is_file() + ) + if not files: + raise MarkdownImportValidationError(f"no markdown files were found at {source_path}") + return source_path, files + + +def _resolve_object_type_and_text(*, text: str, type_hint: str | None) -> tuple[str, str]: + if type_hint is not None: + return normalize_object_type(type_hint), text + + lowered = text.casefold() + for prefix, object_type in _PREFIX_TO_OBJECT_TYPE: + if not lowered.startswith(prefix): + continue + stripped = normalize_optional_text(text[len(prefix) :]) + if stripped is None: + raise MarkdownImportValidationError("markdown entry content must not be empty") + return object_type, stripped + + return "Note", text + + +def _parse_line_tags(line: str) -> tuple[str, dict[str, str]]: + segments = [segment.strip() for segment in line.split("|")] + text_segment = segments[0] + tags: dict[str, str] = {} + for segment in segments[1:]: + if "=" not in segment: + continue + key, value = segment.split("=", 1) + normalized_key = normalize_optional_text(key) + normalized_value = normalize_optional_text(value) + if normalized_key is None or normalized_value is None: + continue + tags[normalized_key.casefold().replace("-", "_")] = normalized_value + return text_segment, tags + + +def _merge_source_event_ids(*, existing: list[str], maybe_csv: str | None, single: str | None) -> list[str]: + output = list(existing) + seen = set(output) + + if maybe_csv is not None: + for part in maybe_csv.split(","): + normalized = normalize_optional_text(part) + if normalized is None or normalized in seen: + continue + output.append(normalized) + seen.add(normalized) + + normalized_single = normalize_optional_text(single) + if normalized_single is not None and normalized_single not in seen: + output.append(normalized_single) + + return output + + +def load_markdown_payload(source: str | Path) -> ImporterNormalizedBatch: + source_path, markdown_files = _read_markdown_source(source) + + fixture_id: str | None = None + workspace_id: str | None = None + workspace_name: str | None = None + default_status: str = "active" + default_confidence = _DEFAULT_CONFIDENCE + default_scope: JsonObject = {} + + items: list[ImporterNormalizedItem] = [] + + for file_path in markdown_files: + raw_text = file_path.read_text(encoding="utf-8") + metadata, lines = _parse_frontmatter(raw_text) + + if fixture_id is None: + fixture_id = normalize_optional_text(metadata.get("fixture_id")) + if workspace_id is None: + workspace_id = normalize_optional_text(metadata.get("workspace_id")) + if workspace_name is None: + workspace_name = normalize_optional_text(metadata.get("workspace_name")) + + maybe_default_status = parse_optional_status(metadata.get("default_status")) + if maybe_default_status is not None: + default_status = maybe_default_status + + maybe_default_confidence = parse_optional_confidence(metadata.get("default_confidence")) + if maybe_default_confidence is not None: + default_confidence = maybe_default_confidence + + file_scope: JsonObject = {} + for key in ("thread_id", "task_id", "project", "person", "confirmation_status"): + value = normalize_optional_text(metadata.get(key)) + if value is not None: + file_scope[key] = value if key != "confirmation_status" else value.casefold() + + for line_number, raw_line in enumerate(lines, start=1): + stripped = _strip_list_prefix(raw_line) + normalized_line = normalize_optional_text(stripped) + if normalized_line is None: + continue + if normalized_line.startswith("#"): + continue + + content_segment, tags = _parse_line_tags(normalized_line) + normalized_content = normalize_optional_text(content_segment) + if normalized_content is None: + continue + + object_type, object_text = _resolve_object_type_and_text( + text=normalized_content, + type_hint=tags.get("type"), + ) + status = parse_optional_status(tags.get("status")) or default_status + confidence = parse_optional_confidence(tags.get("confidence")) + if confidence is None: + confidence = default_confidence + + source_item_id = normalize_optional_text(tags.get("id")) or f"{file_path.name}:{line_number}" + title = _build_title( + object_type=object_type, + text=object_text, + explicit_title=normalize_optional_text(tags.get("title")), + ) + + body_key = OBJECT_TYPE_TO_BODY_KEY[object_type] + body: JsonObject = { + body_key: object_text, + "raw_import_text": object_text, + "markdown_raw_line": raw_line, + "markdown_line_number": line_number, + "markdown_source_file": file_path.name, + } + + source_provenance = merge_json_objects( + default_scope, + file_scope, + { + "markdown_source_relpath": str(file_path.relative_to(source_path)) + if source_path.is_dir() + else file_path.name, + }, + ) + + for key in ("thread_id", "task_id", "project", "person", "confirmation_status"): + value = normalize_optional_text(tags.get(key)) + if value is None: + continue + source_provenance[key] = value if key != "confirmation_status" else value.casefold() + + source_event_ids = _merge_source_event_ids( + existing=[], + maybe_csv=tags.get("source_event_ids"), + single=tags.get("source_event_id"), + ) + if source_event_ids: + source_provenance["source_event_ids"] = source_event_ids + + dedupe_payload: JsonObject = { + "workspace_id": workspace_id or source_path.stem, + "object_type": object_type, + "status": status, + "title": title, + "body": { + body_key: object_text, + "raw_import_text": object_text, + }, + "source_provenance": source_provenance, + } + + items.append( + ImporterNormalizedItem( + source_item_id=source_item_id, + source_file=file_path.name, + object_type=object_type, + status=status, + raw_content=_build_raw_content(object_type=object_type, text=object_text), + title=title, + body=body, + confidence=confidence, + source_provenance=source_provenance, + dedupe_key=dedupe_key_for_payload(dedupe_payload), + ) + ) + + resolved_workspace_id = workspace_id or source_path.stem + if not items: + raise MarkdownImportValidationError("markdown source did not contain any importable entries") + + return ImporterNormalizedBatch( + context=ImporterWorkspaceContext( + fixture_id=fixture_id, + workspace_id=resolved_workspace_id, + workspace_name=workspace_name, + source_path=str(source_path), + ), + items=items, + ) + + +def import_markdown_source( + store: ContinuityStore, + *, + user_id: UUID, + source: str | Path, +) -> JsonObject: + batch = load_markdown_payload(source) + return import_normalized_batch( + store, + user_id=user_id, + batch=batch, + config=ImportPersistenceConfig( + source_kind="markdown_import", + source_prefix="markdown", + admission_reason="markdown_import", + dedupe_key_field="markdown_dedupe_key", + dedupe_posture=_DEFAULT_DEDUPE_POSTURE, + ), + ) + + +__all__ = ["MarkdownImportValidationError", "import_markdown_source", "load_markdown_payload"] diff --git a/apps/api/src/alicebot_api/mcp_server.py b/apps/api/src/alicebot_api/mcp_server.py new file mode 100644 index 0000000..c069ecf --- /dev/null +++ b/apps/api/src/alicebot_api/mcp_server.py @@ -0,0 +1,307 @@ +from __future__ import annotations + +import argparse +import json +import os +import sys +from typing import Any, BinaryIO, Literal +from uuid import UUID + +from alicebot_api import __version__ +from alicebot_api.config import Settings, get_settings +from alicebot_api.mcp_tools import ( + MCPRuntimeContext, + MCPToolError, + MCPToolNotFoundError, + call_mcp_tool, + list_mcp_tools, +) + + +_JSONRPC_VERSION = "2.0" +_MCP_PROTOCOL_VERSION = "2024-11-05" +_MCP_SERVER_NAME = "alice-core-mcp" +_DEFAULT_MCP_USER_ID = "00000000-0000-0000-0000-000000000001" +_TRANSPORT_CONTENT_LENGTH = "content-length" +_TRANSPORT_JSON_LINE = "json-line" +_TransportMode = Literal["content-length", "json-line"] + + +def _parse_uuid(value: str) -> UUID: + try: + return UUID(value) + except ValueError as exc: + raise argparse.ArgumentTypeError(f"invalid UUID value: {value}") from exc + + +def _resolve_user_id(settings: Settings, user_id_flag: str | None) -> UUID: + if user_id_flag is not None: + return _parse_uuid(user_id_flag) + if settings.auth_user_id != "": + return UUID(settings.auth_user_id) + return UUID(os.getenv("ALICEBOT_AUTH_USER_ID", _DEFAULT_MCP_USER_ID)) + + +def _build_runtime_context(args: argparse.Namespace) -> MCPRuntimeContext: + settings = get_settings() + database_url = args.database_url if args.database_url is not None else settings.database_url + user_id = _resolve_user_id(settings, args.user_id) + return MCPRuntimeContext(database_url=database_url, user_id=user_id) + + +def _parse_json_rpc_payload(raw_payload: str) -> dict[str, Any]: + payload = json.loads(raw_payload) + if not isinstance(payload, dict): + raise ValueError("JSON-RPC payload must be an object") + return payload + + +def _read_message(stream: BinaryIO) -> tuple[dict[str, Any], _TransportMode] | None: + first_line = stream.readline() + if first_line == b"": + return None + + # MCP SDK >=1.0 stdio transport sends one JSON-RPC message per line. + stripped_first_line = first_line.strip() + if stripped_first_line.startswith(b"{"): + payload = _parse_json_rpc_payload(stripped_first_line.decode("utf-8")) + return payload, _TRANSPORT_JSON_LINE + + headers: dict[str, str] = {} + line = first_line + while True: + if line in {b"\r\n", b"\n"}: + break + + decoded = line.decode("utf-8").strip() + if ":" not in decoded: + raise ValueError("invalid MCP header line") + key, value = decoded.split(":", 1) + headers[key.strip().lower()] = value.strip() + + line = stream.readline() + if line == b"": + return None + + content_length_raw = headers.get("content-length") + if content_length_raw is None: + raise ValueError("missing Content-Length header") + try: + content_length = int(content_length_raw) + except ValueError as exc: + raise ValueError("invalid Content-Length header") from exc + if content_length < 0: + raise ValueError("invalid Content-Length header") + + body = stream.read(content_length) + if len(body) != content_length: + return None + payload = _parse_json_rpc_payload(body.decode("utf-8")) + return payload, _TRANSPORT_CONTENT_LENGTH + + +def _write_message( + stream: BinaryIO, + message: dict[str, Any], + *, + transport_mode: _TransportMode, +) -> None: + encoded = json.dumps(message, separators=(",", ":"), sort_keys=True).encode("utf-8") + if transport_mode == _TRANSPORT_JSON_LINE: + stream.write(encoded + b"\n") + else: + header = f"Content-Length: {len(encoded)}\r\n\r\n".encode("ascii") + stream.write(header) + stream.write(encoded) + stream.flush() + + +def _response_success(request_id: object, *, result: object) -> dict[str, Any]: + return { + "jsonrpc": _JSONRPC_VERSION, + "id": request_id, + "result": result, + } + + +def _response_error(request_id: object, *, code: int, message: str) -> dict[str, Any]: + return { + "jsonrpc": _JSONRPC_VERSION, + "id": request_id, + "error": { + "code": code, + "message": message, + }, + } + + +class MCPServer: + def __init__(self, *, context: MCPRuntimeContext, input_stream: BinaryIO, output_stream: BinaryIO) -> None: + self._context = context + self._input_stream = input_stream + self._output_stream = output_stream + self._transport_mode: _TransportMode = _TRANSPORT_CONTENT_LENGTH + + def _handle_request(self, request: dict[str, Any]) -> dict[str, Any] | None: + if request.get("jsonrpc") != _JSONRPC_VERSION: + return _response_error(request.get("id"), code=-32600, message="invalid jsonrpc version") + + method = request.get("method") + if not isinstance(method, str): + return _response_error(request.get("id"), code=-32600, message="method must be a string") + + request_id = request.get("id") + params = request.get("params", {}) + if params is None: + params = {} + if not isinstance(params, dict): + return _response_error(request_id, code=-32602, message="params must be a JSON object") + + if method == "notifications/initialized": + return None + + if request_id is None: + return None + + if method == "initialize": + return _response_success( + request_id, + result={ + "protocolVersion": _MCP_PROTOCOL_VERSION, + "capabilities": { + "tools": {}, + }, + "serverInfo": { + "name": _MCP_SERVER_NAME, + "version": __version__, + }, + }, + ) + + if method == "ping": + return _response_success(request_id, result={}) + + if method == "tools/list": + return _response_success( + request_id, + result={ + "tools": list_mcp_tools(), + }, + ) + + if method == "tools/call": + name = params.get("name") + if not isinstance(name, str): + return _response_error(request_id, code=-32602, message="tools/call requires string name") + + arguments = params.get("arguments") + try: + structured = call_mcp_tool( + self._context, + name=name, + arguments=arguments, + ) + except (MCPToolError, MCPToolNotFoundError) as exc: + return _response_success( + request_id, + result={ + "content": [{"type": "text", "text": str(exc)}], + "isError": True, + }, + ) + + return _response_success( + request_id, + result={ + "content": [ + { + "type": "text", + "text": json.dumps(structured, separators=(",", ":"), sort_keys=True), + } + ], + "structuredContent": structured, + "isError": False, + }, + ) + + return _response_error(request_id, code=-32601, message=f"method not found: {method}") + + def run(self) -> int: + while True: + try: + framed_request = _read_message(self._input_stream) + except json.JSONDecodeError as exc: + response = _response_error(None, code=-32700, message=f"parse error: {exc.msg}") + _write_message( + self._output_stream, + response, + transport_mode=self._transport_mode, + ) + continue + except ValueError as exc: + response = _response_error(None, code=-32600, message=str(exc)) + _write_message( + self._output_stream, + response, + transport_mode=self._transport_mode, + ) + continue + + if framed_request is None: + return 0 + + request, transport_mode = framed_request + self._transport_mode = transport_mode + response = self._handle_request(request) + if response is not None: + _write_message( + self._output_stream, + response, + transport_mode=self._transport_mode, + ) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="alicebot-mcp", + description="Deterministic local MCP server for Alice continuity workflows.", + ) + parser.add_argument( + "--database-url", + default=None, + help="Override database URL. Defaults to settings/env DATABASE_URL.", + ) + parser.add_argument( + "--user-id", + default=None, + help=( + "Override acting user UUID. Defaults to ALICEBOT_AUTH_USER_ID when set, " + f"otherwise {_DEFAULT_MCP_USER_ID}." + ), + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + try: + context = _build_runtime_context(args) + except ValueError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + server = MCPServer( + context=context, + input_stream=sys.stdin.buffer, + output_stream=sys.stdout.buffer, + ) + return server.run() + + +__all__ = ["MCPServer", "build_parser", "main"] + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/apps/api/src/alicebot_api/mcp_tools.py b/apps/api/src/alicebot_api/mcp_tools.py new file mode 100644 index 0000000..583fcf1 --- /dev/null +++ b/apps/api/src/alicebot_api/mcp_tools.py @@ -0,0 +1,791 @@ +from __future__ import annotations + +from collections.abc import Mapping +from contextlib import contextmanager +from dataclasses import dataclass +from datetime import datetime +from uuid import UUID + +from alicebot_api.continuity_capture import ( + ContinuityCaptureValidationError, + capture_continuity_input, +) +from alicebot_api.continuity_open_loops import ( + ContinuityOpenLoopValidationError, + compile_continuity_open_loop_dashboard, +) +from alicebot_api.continuity_recall import ( + ContinuityRecallValidationError, + query_continuity_recall, +) +from alicebot_api.continuity_resumption import ( + ContinuityResumptionValidationError, + compile_continuity_resumption_brief, +) +from alicebot_api.continuity_review import ( + ContinuityReviewNotFoundError, + ContinuityReviewValidationError, + apply_continuity_correction, + get_continuity_review_detail, + list_continuity_review_queue, +) +from alicebot_api.contracts import ( + CONTINUITY_CAPTURE_EXPLICIT_SIGNALS, + CONTINUITY_CORRECTION_ACTIONS, + CONTINUITY_REVIEW_QUEUE_ORDER, + CONTINUITY_RESUMPTION_RECENT_CHANGE_ORDER, + DEFAULT_CONTINUITY_OPEN_LOOP_LIMIT, + DEFAULT_CONTINUITY_RECALL_LIMIT, + DEFAULT_CONTINUITY_RESUMPTION_OPEN_LOOP_LIMIT, + DEFAULT_CONTINUITY_RESUMPTION_RECENT_CHANGES_LIMIT, + DEFAULT_CONTINUITY_REVIEW_LIMIT, + MAX_CONTINUITY_OPEN_LOOP_LIMIT, + MAX_CONTINUITY_RECALL_LIMIT, + MAX_CONTINUITY_RESUMPTION_OPEN_LOOP_LIMIT, + MAX_CONTINUITY_RESUMPTION_RECENT_CHANGES_LIMIT, + MAX_CONTINUITY_REVIEW_LIMIT, + ContinuityCaptureCreateInput, + ContinuityCorrectionInput, + ContinuityOpenLoopDashboardQueryInput, + ContinuityRecallQueryInput, + ContinuityResumptionBriefRequestInput, + ContinuityReviewQueueQueryInput, +) +from alicebot_api.db import user_connection +from alicebot_api.store import ContinuityStore, JsonObject + + +_REVIEW_STATUS_CHOICES = ("correction_ready", "active", "stale", "superseded", "deleted", "all") +_CONTEXT_PACK_ASSEMBLY_VERSION_V0 = "alice_context_pack_v0" + + +class MCPToolError(ValueError): + """Raised when MCP tool input or execution fails.""" + + +class MCPToolNotFoundError(LookupError): + """Raised when an MCP tool name is not supported.""" + + +@dataclass(frozen=True, slots=True) +class MCPRuntimeContext: + database_url: str + user_id: UUID + + +@contextmanager +def _store_context(context: MCPRuntimeContext): + with user_connection(context.database_url, context.user_id) as conn: + yield ContinuityStore(conn) + + +def _normalize_arguments(arguments: object) -> Mapping[str, object]: + if arguments is None: + return {} + if not isinstance(arguments, Mapping): + raise MCPToolError("tool arguments must be a JSON object") + return arguments + + +def _parse_optional_text(arguments: Mapping[str, object], key: str) -> str | None: + value = arguments.get(key) + if value is None: + return None + if not isinstance(value, str): + raise MCPToolError(f"{key} must be a string") + normalized = " ".join(value.split()).strip() + if normalized == "": + return None + return normalized + + +def _parse_required_text(arguments: Mapping[str, object], key: str) -> str: + value = arguments.get(key) + if not isinstance(value, str): + raise MCPToolError(f"{key} is required and must be a string") + normalized = " ".join(value.split()).strip() + if normalized == "": + raise MCPToolError(f"{key} must not be empty") + return normalized + + +def _parse_optional_uuid(arguments: Mapping[str, object], key: str) -> UUID | None: + value = arguments.get(key) + if value is None: + return None + if not isinstance(value, str): + raise MCPToolError(f"{key} must be a UUID string") + try: + return UUID(value) + except ValueError as exc: + raise MCPToolError(f"{key} must be a valid UUID") from exc + + +def _parse_required_uuid(arguments: Mapping[str, object], key: str) -> UUID: + value = _parse_optional_uuid(arguments, key) + if value is None: + raise MCPToolError(f"{key} is required and must be a UUID string") + return value + + +def _parse_optional_datetime(arguments: Mapping[str, object], key: str) -> datetime | None: + value = arguments.get(key) + if value is None: + return None + if not isinstance(value, str): + raise MCPToolError(f"{key} must be an ISO-8601 datetime string") + normalized = value.strip() + if normalized.endswith("Z"): + normalized = normalized[:-1] + "+00:00" + try: + return datetime.fromisoformat(normalized) + except ValueError as exc: + raise MCPToolError(f"{key} must be an ISO-8601 datetime string") from exc + + +def _parse_int( + arguments: Mapping[str, object], + *, + key: str, + default: int, + minimum: int, + maximum: int, +) -> int: + value = arguments.get(key, default) + if isinstance(value, bool): + raise MCPToolError(f"{key} must be an integer") + + if isinstance(value, int): + parsed = value + elif isinstance(value, str): + stripped = value.strip() + if stripped == "": + raise MCPToolError(f"{key} must be an integer") + try: + parsed = int(stripped) + except ValueError as exc: + raise MCPToolError(f"{key} must be an integer") from exc + else: + raise MCPToolError(f"{key} must be an integer") + + if parsed < minimum or parsed > maximum: + raise MCPToolError(f"{key} must be between {minimum} and {maximum}") + return parsed + + +def _parse_optional_json_object(arguments: Mapping[str, object], key: str) -> JsonObject | None: + value = arguments.get(key) + if value is None: + return None + if not isinstance(value, dict): + raise MCPToolError(f"{key} must be a JSON object") + return value + + +def _parse_optional_float(arguments: Mapping[str, object], key: str) -> float | None: + value = arguments.get(key) + if value is None: + return None + if isinstance(value, bool): + raise MCPToolError(f"{key} must be a number") + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + try: + return float(value.strip()) + except ValueError as exc: + raise MCPToolError(f"{key} must be a number") from exc + raise MCPToolError(f"{key} must be a number") + + +def _parse_bool(arguments: Mapping[str, object], *, key: str, default: bool = False) -> bool: + value = arguments.get(key, default) + if isinstance(value, bool): + return value + if isinstance(value, str): + normalized = value.strip().casefold() + if normalized in {"true", "1", "yes"}: + return True + if normalized in {"false", "0", "no"}: + return False + raise MCPToolError(f"{key} must be a boolean") + + +def _build_recall_query(arguments: Mapping[str, object], *, limit: int) -> ContinuityRecallQueryInput: + return ContinuityRecallQueryInput( + query=_parse_optional_text(arguments, "query"), + thread_id=_parse_optional_uuid(arguments, "thread_id"), + task_id=_parse_optional_uuid(arguments, "task_id"), + project=_parse_optional_text(arguments, "project"), + person=_parse_optional_text(arguments, "person"), + since=_parse_optional_datetime(arguments, "since"), + until=_parse_optional_datetime(arguments, "until"), + limit=limit, + ) + + +def _canonicalize_json(value: object) -> object: + if isinstance(value, dict): + return { + key: _canonicalize_json(value[key]) + for key in sorted(value) + } + if isinstance(value, list): + return [_canonicalize_json(item) for item in value] + return value + + +def _recency_sort_key(item: Mapping[str, object]) -> tuple[str, str]: + created_at = str(item.get("created_at", "")) + item_id = str(item.get("id", "")) + return created_at, item_id + + +def _handle_alice_capture(context: MCPRuntimeContext, arguments: Mapping[str, object]) -> JsonObject: + explicit_signal = arguments.get("explicit_signal") + if explicit_signal is not None and not isinstance(explicit_signal, str): + raise MCPToolError("explicit_signal must be a string when provided") + + with _store_context(context) as store: + return capture_continuity_input( + store, + user_id=context.user_id, + request=ContinuityCaptureCreateInput( + raw_content=_parse_required_text(arguments, "raw_content"), + explicit_signal=explicit_signal, + ), + ) + + +def _handle_alice_recall(context: MCPRuntimeContext, arguments: Mapping[str, object]) -> JsonObject: + limit = _parse_int( + arguments, + key="limit", + default=DEFAULT_CONTINUITY_RECALL_LIMIT, + minimum=1, + maximum=MAX_CONTINUITY_RECALL_LIMIT, + ) + + with _store_context(context) as store: + return query_continuity_recall( + store, + user_id=context.user_id, + request=_build_recall_query(arguments, limit=limit), + ) + + +def _handle_alice_resume(context: MCPRuntimeContext, arguments: Mapping[str, object]) -> JsonObject: + max_recent_changes = _parse_int( + arguments, + key="max_recent_changes", + default=DEFAULT_CONTINUITY_RESUMPTION_RECENT_CHANGES_LIMIT, + minimum=0, + maximum=MAX_CONTINUITY_RESUMPTION_RECENT_CHANGES_LIMIT, + ) + max_open_loops = _parse_int( + arguments, + key="max_open_loops", + default=DEFAULT_CONTINUITY_RESUMPTION_OPEN_LOOP_LIMIT, + minimum=0, + maximum=MAX_CONTINUITY_RESUMPTION_OPEN_LOOP_LIMIT, + ) + + with _store_context(context) as store: + return compile_continuity_resumption_brief( + store, + user_id=context.user_id, + request=ContinuityResumptionBriefRequestInput( + query=_parse_optional_text(arguments, "query"), + thread_id=_parse_optional_uuid(arguments, "thread_id"), + task_id=_parse_optional_uuid(arguments, "task_id"), + project=_parse_optional_text(arguments, "project"), + person=_parse_optional_text(arguments, "person"), + since=_parse_optional_datetime(arguments, "since"), + until=_parse_optional_datetime(arguments, "until"), + max_recent_changes=max_recent_changes, + max_open_loops=max_open_loops, + include_non_promotable_facts=_parse_bool( + arguments, + key="include_non_promotable_facts", + default=False, + ), + ), + ) + + +def _handle_alice_open_loops(context: MCPRuntimeContext, arguments: Mapping[str, object]) -> JsonObject: + limit = _parse_int( + arguments, + key="limit", + default=DEFAULT_CONTINUITY_OPEN_LOOP_LIMIT, + minimum=0, + maximum=MAX_CONTINUITY_OPEN_LOOP_LIMIT, + ) + + with _store_context(context) as store: + return compile_continuity_open_loop_dashboard( + store, + user_id=context.user_id, + request=ContinuityOpenLoopDashboardQueryInput( + query=_parse_optional_text(arguments, "query"), + thread_id=_parse_optional_uuid(arguments, "thread_id"), + task_id=_parse_optional_uuid(arguments, "task_id"), + project=_parse_optional_text(arguments, "project"), + person=_parse_optional_text(arguments, "person"), + since=_parse_optional_datetime(arguments, "since"), + until=_parse_optional_datetime(arguments, "until"), + limit=limit, + ), + ) + + +def _recent_decisions_payload( + context: MCPRuntimeContext, + *, + arguments: Mapping[str, object], + limit: int, +) -> JsonObject: + with _store_context(context) as store: + recall_payload = query_continuity_recall( + store, + user_id=context.user_id, + request=_build_recall_query(arguments, limit=MAX_CONTINUITY_RECALL_LIMIT), + apply_limit=False, + ) + + all_decisions = [ + item + for item in recall_payload["items"] + if item["object_type"] == "Decision" + ] + ordered = sorted(all_decisions, key=_recency_sort_key, reverse=True) + items = ordered[:limit] + return { + "items": items, + "summary": { + "scope": recall_payload["summary"]["filters"], + "limit": limit, + "returned_count": len(items), + "total_count": len(all_decisions), + "order": ["created_at_desc", "id_desc"], + }, + } + + +def _handle_alice_recent_decisions(context: MCPRuntimeContext, arguments: Mapping[str, object]) -> JsonObject: + limit = _parse_int( + arguments, + key="limit", + default=DEFAULT_CONTINUITY_RESUMPTION_RECENT_CHANGES_LIMIT, + minimum=1, + maximum=MAX_CONTINUITY_RECALL_LIMIT, + ) + return _recent_decisions_payload(context, arguments=arguments, limit=limit) + + +def _handle_alice_recent_changes(context: MCPRuntimeContext, arguments: Mapping[str, object]) -> JsonObject: + max_recent_changes = _parse_int( + arguments, + key="limit", + default=DEFAULT_CONTINUITY_RESUMPTION_RECENT_CHANGES_LIMIT, + minimum=0, + maximum=MAX_CONTINUITY_RESUMPTION_RECENT_CHANGES_LIMIT, + ) + + with _store_context(context) as store: + resumption_payload = compile_continuity_resumption_brief( + store, + user_id=context.user_id, + request=ContinuityResumptionBriefRequestInput( + query=_parse_optional_text(arguments, "query"), + thread_id=_parse_optional_uuid(arguments, "thread_id"), + task_id=_parse_optional_uuid(arguments, "task_id"), + project=_parse_optional_text(arguments, "project"), + person=_parse_optional_text(arguments, "person"), + since=_parse_optional_datetime(arguments, "since"), + until=_parse_optional_datetime(arguments, "until"), + max_recent_changes=max_recent_changes, + max_open_loops=0, + ), + ) + + brief = resumption_payload["brief"] + return { + "recent_changes": brief["recent_changes"], + "scope": brief["scope"], + "sources": brief["sources"], + "order": list(CONTINUITY_RESUMPTION_RECENT_CHANGE_ORDER), + } + + +def _handle_alice_memory_review(context: MCPRuntimeContext, arguments: Mapping[str, object]) -> JsonObject: + continuity_object_id = _parse_optional_uuid(arguments, "continuity_object_id") + if continuity_object_id is not None: + with _store_context(context) as store: + payload = get_continuity_review_detail( + store, + user_id=context.user_id, + continuity_object_id=continuity_object_id, + ) + return { + "mode": "detail", + "review": payload["review"], + } + + status = arguments.get("status", "correction_ready") + if not isinstance(status, str): + raise MCPToolError("status must be a string") + if status not in _REVIEW_STATUS_CHOICES: + allowed = ", ".join(_REVIEW_STATUS_CHOICES) + raise MCPToolError(f"status must be one of: {allowed}") + limit = _parse_int( + arguments, + key="limit", + default=DEFAULT_CONTINUITY_REVIEW_LIMIT, + minimum=1, + maximum=MAX_CONTINUITY_REVIEW_LIMIT, + ) + + with _store_context(context) as store: + payload = list_continuity_review_queue( + store, + user_id=context.user_id, + request=ContinuityReviewQueueQueryInput( + status=status, + limit=limit, + ), + ) + return { + "mode": "queue", + "items": payload["items"], + "summary": { + **payload["summary"], + "order": list(CONTINUITY_REVIEW_QUEUE_ORDER), + }, + } + + +def _handle_alice_memory_correct(context: MCPRuntimeContext, arguments: Mapping[str, object]) -> JsonObject: + with _store_context(context) as store: + return apply_continuity_correction( + store, + user_id=context.user_id, + continuity_object_id=_parse_required_uuid(arguments, "continuity_object_id"), + request=ContinuityCorrectionInput( + action=_parse_required_text(arguments, "action"), + reason=_parse_optional_text(arguments, "reason"), + title=_parse_optional_text(arguments, "title"), + body=_parse_optional_json_object(arguments, "body"), + provenance=_parse_optional_json_object(arguments, "provenance"), + confidence=_parse_optional_float(arguments, "confidence"), + replacement_title=_parse_optional_text(arguments, "replacement_title"), + replacement_body=_parse_optional_json_object(arguments, "replacement_body"), + replacement_provenance=_parse_optional_json_object(arguments, "replacement_provenance"), + replacement_confidence=_parse_optional_float(arguments, "replacement_confidence"), + ), + ) + + +def _handle_alice_context_pack(context: MCPRuntimeContext, arguments: Mapping[str, object]) -> JsonObject: + open_loops_limit = _parse_int( + arguments, + key="open_loops_limit", + default=DEFAULT_CONTINUITY_RESUMPTION_OPEN_LOOP_LIMIT, + minimum=0, + maximum=MAX_CONTINUITY_RESUMPTION_OPEN_LOOP_LIMIT, + ) + recent_changes_limit = _parse_int( + arguments, + key="recent_changes_limit", + default=DEFAULT_CONTINUITY_RESUMPTION_RECENT_CHANGES_LIMIT, + minimum=0, + maximum=MAX_CONTINUITY_RESUMPTION_RECENT_CHANGES_LIMIT, + ) + recent_decisions_limit = _parse_int( + arguments, + key="recent_decisions_limit", + default=DEFAULT_CONTINUITY_RESUMPTION_RECENT_CHANGES_LIMIT, + minimum=1, + maximum=MAX_CONTINUITY_RECALL_LIMIT, + ) + + with _store_context(context) as store: + resumption_payload = compile_continuity_resumption_brief( + store, + user_id=context.user_id, + request=ContinuityResumptionBriefRequestInput( + query=_parse_optional_text(arguments, "query"), + thread_id=_parse_optional_uuid(arguments, "thread_id"), + task_id=_parse_optional_uuid(arguments, "task_id"), + project=_parse_optional_text(arguments, "project"), + person=_parse_optional_text(arguments, "person"), + since=_parse_optional_datetime(arguments, "since"), + until=_parse_optional_datetime(arguments, "until"), + max_recent_changes=recent_changes_limit, + max_open_loops=open_loops_limit, + ), + ) + + brief = resumption_payload["brief"] + recent_decisions = _recent_decisions_payload( + context, + arguments=arguments, + limit=recent_decisions_limit, + ) + return { + "context_pack": { + "assembly_version": _CONTEXT_PACK_ASSEMBLY_VERSION_V0, + "scope": brief["scope"], + "last_decision": brief["last_decision"], + "next_action": brief["next_action"], + "open_loops": brief["open_loops"], + "recent_changes": brief["recent_changes"], + "recent_decisions": recent_decisions, + "sources": [ + "continuity_capture_events", + "continuity_objects", + "continuity_correction_events", + ], + } + } + + +_TOOL_DEFINITIONS: list[dict[str, object]] = [ + { + "name": "alice_capture", + "description": "Capture continuity input into deterministic continuity objects.", + "inputSchema": { + "type": "object", + "additionalProperties": False, + "required": ["raw_content"], + "properties": { + "raw_content": {"type": "string"}, + "explicit_signal": {"type": "string", "enum": list(CONTINUITY_CAPTURE_EXPLICIT_SIGNALS)}, + }, + }, + }, + { + "name": "alice_recall", + "description": "Recall continuity objects with deterministic ranking and provenance fields.", + "inputSchema": { + "type": "object", + "additionalProperties": False, + "properties": { + "query": {"type": "string"}, + "thread_id": {"type": "string", "format": "uuid"}, + "task_id": {"type": "string", "format": "uuid"}, + "project": {"type": "string"}, + "person": {"type": "string"}, + "since": {"type": "string", "format": "date-time"}, + "until": {"type": "string", "format": "date-time"}, + "limit": {"type": "integer", "minimum": 1, "maximum": MAX_CONTINUITY_RECALL_LIMIT}, + }, + }, + }, + { + "name": "alice_resume", + "description": "Compile continuity resumption brief for decisions, open loops, and next action.", + "inputSchema": { + "type": "object", + "additionalProperties": False, + "properties": { + "query": {"type": "string"}, + "thread_id": {"type": "string", "format": "uuid"}, + "task_id": {"type": "string", "format": "uuid"}, + "project": {"type": "string"}, + "person": {"type": "string"}, + "since": {"type": "string", "format": "date-time"}, + "until": {"type": "string", "format": "date-time"}, + "max_recent_changes": { + "type": "integer", + "minimum": 0, + "maximum": MAX_CONTINUITY_RESUMPTION_RECENT_CHANGES_LIMIT, + }, + "max_open_loops": { + "type": "integer", + "minimum": 0, + "maximum": MAX_CONTINUITY_RESUMPTION_OPEN_LOOP_LIMIT, + }, + "include_non_promotable_facts": {"type": "boolean"}, + }, + }, + }, + { + "name": "alice_open_loops", + "description": "List continuity open loops grouped by deterministic posture sections.", + "inputSchema": { + "type": "object", + "additionalProperties": False, + "properties": { + "query": {"type": "string"}, + "thread_id": {"type": "string", "format": "uuid"}, + "task_id": {"type": "string", "format": "uuid"}, + "project": {"type": "string"}, + "person": {"type": "string"}, + "since": {"type": "string", "format": "date-time"}, + "until": {"type": "string", "format": "date-time"}, + "limit": {"type": "integer", "minimum": 0, "maximum": MAX_CONTINUITY_OPEN_LOOP_LIMIT}, + }, + }, + }, + { + "name": "alice_recent_decisions", + "description": "List most recent continuity decisions in deterministic recency order.", + "inputSchema": { + "type": "object", + "additionalProperties": False, + "properties": { + "query": {"type": "string"}, + "thread_id": {"type": "string", "format": "uuid"}, + "task_id": {"type": "string", "format": "uuid"}, + "project": {"type": "string"}, + "person": {"type": "string"}, + "since": {"type": "string", "format": "date-time"}, + "until": {"type": "string", "format": "date-time"}, + "limit": {"type": "integer", "minimum": 1, "maximum": MAX_CONTINUITY_RECALL_LIMIT}, + }, + }, + }, + { + "name": "alice_recent_changes", + "description": "List recent continuity changes from the shipped resumption assembly logic.", + "inputSchema": { + "type": "object", + "additionalProperties": False, + "properties": { + "query": {"type": "string"}, + "thread_id": {"type": "string", "format": "uuid"}, + "task_id": {"type": "string", "format": "uuid"}, + "project": {"type": "string"}, + "person": {"type": "string"}, + "since": {"type": "string", "format": "date-time"}, + "until": {"type": "string", "format": "date-time"}, + "limit": { + "type": "integer", + "minimum": 0, + "maximum": MAX_CONTINUITY_RESUMPTION_RECENT_CHANGES_LIMIT, + }, + }, + }, + }, + { + "name": "alice_memory_review", + "description": "List correction review queue or fetch review detail for one continuity object.", + "inputSchema": { + "type": "object", + "additionalProperties": False, + "properties": { + "continuity_object_id": {"type": "string", "format": "uuid"}, + "status": {"type": "string", "enum": list(_REVIEW_STATUS_CHOICES)}, + "limit": {"type": "integer", "minimum": 1, "maximum": MAX_CONTINUITY_REVIEW_LIMIT}, + }, + }, + }, + { + "name": "alice_memory_correct", + "description": "Apply deterministic continuity correction actions and return correction evidence.", + "inputSchema": { + "type": "object", + "additionalProperties": False, + "required": ["continuity_object_id", "action"], + "properties": { + "continuity_object_id": {"type": "string", "format": "uuid"}, + "action": {"type": "string", "enum": list(CONTINUITY_CORRECTION_ACTIONS)}, + "reason": {"type": "string"}, + "title": {"type": "string"}, + "body": {"type": "object"}, + "provenance": {"type": "object"}, + "confidence": {"type": "number"}, + "replacement_title": {"type": "string"}, + "replacement_body": {"type": "object"}, + "replacement_provenance": {"type": "object"}, + "replacement_confidence": {"type": "number"}, + }, + }, + }, + { + "name": "alice_context_pack", + "description": "Assemble a deterministic continuity context pack for scoped external-agent use.", + "inputSchema": { + "type": "object", + "additionalProperties": False, + "properties": { + "query": {"type": "string"}, + "thread_id": {"type": "string", "format": "uuid"}, + "task_id": {"type": "string", "format": "uuid"}, + "project": {"type": "string"}, + "person": {"type": "string"}, + "since": {"type": "string", "format": "date-time"}, + "until": {"type": "string", "format": "date-time"}, + "recent_decisions_limit": { + "type": "integer", + "minimum": 1, + "maximum": MAX_CONTINUITY_RECALL_LIMIT, + }, + "recent_changes_limit": { + "type": "integer", + "minimum": 0, + "maximum": MAX_CONTINUITY_RESUMPTION_RECENT_CHANGES_LIMIT, + }, + "open_loops_limit": { + "type": "integer", + "minimum": 0, + "maximum": MAX_CONTINUITY_RESUMPTION_OPEN_LOOP_LIMIT, + }, + }, + }, + }, +] + +_TOOL_HANDLERS = { + "alice_capture": _handle_alice_capture, + "alice_recall": _handle_alice_recall, + "alice_resume": _handle_alice_resume, + "alice_open_loops": _handle_alice_open_loops, + "alice_recent_decisions": _handle_alice_recent_decisions, + "alice_recent_changes": _handle_alice_recent_changes, + "alice_memory_review": _handle_alice_memory_review, + "alice_memory_correct": _handle_alice_memory_correct, + "alice_context_pack": _handle_alice_context_pack, +} + + +def list_mcp_tools() -> list[dict[str, object]]: + return _canonicalize_json(_TOOL_DEFINITIONS) # type: ignore[return-value] + + +def call_mcp_tool( + context: MCPRuntimeContext, + *, + name: str, + arguments: object, +) -> JsonObject: + handler = _TOOL_HANDLERS.get(name) + if handler is None: + raise MCPToolNotFoundError(f"unknown tool '{name}'") + + parsed_arguments = _normalize_arguments(arguments) + try: + payload = handler(context, parsed_arguments) + except ( + ContinuityCaptureValidationError, + ContinuityRecallValidationError, + ContinuityResumptionValidationError, + ContinuityOpenLoopValidationError, + ContinuityReviewValidationError, + ContinuityReviewNotFoundError, + ) as exc: + raise MCPToolError(str(exc)) from exc + except (TypeError, ValueError) as exc: + raise MCPToolError(str(exc)) from exc + + return _canonicalize_json(payload) # type: ignore[return-value] + + +__all__ = [ + "MCPRuntimeContext", + "MCPToolError", + "MCPToolNotFoundError", + "call_mcp_tool", + "list_mcp_tools", +] diff --git a/apps/api/src/alicebot_api/memory.py b/apps/api/src/alicebot_api/memory.py new file mode 100644 index 0000000..ad24044 --- /dev/null +++ b/apps/api/src/alicebot_api/memory.py @@ -0,0 +1,1600 @@ +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +import psycopg + +from alicebot_api.continuity_open_loops import compile_continuity_weekly_review +from alicebot_api.contracts import ( + AdmissionDecisionOutput, + DEFAULT_AGENT_PROFILE_ID, + DEFAULT_MEMORY_CONFIRMATION_STATUS, + DEFAULT_MEMORY_PROMOTION_ELIGIBILITY, + DEFAULT_MEMORY_REVIEW_QUEUE_PRIORITY_MODE, + DEFAULT_MEMORY_TRUST_CLASS, + DEFAULT_MEMORY_TYPE, + DEFAULT_MEMORY_REVIEW_LIMIT, + DEFAULT_OPEN_LOOP_LIMIT, + MEMORY_QUALITY_HIGH_RISK_CONFIDENCE_THRESHOLD, + MEMORY_QUALITY_MIN_ADJUDICATED_SAMPLE, + MEMORY_QUALITY_PRECISION_TARGET, + MEMORY_CONFIRMATION_STATUSES, + MEMORY_PROMOTION_ELIGIBILITIES, + OPEN_LOOP_REVIEW_ORDER, + OPEN_LOOP_STATUSES, + MEMORY_REVIEW_LABEL_ORDER, + MEMORY_REVIEW_LABEL_VALUES, + MEMORY_REVIEW_QUEUE_ORDER_BY_PRIORITY_MODE, + MEMORY_REVIEW_QUEUE_PRIORITY_MODES, + MEMORY_REVISION_REVIEW_ORDER, + MEMORY_REVIEW_ORDER, + MEMORY_TYPES, + MEMORY_TRUST_CLASSES, + MemoryCandidateInput, + MemoryEvaluationSummary, + MemoryEvaluationSummaryResponse, + MemoryReviewLabelCounts, + MemoryReviewLabelCreateResponse, + MemoryReviewLabelListResponse, + MemoryReviewLabelRecord, + MemoryReviewLabelSummary, + MemoryReviewLabelValue, + MemoryReviewQueuePriorityMode, + MemoryReviewQueueItem, + MemoryReviewQueueResponse, + MemoryReviewQueueSummary, + MemoryQualityGateComputationCounts, + MemoryQualityGateResponse, + MemoryQualityReviewAction, + MemoryQualityGateStatus, + MemoryQualityGateSummary, + MemoryTrustCorrectionFreshnessSummary, + MemoryTrustDashboardResponse, + MemoryTrustDashboardSummary, + MemoryTrustQueueAgingSummary, + MemoryTrustQueuePostureSummary, + MemoryTrustRecommendedReview, + MemoryRevisionReviewListResponse, + MemoryRevisionReviewListSummary, + MemoryRevisionReviewRecord, + MemoryReviewDetailResponse, + MemoryReviewListResponse, + MemoryReviewListSummary, + MemoryReviewRecord, + MemoryReviewStatusFilter, + OpenLoopCreateInput, + OpenLoopCreateResponse, + OpenLoopDetailResponse, + OpenLoopListResponse, + OpenLoopListSummary, + OpenLoopRecord, + OpenLoopStatusFilter, + OpenLoopStatusUpdateInput, + OpenLoopStatusUpdateResponse, + PersistedMemoryRecord, + PersistedMemoryRevisionRecord, + ContinuityWeeklyReviewRequestInput, + isoformat_or_none, +) +from alicebot_api.retrieval_evaluation import get_retrieval_evaluation_summary +from alicebot_api.store import ( + ContinuityStore, + EventRow, + JsonObject, + LabelCountRow, + MemoryReviewLabelRow, + MemoryRevisionRow, + MemoryRow, + OpenLoopRow, +) + + +class MemoryAdmissionValidationError(ValueError): + """Raised when an admission request fails explicit candidate validation.""" + + +class MemoryReviewNotFoundError(LookupError): + """Raised when a requested memory is not visible inside the current user scope.""" + + +class OpenLoopValidationError(ValueError): + """Raised when an open-loop request fails explicit lifecycle validation.""" + + +class OpenLoopNotFoundError(LookupError): + """Raised when a requested open loop is not visible inside the current user scope.""" + + +def _serialize_typed_memory_metadata(memory: MemoryRow) -> JsonObject: + payload: JsonObject = {} + + if "memory_type" in memory: + payload["memory_type"] = memory["memory_type"] + if "confidence" in memory: + payload["confidence"] = memory["confidence"] + if "salience" in memory: + payload["salience"] = memory["salience"] + if "confirmation_status" in memory: + payload["confirmation_status"] = memory["confirmation_status"] + if "trust_class" in memory: + payload["trust_class"] = memory["trust_class"] + if "promotion_eligibility" in memory: + payload["promotion_eligibility"] = memory["promotion_eligibility"] + if "evidence_count" in memory: + payload["evidence_count"] = memory["evidence_count"] + if "independent_source_count" in memory: + payload["independent_source_count"] = memory["independent_source_count"] + if "extracted_by_model" in memory: + payload["extracted_by_model"] = memory["extracted_by_model"] + if "trust_reason" in memory: + payload["trust_reason"] = memory["trust_reason"] + if "valid_from" in memory: + payload["valid_from"] = isoformat_or_none(memory["valid_from"]) + if "valid_to" in memory: + payload["valid_to"] = isoformat_or_none(memory["valid_to"]) + if "last_confirmed_at" in memory: + payload["last_confirmed_at"] = isoformat_or_none(memory["last_confirmed_at"]) + + return payload + + +def _serialize_memory(memory: MemoryRow) -> PersistedMemoryRecord: + payload: PersistedMemoryRecord = { + "id": str(memory["id"]), + "user_id": str(memory["user_id"]), + "memory_key": memory["memory_key"], + "value": memory["value"], + "status": memory["status"], + "source_event_ids": memory["source_event_ids"], + "created_at": memory["created_at"].isoformat(), + "updated_at": memory["updated_at"].isoformat(), + "deleted_at": isoformat_or_none(memory["deleted_at"]), + } + payload.update(_serialize_typed_memory_metadata(memory)) + return payload + + +def _serialize_memory_revision(revision: MemoryRevisionRow) -> PersistedMemoryRevisionRecord: + return { + "id": str(revision["id"]), + "user_id": str(revision["user_id"]), + "memory_id": str(revision["memory_id"]), + "sequence_no": revision["sequence_no"], + "action": revision["action"], + "memory_key": revision["memory_key"], + "previous_value": revision["previous_value"], + "new_value": revision["new_value"], + "source_event_ids": revision["source_event_ids"], + "candidate": revision["candidate"], + "created_at": revision["created_at"].isoformat(), + } + + +def _serialize_memory_review(memory: MemoryRow) -> MemoryReviewRecord: + payload: MemoryReviewRecord = { + "id": str(memory["id"]), + "memory_key": memory["memory_key"], + "value": memory["value"], + "status": memory["status"], + "source_event_ids": memory["source_event_ids"], + "created_at": memory["created_at"].isoformat(), + "updated_at": memory["updated_at"].isoformat(), + "deleted_at": isoformat_or_none(memory["deleted_at"]), + } + payload.update(_serialize_typed_memory_metadata(memory)) + return payload + + +def _is_stale_truth_memory(memory: MemoryRow) -> bool: + if memory.get("confirmation_status") == "contested": + return True + return memory.get("valid_to") is not None + + +def _is_high_risk_memory(memory: MemoryRow) -> bool: + if memory.get("promotion_eligibility") == "not_promotable": + return True + if _is_stale_truth_memory(memory): + return True + if memory.get("confirmation_status") != "confirmed": + return True + confidence = memory.get("confidence") + if confidence is None: + return True + return confidence < MEMORY_QUALITY_HIGH_RISK_CONFIDENCE_THRESHOLD + + +def _high_risk_confidence_priority(memory: MemoryRow) -> float: + confidence = memory.get("confidence") + if confidence is None: + return 2.0 + if confidence < MEMORY_QUALITY_HIGH_RISK_CONFIDENCE_THRESHOLD: + return 1.0 - confidence + return 0.0 + + +def _stale_truth_priority(memory: MemoryRow) -> float: + valid_to = memory.get("valid_to") + if valid_to is None: + return float("-inf") + return -valid_to.timestamp() + + +def _order_review_queue_memories( + memories: list[MemoryRow], + *, + priority_mode: MemoryReviewQueuePriorityMode, +) -> list[MemoryRow]: + if priority_mode == "oldest_first": + return sorted( + memories, + key=lambda memory: (memory["updated_at"], memory["created_at"], str(memory["id"])), + ) + + if priority_mode == "high_risk_first": + return sorted( + memories, + key=lambda memory: ( + _is_high_risk_memory(memory), + _high_risk_confidence_priority(memory), + memory["updated_at"], + memory["created_at"], + str(memory["id"]), + ), + reverse=True, + ) + + if priority_mode == "stale_truth_first": + return sorted( + memories, + key=lambda memory: ( + _is_stale_truth_memory(memory), + _stale_truth_priority(memory), + memory["updated_at"], + memory["created_at"], + str(memory["id"]), + ), + reverse=True, + ) + + return sorted( + memories, + key=lambda memory: (memory["updated_at"], memory["created_at"], str(memory["id"])), + reverse=True, + ) + + +def _review_queue_priority_reason( + *, + priority_mode: MemoryReviewQueuePriorityMode, + is_high_risk: bool, + is_stale_truth: bool, + is_promotable: bool, +) -> str: + if not is_promotable: + if priority_mode == "oldest_first": + return "oldest_not_promotable" + if priority_mode == "recent_first": + return "recent_not_promotable" + if priority_mode == "stale_truth_first" and is_stale_truth: + return "stale_truth_not_promotable" + return "high_risk_not_promotable" + + if priority_mode == "high_risk_first": + if is_high_risk and is_stale_truth: + return "high_risk_stale_truth" + if is_high_risk: + return "high_risk" + if is_stale_truth: + return "stale_truth" + return "recent_backlog" + + if priority_mode == "stale_truth_first": + if is_stale_truth and is_high_risk: + return "stale_truth_high_risk" + if is_stale_truth: + return "stale_truth" + if is_high_risk: + return "high_risk" + return "recent_backlog" + + if priority_mode == "oldest_first": + return "oldest_first" + + return "recent_first" + + +def _serialize_memory_review_queue_item( + memory: MemoryRow, + *, + priority_mode: MemoryReviewQueuePriorityMode, +) -> MemoryReviewQueueItem: + is_high_risk = _is_high_risk_memory(memory) + is_stale_truth = _is_stale_truth_memory(memory) + is_promotable = memory.get("promotion_eligibility") != "not_promotable" + payload: MemoryReviewQueueItem = { + "id": str(memory["id"]), + "memory_key": memory["memory_key"], + "value": memory["value"], + "status": memory["status"], + "source_event_ids": memory["source_event_ids"], + "is_high_risk": is_high_risk, + "is_stale_truth": is_stale_truth, + "is_promotable": is_promotable, + "queue_priority_mode": priority_mode, + "priority_reason": _review_queue_priority_reason( + priority_mode=priority_mode, + is_high_risk=is_high_risk, + is_stale_truth=is_stale_truth, + is_promotable=is_promotable, + ), + "created_at": memory["created_at"].isoformat(), + "updated_at": memory["updated_at"].isoformat(), + } + payload.update(_serialize_typed_memory_metadata(memory)) + return payload + + +def _serialize_memory_revision_review(revision: MemoryRevisionRow) -> MemoryRevisionReviewRecord: + return { + "id": str(revision["id"]), + "memory_id": str(revision["memory_id"]), + "sequence_no": revision["sequence_no"], + "action": revision["action"], + "memory_key": revision["memory_key"], + "previous_value": revision["previous_value"], + "new_value": revision["new_value"], + "source_event_ids": revision["source_event_ids"], + "created_at": revision["created_at"].isoformat(), + } + + +def _serialize_memory_review_label(label: MemoryReviewLabelRow) -> MemoryReviewLabelRecord: + return { + "id": str(label["id"]), + "memory_id": str(label["memory_id"]), + "reviewer_user_id": str(label["user_id"]), + "label": label["label"], + "note": label["note"], + "created_at": label["created_at"].isoformat(), + } + + +def _empty_memory_review_label_counts() -> MemoryReviewLabelCounts: + return { + "correct": 0, + "incorrect": 0, + "outdated": 0, + "insufficient_evidence": 0, + } + + +def _summarize_memory_review_label_counts(rows: list[LabelCountRow]) -> MemoryReviewLabelCounts: + counts = _empty_memory_review_label_counts() + for row in rows: + label = row["label"] + if label in counts: + counts[label] = row["count"] + return counts + + +def _build_memory_review_label_summary( + *, + memory_id: UUID, + counts: MemoryReviewLabelCounts, +) -> MemoryReviewLabelSummary: + return { + "memory_id": str(memory_id), + "total_count": sum(counts.values()), + "counts_by_label": counts, + "order": list(MEMORY_REVIEW_LABEL_ORDER), + } + + +def _normalize_memory_status_filter(status: MemoryReviewStatusFilter) -> str | None: + if status == "all": + return None + return status + + +def list_memory_review_records( + store: ContinuityStore, + *, + user_id: UUID, + status: MemoryReviewStatusFilter = "active", + limit: int = DEFAULT_MEMORY_REVIEW_LIMIT, +) -> MemoryReviewListResponse: + del user_id + + normalized_status = _normalize_memory_status_filter(status) + total_count = store.count_memories(status=normalized_status) + memories = store.list_review_memories(status=normalized_status, limit=limit) + items = [_serialize_memory_review(memory) for memory in memories] + summary: MemoryReviewListSummary = { + "status": status, + "limit": limit, + "returned_count": len(items), + "total_count": total_count, + "has_more": len(items) < total_count, + "order": list(MEMORY_REVIEW_ORDER), + } + return { + "items": items, + "summary": summary, + } + + +def list_memory_review_queue_records( + store: ContinuityStore, + *, + user_id: UUID, + limit: int = DEFAULT_MEMORY_REVIEW_LIMIT, + priority_mode: MemoryReviewQueuePriorityMode = DEFAULT_MEMORY_REVIEW_QUEUE_PRIORITY_MODE, +) -> MemoryReviewQueueResponse: + del user_id + + candidate_memories = store.list_unlabeled_review_memories(limit=None) + ordered_memories = _order_review_queue_memories( + candidate_memories, + priority_mode=priority_mode, + ) + selected_memories = ordered_memories[:limit] + items = [ + _serialize_memory_review_queue_item( + memory, + priority_mode=priority_mode, + ) + for memory in selected_memories + ] + total_count = len(candidate_memories) + summary: MemoryReviewQueueSummary = { + "memory_status": "active", + "review_state": "unlabeled", + "priority_mode": priority_mode, + "available_priority_modes": list(MEMORY_REVIEW_QUEUE_PRIORITY_MODES), + "limit": limit, + "returned_count": len(items), + "total_count": total_count, + "has_more": len(items) < total_count, + "order": list(MEMORY_REVIEW_QUEUE_ORDER_BY_PRIORITY_MODE[priority_mode]), + } + return { + "items": items, + "summary": summary, + } + + +def get_memory_review_record( + store: ContinuityStore, + *, + user_id: UUID, + memory_id: UUID, +) -> MemoryReviewDetailResponse: + del user_id + + memory = store.get_memory_optional(memory_id) + if memory is None: + raise MemoryReviewNotFoundError(f"memory {memory_id} was not found") + + return { + "memory": _serialize_memory_review(memory), + } + + +def list_memory_revision_review_records( + store: ContinuityStore, + *, + user_id: UUID, + memory_id: UUID, + limit: int = DEFAULT_MEMORY_REVIEW_LIMIT, +) -> MemoryRevisionReviewListResponse: + del user_id + + memory = store.get_memory_optional(memory_id) + if memory is None: + raise MemoryReviewNotFoundError(f"memory {memory_id} was not found") + + total_count = store.count_memory_revisions(memory_id) + revisions = store.list_memory_revisions(memory_id, limit=limit) + items = [_serialize_memory_revision_review(revision) for revision in revisions] + summary: MemoryRevisionReviewListSummary = { + "memory_id": str(memory["id"]), + "limit": limit, + "returned_count": len(items), + "total_count": total_count, + "has_more": len(items) < total_count, + "order": list(MEMORY_REVISION_REVIEW_ORDER), + } + return { + "items": items, + "summary": summary, + } + + +def create_memory_review_label_record( + store: ContinuityStore, + *, + user_id: UUID, + memory_id: UUID, + label: MemoryReviewLabelValue, + note: str | None, +) -> MemoryReviewLabelCreateResponse: + del user_id + + memory = store.get_memory_optional(memory_id) + if memory is None: + raise MemoryReviewNotFoundError(f"memory {memory_id} was not found") + + created_label = store.create_memory_review_label( + memory_id=memory_id, + label=label, + note=note, + ) + counts = _summarize_memory_review_label_counts(store.list_memory_review_label_counts(memory_id)) + return { + "label": _serialize_memory_review_label(created_label), + "summary": _build_memory_review_label_summary(memory_id=memory_id, counts=counts), + } + + +def list_memory_review_label_records( + store: ContinuityStore, + *, + user_id: UUID, + memory_id: UUID, +) -> MemoryReviewLabelListResponse: + del user_id + + memory = store.get_memory_optional(memory_id) + if memory is None: + raise MemoryReviewNotFoundError(f"memory {memory_id} was not found") + + items = [_serialize_memory_review_label(label) for label in store.list_memory_review_labels(memory_id)] + counts = _summarize_memory_review_label_counts(store.list_memory_review_label_counts(memory_id)) + return { + "items": items, + "summary": _build_memory_review_label_summary(memory_id=memory_id, counts=counts), + } + + +def get_memory_evaluation_summary( + store: ContinuityStore, + *, + user_id: UUID, +) -> MemoryEvaluationSummaryResponse: + del user_id + + total_memory_count = store.count_memories() + active_memory_count = store.count_memories(status="active") + deleted_memory_count = store.count_memories(status="deleted") + labeled_memory_count = store.count_labeled_memories() + unlabeled_memory_count = store.count_unlabeled_memories() + label_row_counts = _summarize_memory_review_label_counts(store.list_all_memory_review_label_counts()) + summary: MemoryEvaluationSummary = { + "total_memory_count": total_memory_count, + "active_memory_count": active_memory_count, + "deleted_memory_count": deleted_memory_count, + "labeled_memory_count": labeled_memory_count, + "unlabeled_memory_count": unlabeled_memory_count, + "total_label_row_count": sum(label_row_counts.values()), + "label_row_counts_by_value": label_row_counts, + "label_value_order": list(MEMORY_REVIEW_LABEL_VALUES), + } + return { + "summary": summary, + } + + +def _calculate_memory_precision(*, correct_count: int, incorrect_count: int) -> float | None: + denominator = correct_count + incorrect_count + if denominator == 0: + return None + return correct_count / denominator + + +def _queue_age_hours(*, anchor_updated_at: datetime, updated_at: datetime) -> float: + age_hours = (anchor_updated_at - updated_at).total_seconds() / 3600.0 + return max(0.0, age_hours) + + +def _summarize_queue_aging(queue_memories: list[MemoryRow]) -> MemoryTrustQueueAgingSummary: + if not queue_memories: + return { + "anchor_updated_at": None, + "newest_updated_at": None, + "oldest_updated_at": None, + "backlog_span_hours": 0.0, + "fresh_within_24h_count": 0, + "aging_24h_to_72h_count": 0, + "stale_over_72h_count": 0, + } + + newest_updated_at = max(memory["updated_at"] for memory in queue_memories) + oldest_updated_at = min(memory["updated_at"] for memory in queue_memories) + fresh_count = 0 + aging_count = 0 + stale_count = 0 + + for memory in queue_memories: + age_hours = _queue_age_hours( + anchor_updated_at=newest_updated_at, + updated_at=memory["updated_at"], + ) + if age_hours <= 24.0: + fresh_count += 1 + elif age_hours <= 72.0: + aging_count += 1 + else: + stale_count += 1 + + backlog_span_hours = max( + 0.0, + (newest_updated_at - oldest_updated_at).total_seconds() / 3600.0, + ) + return { + "anchor_updated_at": newest_updated_at.isoformat(), + "newest_updated_at": newest_updated_at.isoformat(), + "oldest_updated_at": oldest_updated_at.isoformat(), + "backlog_span_hours": round(backlog_span_hours, 6), + "fresh_within_24h_count": fresh_count, + "aging_24h_to_72h_count": aging_count, + "stale_over_72h_count": stale_count, + } + + +def _summarize_queue_posture( + *, + queue_memories: list[MemoryRow], + priority_mode: MemoryReviewQueuePriorityMode, +) -> MemoryTrustQueuePostureSummary: + high_risk_count = 0 + stale_truth_count = 0 + priority_reason_counts: dict[str, int] = {} + + for memory in queue_memories: + is_high_risk = _is_high_risk_memory(memory) + is_stale_truth = _is_stale_truth_memory(memory) + if is_high_risk: + high_risk_count += 1 + if is_stale_truth: + stale_truth_count += 1 + + reason = _review_queue_priority_reason( + priority_mode=priority_mode, + is_high_risk=is_high_risk, + is_stale_truth=is_stale_truth, + is_promotable=memory.get("promotion_eligibility") != "not_promotable", + ) + priority_reason_counts[reason] = priority_reason_counts.get(reason, 0) + 1 + + return { + "priority_mode": priority_mode, + "total_count": len(queue_memories), + "high_risk_count": high_risk_count, + "stale_truth_count": stale_truth_count, + "priority_reason_counts": { + reason: priority_reason_counts[reason] for reason in sorted(priority_reason_counts) + }, + "order": list(MEMORY_REVIEW_QUEUE_ORDER_BY_PRIORITY_MODE[priority_mode]), + "aging": _summarize_queue_aging(queue_memories), + } + + +def _determine_recommended_review( + *, + quality_gate: MemoryQualityGateSummary, + queue_posture: MemoryTrustQueuePostureSummary, + correction_freshness: MemoryTrustCorrectionFreshnessSummary, +) -> MemoryTrustRecommendedReview: + action: MemoryQualityReviewAction + priority_mode: MemoryReviewQueuePriorityMode + reason: str + + if quality_gate["remaining_to_minimum_sample"] > 0: + action = "adjudicate_minimum_sample" + priority_mode = "recent_first" + reason = ( + "Adjudicated sample is below minimum threshold; prioritize recent backlog " + "to reach quality-gate sample sufficiency." + ) + elif queue_posture["high_risk_count"] > 0: + action = "review_high_risk_queue" + priority_mode = "high_risk_first" + reason = "High-risk unlabeled memories are present; triage those before lower-risk backlog." + elif queue_posture["stale_truth_count"] > 0: + action = "review_stale_truth_queue" + priority_mode = "stale_truth_first" + reason = "Stale-truth unlabeled memories are present; resolve stale truth before newer backlog." + elif queue_posture["total_count"] > 0: + action = "drain_unlabeled_queue" + priority_mode = "oldest_first" + reason = "Unlabeled backlog remains; drain oldest-first for deterministic queue hygiene." + elif correction_freshness["correction_recurrence_count"] > 0: + action = "investigate_correction_recurrence" + priority_mode = "recent_first" + reason = ( + "Queue is clear but recurring correction patterns are present; inspect recent corrections " + "for repeated quality misses." + ) + elif correction_freshness["freshness_drift_count"] > 0: + action = "remediate_freshness_drift" + priority_mode = "stale_truth_first" + reason = "Queue is clear but freshness drift is present; prioritize stale truth remediation." + else: + action = "monitor_quality_posture" + priority_mode = "recent_first" + reason = "Quality posture is stable; continue deterministic monitoring with recent-first review." + + return { + "priority_mode": priority_mode, + "action": action, + "reason": reason, + } + + +def _is_missing_continuity_table_error(exc: psycopg.errors.UndefinedTable) -> bool: + message = str(exc) + return ( + "continuity_objects" in message + or "continuity_capture_events" in message + or "continuity_correction_events" in message + ) + + +def _summarize_correction_freshness( + store: ContinuityStore, + *, + user_id: UUID, +) -> MemoryTrustCorrectionFreshnessSummary: + try: + weekly_rollup = compile_continuity_weekly_review( + store, + user_id=user_id, + request=ContinuityWeeklyReviewRequestInput(), + )["review"]["rollup"] + except psycopg.errors.UndefinedTable as exc: + if not _is_missing_continuity_table_error(exc): + raise + return { + "total_open_loop_count": 0, + "stale_open_loop_count": 0, + "correction_recurrence_count": 0, + "freshness_drift_count": 0, + } + + return { + "total_open_loop_count": weekly_rollup["total_count"], + "stale_open_loop_count": weekly_rollup["stale_count"], + "correction_recurrence_count": weekly_rollup["correction_recurrence_count"], + "freshness_drift_count": weekly_rollup["freshness_drift_count"], + } + + +def _determine_memory_quality_gate_status( + *, + adjudicated_sample_count: int, + minimum_adjudicated_sample: int, + precision: float | None, + precision_target: float, + unlabeled_memory_count: int, + high_risk_memory_count: int, + stale_truth_count: int, + superseded_active_conflict_count: int, +) -> MemoryQualityGateStatus: + if adjudicated_sample_count < minimum_adjudicated_sample: + return "insufficient_sample" + + if precision is None or precision < precision_target: + return "degraded" + + if superseded_active_conflict_count > 0: + return "degraded" + + if unlabeled_memory_count > 0 or high_risk_memory_count > 0 or stale_truth_count > 0: + return "needs_review" + + return "healthy" + + +def _count_superseded_active_conflicts( + store: ContinuityStore, + *, + active_memories: list[MemoryRow], +) -> int: + conflicted_count = 0 + for memory in active_memories: + counts = _summarize_memory_review_label_counts( + store.list_memory_review_label_counts(memory["id"]) + ) + if counts["outdated"] > 0: + conflicted_count += 1 + return conflicted_count + + +def get_memory_quality_gate_summary( + store: ContinuityStore, + *, + user_id: UUID, +) -> MemoryQualityGateResponse: + del user_id + + active_memory_count = store.count_memories(status="active") + active_memories = ( + [] + if active_memory_count == 0 + else store.list_review_memories(status="active", limit=active_memory_count) + ) + unlabeled_memory_count = store.count_unlabeled_review_memories() + high_risk_memory_count = sum(1 for memory in active_memories if _is_high_risk_memory(memory)) + stale_truth_count = sum(1 for memory in active_memories if _is_stale_truth_memory(memory)) + active_label_counts = _summarize_memory_review_label_counts( + store.list_active_memory_review_label_counts() + ) + adjudicated_correct_count = active_label_counts["correct"] + adjudicated_incorrect_count = active_label_counts["incorrect"] + adjudicated_sample_count = adjudicated_correct_count + adjudicated_incorrect_count + precision = _calculate_memory_precision( + correct_count=adjudicated_correct_count, + incorrect_count=adjudicated_incorrect_count, + ) + minimum_adjudicated_sample = MEMORY_QUALITY_MIN_ADJUDICATED_SAMPLE + precision_target = MEMORY_QUALITY_PRECISION_TARGET + remaining_to_minimum_sample = max(0, minimum_adjudicated_sample - adjudicated_sample_count) + labeled_active_memory_count = max(0, active_memory_count - unlabeled_memory_count) + superseded_active_conflict_count = _count_superseded_active_conflicts( + store, + active_memories=active_memories, + ) + + counts: MemoryQualityGateComputationCounts = { + "active_memory_count": active_memory_count, + "labeled_active_memory_count": labeled_active_memory_count, + "adjudicated_correct_count": adjudicated_correct_count, + "adjudicated_incorrect_count": adjudicated_incorrect_count, + "outdated_label_count": active_label_counts["outdated"], + "insufficient_evidence_label_count": active_label_counts["insufficient_evidence"], + } + summary: MemoryQualityGateSummary = { + "status": _determine_memory_quality_gate_status( + adjudicated_sample_count=adjudicated_sample_count, + minimum_adjudicated_sample=minimum_adjudicated_sample, + precision=precision, + precision_target=precision_target, + unlabeled_memory_count=unlabeled_memory_count, + high_risk_memory_count=high_risk_memory_count, + stale_truth_count=stale_truth_count, + superseded_active_conflict_count=superseded_active_conflict_count, + ), + "precision": precision, + "precision_target": precision_target, + "adjudicated_sample_count": adjudicated_sample_count, + "minimum_adjudicated_sample": minimum_adjudicated_sample, + "remaining_to_minimum_sample": remaining_to_minimum_sample, + "unlabeled_memory_count": unlabeled_memory_count, + "high_risk_memory_count": high_risk_memory_count, + "stale_truth_count": stale_truth_count, + "superseded_active_conflict_count": superseded_active_conflict_count, + "counts": counts, + } + return { + "summary": summary, + } + + +def get_memory_trust_dashboard_summary( + store: ContinuityStore, + *, + user_id: UUID, +) -> MemoryTrustDashboardResponse: + quality_gate_summary = get_memory_quality_gate_summary( + store, + user_id=user_id, + )["summary"] + + queue_priority_mode = DEFAULT_MEMORY_REVIEW_QUEUE_PRIORITY_MODE + unlabeled_queue_count = store.count_unlabeled_review_memories() + queue_candidates = ( + [] + if unlabeled_queue_count == 0 + else store.list_unlabeled_review_memories(limit=None) + ) + queue_memories = _order_review_queue_memories( + queue_candidates, + priority_mode=queue_priority_mode, + ) + queue_posture = _summarize_queue_posture( + queue_memories=queue_memories, + priority_mode=queue_priority_mode, + ) + + retrieval_quality_summary = get_retrieval_evaluation_summary( + store, + user_id=user_id, + )["summary"] + correction_freshness = _summarize_correction_freshness( + store, + user_id=user_id, + ) + recommended_review = _determine_recommended_review( + quality_gate=quality_gate_summary, + queue_posture=queue_posture, + correction_freshness=correction_freshness, + ) + + dashboard: MemoryTrustDashboardSummary = { + "quality_gate": quality_gate_summary, + "queue_posture": queue_posture, + "retrieval_quality": retrieval_quality_summary, + "correction_freshness": correction_freshness, + "recommended_review": recommended_review, + "sources": [ + "memories", + "memory_review_labels", + "continuity_recall", + "continuity_correction_events", + "retrieval_evaluation_fixtures", + ], + } + return {"dashboard": dashboard} + + +def _serialize_open_loop(open_loop: OpenLoopRow) -> OpenLoopRecord: + return { + "id": str(open_loop["id"]), + "memory_id": None if open_loop["memory_id"] is None else str(open_loop["memory_id"]), + "title": open_loop["title"], + "status": open_loop["status"], + "opened_at": open_loop["opened_at"].isoformat(), + "due_at": isoformat_or_none(open_loop["due_at"]), + "resolved_at": isoformat_or_none(open_loop["resolved_at"]), + "resolution_note": open_loop["resolution_note"], + "created_at": open_loop["created_at"].isoformat(), + "updated_at": open_loop["updated_at"].isoformat(), + } + + +def _normalize_open_loop_status_filter(status: OpenLoopStatusFilter) -> str | None: + if status == "all": + return None + return status + + +def _normalize_open_loop_title( + title: str, + *, + error_prefix: str, + error_type: type[ValueError], +) -> str: + normalized = title.strip() + if not normalized: + raise error_type(f"{error_prefix} must be a non-empty string") + if len(normalized) > 280: + raise error_type(f"{error_prefix} must be 280 characters or fewer") + return normalized + + +def _normalize_open_loop_resolution_note(note: str | None) -> str | None: + if note is None: + return None + normalized = note.strip() + if not normalized: + raise OpenLoopValidationError("resolution_note must be a non-empty string when provided") + if len(normalized) > 2000: + raise OpenLoopValidationError("resolution_note must be 2000 characters or fewer") + return normalized + + +def _validate_open_loop_status(status: str) -> str: + if status not in OPEN_LOOP_STATUSES: + allowed_values = ", ".join(OPEN_LOOP_STATUSES) + raise OpenLoopValidationError(f"status must be one of: {allowed_values}") + return status + + +def list_open_loop_records( + store: ContinuityStore, + *, + user_id: UUID, + status: OpenLoopStatusFilter = "open", + limit: int = DEFAULT_OPEN_LOOP_LIMIT, +) -> OpenLoopListResponse: + del user_id + + normalized_status = _normalize_open_loop_status_filter(status) + total_count = store.count_open_loops(status=normalized_status) + open_loops = store.list_open_loops(status=normalized_status, limit=limit) + items = [_serialize_open_loop(open_loop) for open_loop in open_loops] + summary: OpenLoopListSummary = { + "status": status, + "limit": limit, + "returned_count": len(items), + "total_count": total_count, + "has_more": len(items) < total_count, + "order": list(OPEN_LOOP_REVIEW_ORDER), + } + return { + "items": items, + "summary": summary, + } + + +def get_open_loop_record( + store: ContinuityStore, + *, + user_id: UUID, + open_loop_id: UUID, +) -> OpenLoopDetailResponse: + del user_id + + open_loop = store.get_open_loop_optional(open_loop_id) + if open_loop is None: + raise OpenLoopNotFoundError(f"open loop {open_loop_id} was not found") + return { + "open_loop": _serialize_open_loop(open_loop), + } + + +def create_open_loop_record( + store: ContinuityStore, + *, + user_id: UUID, + open_loop: OpenLoopCreateInput, +) -> OpenLoopCreateResponse: + del user_id + + if open_loop.memory_id is not None: + memory = store.get_memory_optional(open_loop.memory_id) + if memory is None: + raise OpenLoopValidationError( + "memory_id must reference an existing memory owned by the user" + ) + + created = store.create_open_loop( + memory_id=open_loop.memory_id, + title=_normalize_open_loop_title( + open_loop.title, + error_prefix="title", + error_type=OpenLoopValidationError, + ), + status="open", + opened_at=None, + due_at=open_loop.due_at, + resolved_at=None, + resolution_note=None, + ) + return { + "open_loop": _serialize_open_loop(created), + } + + +def update_open_loop_status_record( + store: ContinuityStore, + *, + user_id: UUID, + open_loop_id: UUID, + request: OpenLoopStatusUpdateInput, +) -> OpenLoopStatusUpdateResponse: + del user_id + + existing = store.get_open_loop_optional(open_loop_id) + if existing is None: + raise OpenLoopNotFoundError(f"open loop {open_loop_id} was not found") + + normalized_status = _validate_open_loop_status(request.status) + if normalized_status == "open": + raise OpenLoopValidationError("status transition must be resolved or dismissed") + if existing["status"] != "open": + raise OpenLoopValidationError("open loop status can only transition from open") + + updated = store.update_open_loop_status_optional( + open_loop_id=open_loop_id, + status=normalized_status, + resolved_at=None, + resolution_note=_normalize_open_loop_resolution_note(request.resolution_note), + ) + if updated is None: + raise OpenLoopNotFoundError(f"open loop {open_loop_id} was not found") + + return { + "open_loop": _serialize_open_loop(updated), + } + + +def _dedupe_source_event_ids(source_event_ids: tuple[UUID, ...]) -> tuple[UUID, ...]: + deduped: list[UUID] = [] + seen: set[UUID] = set() + for source_event_id in source_event_ids: + if source_event_id in seen: + continue + seen.add(source_event_id) + deduped.append(source_event_id) + return tuple(deduped) + + +def _resolve_agent_profile_id_from_source_events( + store: ContinuityStore, + *, + source_events: list[EventRow], +) -> str: + thread_ids = sorted({event["thread_id"] for event in source_events}, key=str) + if not thread_ids: + return DEFAULT_AGENT_PROFILE_ID + + resolved_profile_ids: set[str] = set() + for thread_id in thread_ids: + thread = store.get_thread_optional(thread_id) + if thread is None: + raise MemoryAdmissionValidationError( + f"source_event thread {thread_id} was not found" + ) + resolved_profile_ids.add(str(thread.get("agent_profile_id", DEFAULT_AGENT_PROFILE_ID))) + + if len(resolved_profile_ids) > 1: + raise MemoryAdmissionValidationError( + "source_event_ids must all belong to threads with the same agent_profile_id" + ) + return next(iter(resolved_profile_ids)) + + +def _resolve_memory_agent_profile_id( + store: ContinuityStore, + *, + candidate: MemoryCandidateInput, + derived_agent_profile_id: str, +) -> str: + candidate_profile_id = candidate.agent_profile_id + resolved_profile_id = ( + derived_agent_profile_id if candidate_profile_id is None else candidate_profile_id + ) + if store.get_agent_profile_optional(resolved_profile_id) is None: + raise MemoryAdmissionValidationError( + f"agent_profile_id must reference an existing profile: {resolved_profile_id}" + ) + if candidate_profile_id is not None and candidate_profile_id != derived_agent_profile_id: + raise MemoryAdmissionValidationError( + "agent_profile_id must match the profile resolved from source_event_ids" + ) + return resolved_profile_id + + +def _validate_source_events( + store: ContinuityStore, + source_event_ids: tuple[UUID, ...], +) -> tuple[list[str], str]: + normalized_event_ids = _dedupe_source_event_ids(source_event_ids) + if not normalized_event_ids: + raise MemoryAdmissionValidationError( + "source_event_ids must include at least one existing event owned by the user" + ) + source_events = store.list_events_by_ids(list(normalized_event_ids)) + found_event_ids = {event["id"] for event in source_events} + missing_event_ids = [ + str(source_event_id) + for source_event_id in normalized_event_ids + if source_event_id not in found_event_ids + ] + if missing_event_ids: + raise MemoryAdmissionValidationError( + "source_event_ids must all reference existing events owned by the user: " + + ", ".join(missing_event_ids) + ) + derived_profile_id = _resolve_agent_profile_id_from_source_events( + store, + source_events=source_events, + ) + return [str(source_event_id) for source_event_id in normalized_event_ids], derived_profile_id + + +def _candidate_payload( + candidate: MemoryCandidateInput, + *, + resolved_agent_profile_id: str, +) -> JsonObject: + payload = candidate.as_payload() + payload["agent_profile_id"] = resolved_agent_profile_id + return payload + + +def _create_open_loop_for_memory( + store: ContinuityStore, + *, + candidate: MemoryCandidateInput, + memory: MemoryRow, +) -> OpenLoopRecord | None: + if candidate.open_loop is None: + return None + + created = store.create_open_loop( + memory_id=memory["id"], + title=_normalize_open_loop_title( + candidate.open_loop.title, + error_prefix="open_loop.title", + error_type=MemoryAdmissionValidationError, + ), + status="open", + opened_at=None, + due_at=candidate.open_loop.due_at, + resolved_at=None, + resolution_note=None, + ) + return _serialize_open_loop(created) + + +def _validate_memory_type(memory_type: str | None) -> str | None: + if memory_type is None: + return None + if memory_type not in MEMORY_TYPES: + allowed_values = ", ".join(MEMORY_TYPES) + raise MemoryAdmissionValidationError(f"memory_type must be one of: {allowed_values}") + return memory_type + + +def _validate_confirmation_status(confirmation_status: str | None) -> str | None: + if confirmation_status is None: + return None + if confirmation_status not in MEMORY_CONFIRMATION_STATUSES: + allowed_values = ", ".join(MEMORY_CONFIRMATION_STATUSES) + raise MemoryAdmissionValidationError( + f"confirmation_status must be one of: {allowed_values}" + ) + return confirmation_status + + +def _validate_trust_class(trust_class: str | None) -> str | None: + if trust_class is None: + return None + if trust_class not in MEMORY_TRUST_CLASSES: + allowed_values = ", ".join(MEMORY_TRUST_CLASSES) + raise MemoryAdmissionValidationError(f"trust_class must be one of: {allowed_values}") + return trust_class + + +def _validate_promotion_eligibility(promotion_eligibility: str | None) -> str | None: + if promotion_eligibility is None: + return None + if promotion_eligibility not in MEMORY_PROMOTION_ELIGIBILITIES: + allowed_values = ", ".join(MEMORY_PROMOTION_ELIGIBILITIES) + raise MemoryAdmissionValidationError( + f"promotion_eligibility must be one of: {allowed_values}" + ) + return promotion_eligibility + + +def _default_promotion_eligibility_for_trust_class(trust_class: str) -> str: + if trust_class == "llm_single_source": + return "not_promotable" + return DEFAULT_MEMORY_PROMOTION_ELIGIBILITY + + +def _validate_score(name: str, score: float | None) -> float | None: + if score is None: + return None + normalized = float(score) + if normalized < 0.0 or normalized > 1.0: + raise MemoryAdmissionValidationError(f"{name} must be between 0.0 and 1.0") + return normalized + + +def _validate_temporal_range(valid_from: datetime | None, valid_to: datetime | None) -> None: + if valid_from is not None and valid_to is not None and valid_to < valid_from: + raise MemoryAdmissionValidationError("valid_to must be greater than or equal to valid_from") + + +def _validate_count(name: str, value: int | None) -> int | None: + if value is None: + return None + normalized = int(value) + if normalized < 0: + raise MemoryAdmissionValidationError(f"{name} must be greater than or equal to 0") + return normalized + + +def _normalize_optional_text(name: str, value: str | None) -> str | None: + if value is None: + return None + normalized = " ".join(value.split()).strip() + if normalized == "": + raise MemoryAdmissionValidationError(f"{name} must not be empty") + return normalized + + +def _resolve_memory_typed_metadata( + *, + existing_memory: MemoryRow | None, + candidate: MemoryCandidateInput, +) -> dict[str, object]: + memory_type = _validate_memory_type(candidate.memory_type) + confirmation_status = _validate_confirmation_status(candidate.confirmation_status) + trust_class = _validate_trust_class(candidate.trust_class) + promotion_eligibility = _validate_promotion_eligibility(candidate.promotion_eligibility) + confidence = _validate_score("confidence", candidate.confidence) + salience = _validate_score("salience", candidate.salience) + evidence_count = _validate_count("evidence_count", candidate.evidence_count) + independent_source_count = _validate_count( + "independent_source_count", + candidate.independent_source_count, + ) + extracted_by_model = _normalize_optional_text("extracted_by_model", candidate.extracted_by_model) + trust_reason = _normalize_optional_text("trust_reason", candidate.trust_reason) + _validate_temporal_range(candidate.valid_from, candidate.valid_to) + + if existing_memory is None: + resolved_trust_class = trust_class or DEFAULT_MEMORY_TRUST_CLASS + return { + "memory_type": memory_type or DEFAULT_MEMORY_TYPE, + "confidence": confidence, + "salience": salience, + "confirmation_status": confirmation_status or DEFAULT_MEMORY_CONFIRMATION_STATUS, + "trust_class": resolved_trust_class, + "promotion_eligibility": ( + promotion_eligibility + if promotion_eligibility is not None + else _default_promotion_eligibility_for_trust_class(resolved_trust_class) + ), + "evidence_count": evidence_count, + "independent_source_count": independent_source_count, + "extracted_by_model": extracted_by_model, + "trust_reason": trust_reason, + "valid_from": candidate.valid_from, + "valid_to": candidate.valid_to, + "last_confirmed_at": candidate.last_confirmed_at, + } + + existing_trust_class = existing_memory.get("trust_class", DEFAULT_MEMORY_TRUST_CLASS) + resolved_trust_class = trust_class if trust_class is not None else existing_trust_class + resolved_promotion_eligibility: str + if promotion_eligibility is not None: + resolved_promotion_eligibility = promotion_eligibility + elif trust_class is not None: + resolved_promotion_eligibility = _default_promotion_eligibility_for_trust_class( + resolved_trust_class + ) + else: + resolved_promotion_eligibility = existing_memory.get( + "promotion_eligibility", + _default_promotion_eligibility_for_trust_class(resolved_trust_class), + ) + + return { + "memory_type": memory_type if memory_type is not None else existing_memory.get("memory_type", DEFAULT_MEMORY_TYPE), + "confidence": confidence if confidence is not None else existing_memory.get("confidence"), + "salience": salience if salience is not None else existing_memory.get("salience"), + "confirmation_status": ( + confirmation_status + if confirmation_status is not None + else existing_memory.get("confirmation_status", DEFAULT_MEMORY_CONFIRMATION_STATUS) + ), + "trust_class": resolved_trust_class, + "promotion_eligibility": resolved_promotion_eligibility, + "evidence_count": ( + evidence_count if evidence_count is not None else existing_memory.get("evidence_count") + ), + "independent_source_count": ( + independent_source_count + if independent_source_count is not None + else existing_memory.get("independent_source_count") + ), + "extracted_by_model": ( + extracted_by_model + if extracted_by_model is not None + else existing_memory.get("extracted_by_model") + ), + "trust_reason": trust_reason if trust_reason is not None else existing_memory.get("trust_reason"), + "valid_from": candidate.valid_from if candidate.valid_from is not None else existing_memory.get("valid_from"), + "valid_to": candidate.valid_to if candidate.valid_to is not None else existing_memory.get("valid_to"), + "last_confirmed_at": ( + candidate.last_confirmed_at + if candidate.last_confirmed_at is not None + else existing_memory.get("last_confirmed_at") + ), + } + + +def admit_memory_candidate( + store: ContinuityStore, + *, + user_id: UUID, + candidate: MemoryCandidateInput, +) -> AdmissionDecisionOutput: + del user_id + + source_event_ids, derived_agent_profile_id = _validate_source_events( + store, + candidate.source_event_ids, + ) + agent_profile_id = _resolve_memory_agent_profile_id( + store, + candidate=candidate, + derived_agent_profile_id=derived_agent_profile_id, + ) + existing_memory = store.get_memory_by_key_and_profile( + memory_key=candidate.memory_key, + agent_profile_id=agent_profile_id, + ) + resolved_metadata = _resolve_memory_typed_metadata( + existing_memory=existing_memory, + candidate=candidate, + ) + + noop_decision = AdmissionDecisionOutput( + action="NOOP", + reason="candidate_default_noop", + memory=None, + revision=None, + ) + + if candidate.delete_requested: + if existing_memory is None or existing_memory["status"] == "deleted": + return AdmissionDecisionOutput( + action=noop_decision.action, + reason="memory_not_found_for_delete", + memory=None if existing_memory is None else _serialize_memory(existing_memory), + revision=None, + ) + + memory = store.update_memory( + memory_id=existing_memory["id"], + value=existing_memory["value"], + status="deleted", + source_event_ids=source_event_ids, + memory_type=resolved_metadata["memory_type"], + confidence=resolved_metadata["confidence"], + salience=resolved_metadata["salience"], + confirmation_status=resolved_metadata["confirmation_status"], + trust_class=resolved_metadata["trust_class"], + promotion_eligibility=resolved_metadata["promotion_eligibility"], + evidence_count=resolved_metadata["evidence_count"], + independent_source_count=resolved_metadata["independent_source_count"], + extracted_by_model=resolved_metadata["extracted_by_model"], + trust_reason=resolved_metadata["trust_reason"], + valid_from=resolved_metadata["valid_from"], + valid_to=resolved_metadata["valid_to"], + last_confirmed_at=resolved_metadata["last_confirmed_at"], + ) + revision = store.append_memory_revision( + memory_id=memory["id"], + action="DELETE", + memory_key=memory["memory_key"], + previous_value=existing_memory["value"], + new_value=None, + source_event_ids=source_event_ids, + candidate=_candidate_payload( + candidate, + resolved_agent_profile_id=agent_profile_id, + ), + ) + return AdmissionDecisionOutput( + action="DELETE", + reason="source_backed_delete", + memory=_serialize_memory(memory), + revision=_serialize_memory_revision(revision), + ) + + if candidate.value is None: + return AdmissionDecisionOutput( + action=noop_decision.action, + reason="candidate_value_missing", + memory=None if existing_memory is None else _serialize_memory(existing_memory), + revision=None, + ) + + if existing_memory is None: + memory = store.create_memory( + memory_key=candidate.memory_key, + value=candidate.value, + status="active", + source_event_ids=source_event_ids, + memory_type=resolved_metadata["memory_type"], + confidence=resolved_metadata["confidence"], + salience=resolved_metadata["salience"], + confirmation_status=resolved_metadata["confirmation_status"], + trust_class=resolved_metadata["trust_class"], + promotion_eligibility=resolved_metadata["promotion_eligibility"], + evidence_count=resolved_metadata["evidence_count"], + independent_source_count=resolved_metadata["independent_source_count"], + extracted_by_model=resolved_metadata["extracted_by_model"], + trust_reason=resolved_metadata["trust_reason"], + valid_from=resolved_metadata["valid_from"], + valid_to=resolved_metadata["valid_to"], + last_confirmed_at=resolved_metadata["last_confirmed_at"], + agent_profile_id=agent_profile_id, + ) + revision = store.append_memory_revision( + memory_id=memory["id"], + action="ADD", + memory_key=memory["memory_key"], + previous_value=None, + new_value=candidate.value, + source_event_ids=source_event_ids, + candidate=_candidate_payload( + candidate, + resolved_agent_profile_id=agent_profile_id, + ), + ) + return AdmissionDecisionOutput( + action="ADD", + reason="source_backed_add", + memory=_serialize_memory(memory), + revision=_serialize_memory_revision(revision), + open_loop=_create_open_loop_for_memory( + store, + candidate=candidate, + memory=memory, + ), + ) + + metadata_changed = any( + existing_memory.get(field_name) != resolved_metadata[field_name] + for field_name in ( + "memory_type", + "confidence", + "salience", + "confirmation_status", + "trust_class", + "promotion_eligibility", + "evidence_count", + "independent_source_count", + "extracted_by_model", + "trust_reason", + "valid_from", + "valid_to", + "last_confirmed_at", + ) + ) + + if existing_memory["status"] == "active" and existing_memory["value"] == candidate.value and not metadata_changed: + return AdmissionDecisionOutput( + action=noop_decision.action, + reason="memory_unchanged", + memory=_serialize_memory(existing_memory), + revision=None, + open_loop=_create_open_loop_for_memory( + store, + candidate=candidate, + memory=existing_memory, + ), + ) + + memory = store.update_memory( + memory_id=existing_memory["id"], + value=candidate.value, + status="active", + source_event_ids=source_event_ids, + memory_type=resolved_metadata["memory_type"], + confidence=resolved_metadata["confidence"], + salience=resolved_metadata["salience"], + confirmation_status=resolved_metadata["confirmation_status"], + trust_class=resolved_metadata["trust_class"], + promotion_eligibility=resolved_metadata["promotion_eligibility"], + evidence_count=resolved_metadata["evidence_count"], + independent_source_count=resolved_metadata["independent_source_count"], + extracted_by_model=resolved_metadata["extracted_by_model"], + trust_reason=resolved_metadata["trust_reason"], + valid_from=resolved_metadata["valid_from"], + valid_to=resolved_metadata["valid_to"], + last_confirmed_at=resolved_metadata["last_confirmed_at"], + ) + revision = store.append_memory_revision( + memory_id=memory["id"], + action="UPDATE", + memory_key=memory["memory_key"], + previous_value=existing_memory["value"], + new_value=candidate.value, + source_event_ids=source_event_ids, + candidate=_candidate_payload( + candidate, + resolved_agent_profile_id=agent_profile_id, + ), + ) + return AdmissionDecisionOutput( + action="UPDATE", + reason="source_backed_update", + memory=_serialize_memory(memory), + revision=_serialize_memory_revision(revision), + open_loop=_create_open_loop_for_memory( + store, + candidate=candidate, + memory=memory, + ), + ) diff --git a/apps/api/src/alicebot_api/migrations.py b/apps/api/src/alicebot_api/migrations.py new file mode 100644 index 0000000..52a5d15 --- /dev/null +++ b/apps/api/src/alicebot_api/migrations.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from pathlib import Path + +from alembic.config import Config + + +PROJECT_ROOT = Path(__file__).resolve().parents[4] +ALEMBIC_INI_PATH = PROJECT_ROOT / "apps" / "api" / "alembic.ini" + + +def make_alembic_config(database_url: str | None = None) -> Config: + config = Config(str(ALEMBIC_INI_PATH)) + if database_url: + config.set_main_option("sqlalchemy.url", database_url) + return config + diff --git a/apps/api/src/alicebot_api/openclaw_adapter.py b/apps/api/src/alicebot_api/openclaw_adapter.py new file mode 100644 index 0000000..7d397de --- /dev/null +++ b/apps/api/src/alicebot_api/openclaw_adapter.py @@ -0,0 +1,423 @@ +from __future__ import annotations + +from hashlib import sha256 +import json +from pathlib import Path + +from alicebot_api.openclaw_models import ( + OpenClawAdapterValidationError, + OpenClawNormalizedBatch, + OpenClawNormalizedItem, + OpenClawWorkspaceContext, + as_json_object, + canonical_json_string, + ensure_json_object, + merge_json_objects, + normalize_optional_text, + parse_optional_confidence, + parse_optional_status, + pick_first_text, + to_string_list, +) +from alicebot_api.store import JsonObject + + +_OPENCLAW_TYPE_TO_OBJECT_TYPE: dict[str, str] = { + "decision": "Decision", + "decisions": "Decision", + "task": "NextAction", + "next": "NextAction", + "next_action": "NextAction", + "nextaction": "NextAction", + "action": "NextAction", + "commitment": "Commitment", + "waiting": "WaitingFor", + "waiting_for": "WaitingFor", + "waitingfor": "WaitingFor", + "blocker": "Blocker", + "fact": "MemoryFact", + "memory_fact": "MemoryFact", + "memory": "MemoryFact", + "note": "Note", +} + +_OBJECT_TYPE_TO_BODY_KEY: dict[str, str] = { + "Note": "body", + "MemoryFact": "fact_text", + "Decision": "decision_text", + "Commitment": "commitment_text", + "WaitingFor": "waiting_for_text", + "Blocker": "blocking_reason", + "NextAction": "action_text", +} + +_OBJECT_TYPE_TO_PREFIX: dict[str, str] = { + "Decision": "Decision", + "Commitment": "Commitment", + "WaitingFor": "Waiting For", + "Blocker": "Blocker", + "NextAction": "Next Action", + "MemoryFact": "Memory Fact", + "Note": "Note", +} + +_DEFAULT_CONFIDENCE = 0.82 +_SUPPORTED_WORKSPACE_FILENAMES = ( + "workspace.json", + "openclaw_workspace.json", +) +_SUPPORTED_MEMORY_FILENAMES = ( + "durable_memory.json", + "memories.json", + "openclaw_memories.json", +) + + +def _truncate(value: str, *, max_length: int) -> str: + if len(value) <= max_length: + return value + return value[: max_length - 3].rstrip() + "..." + + +def _normalize_object_type(value: object) -> str: + normalized = normalize_optional_text(value) + if normalized is None: + return "Note" + + if normalized in _OBJECT_TYPE_TO_BODY_KEY: + return normalized + + lowered = normalized.casefold().replace("-", "_").replace(" ", "_") + return _OPENCLAW_TYPE_TO_OBJECT_TYPE.get(lowered, "Note") + + +def _build_body(*, object_type: str, text: str, raw_entry: JsonObject) -> JsonObject: + body_key = _OBJECT_TYPE_TO_BODY_KEY[object_type] + return { + body_key: text, + "raw_import_text": text, + "openclaw_raw_entry": raw_entry, + } + + +def _build_title(*, object_type: str, text: str, explicit_title: str | None) -> str: + if explicit_title is not None: + return _truncate(explicit_title, max_length=280) + prefix = _OBJECT_TYPE_TO_PREFIX[object_type] + return _truncate(f"{prefix}: {text}", max_length=280) + + +def _build_raw_content(*, object_type: str, text: str) -> str: + prefix = _OBJECT_TYPE_TO_PREFIX[object_type] + return f"{prefix}: {text}" + + +def _read_json(path: Path) -> object: + try: + return json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise OpenClawAdapterValidationError( + f"invalid JSON at {path}: {exc.msg}" + ) from exc + + +def _extract_workspace_payloads(payload: object) -> tuple[JsonObject | None, list[JsonObject]]: + if isinstance(payload, list): + entries = [ensure_json_object(item, field_name="entry") for item in payload] + return None, entries + + if not isinstance(payload, dict): + raise OpenClawAdapterValidationError("OpenClaw source root must be a JSON object or array") + + workspace_payload = payload.get("workspace") + if workspace_payload is not None and not isinstance(workspace_payload, dict): + raise OpenClawAdapterValidationError("workspace must be a JSON object when provided") + workspace_json = as_json_object(workspace_payload) if workspace_payload is not None else None + + entries: list[JsonObject] = [] + found_memory_key = False + for key in ("durable_memory", "memories", "items", "records"): + raw_entries = payload.get(key) + if raw_entries is None: + continue + found_memory_key = True + if not isinstance(raw_entries, list): + raise OpenClawAdapterValidationError(f"{key} must be a JSON array") + entries.extend(ensure_json_object(item, field_name=f"{key}[]") for item in raw_entries) + + if found_memory_key: + return workspace_json, entries + + if workspace_payload is not None: + return workspace_json, [] + + # Single-record convenience format. + if payload.get("content") is not None or payload.get("text") is not None: + return None, [ensure_json_object(payload, field_name="payload")] + + raise OpenClawAdapterValidationError( + "OpenClaw payload must include one of: durable_memory, memories, items, or records" + ) + + +def _extract_scope_provenance(entry: JsonObject, *, raw_provenance: JsonObject) -> JsonObject: + entry_context = as_json_object(entry.get("context")) + + thread_id = pick_first_text( + entry.get("thread_id"), + raw_provenance.get("thread_id"), + entry_context.get("thread_id"), + ) + task_id = pick_first_text( + entry.get("task_id"), + raw_provenance.get("task_id"), + entry_context.get("task_id"), + ) + project = pick_first_text( + entry.get("project"), + entry.get("project_name"), + raw_provenance.get("project"), + entry_context.get("project"), + entry_context.get("project_name"), + ) + person = pick_first_text( + entry.get("person"), + entry.get("owner"), + raw_provenance.get("person"), + raw_provenance.get("owner"), + entry_context.get("person"), + entry_context.get("owner"), + ) + confirmation_status = pick_first_text( + entry.get("confirmation_status"), + raw_provenance.get("confirmation_status"), + entry_context.get("confirmation_status"), + ) + + source_event_ids = to_string_list(entry.get("source_event_ids")) + if not source_event_ids: + source_event_ids = to_string_list(raw_provenance.get("source_event_ids")) + if not source_event_ids: + source_event_ids = to_string_list(entry_context.get("source_event_ids")) + + payload: JsonObject = {} + if thread_id is not None: + payload["thread_id"] = thread_id + if task_id is not None: + payload["task_id"] = task_id + if project is not None: + payload["project"] = project + if person is not None: + payload["person"] = person + if confirmation_status is not None: + payload["confirmation_status"] = confirmation_status.casefold() + if source_event_ids: + payload["source_event_ids"] = source_event_ids + + tags = to_string_list(entry.get("tags")) + if tags: + payload["openclaw_tags"] = tags + + return payload + + +def _item_text(entry: JsonObject) -> str: + text = pick_first_text( + entry.get("text"), + entry.get("content"), + entry.get("summary"), + entry.get("message"), + ) + if text is None: + raise OpenClawAdapterValidationError("OpenClaw entry must include text/content/summary/message") + return text + + +def _normalize_entry( + *, + entry: JsonObject, + source_file: str, + entry_index: int, + workspace_id: str, +) -> OpenClawNormalizedItem: + source_identifier = pick_first_text( + entry.get("id"), + entry.get("memory_id"), + entry.get("entry_id"), + ) + source_item_id = source_identifier if source_identifier is not None else f"{source_file}:{entry_index + 1}" + + object_type = _normalize_object_type( + pick_first_text( + entry.get("object_type"), + entry.get("type"), + entry.get("kind"), + entry.get("category"), + ) + ) + status = parse_optional_status(entry.get("status")) or "active" + + text = _item_text(entry) + title = _build_title( + object_type=object_type, + text=text, + explicit_title=pick_first_text(entry.get("title")), + ) + raw_entry = as_json_object(entry) + + raw_provenance = as_json_object(entry.get("provenance")) + source_provenance = merge_json_objects( + _extract_scope_provenance(entry, raw_provenance=raw_provenance), + { + "openclaw_record_type": pick_first_text( + entry.get("type"), + entry.get("kind"), + entry.get("category"), + ) + or "unknown", + }, + ) + if source_identifier is not None: + source_provenance["openclaw_source_identifier"] = source_identifier + + confidence = parse_optional_confidence(entry.get("confidence")) + if confidence is None: + confidence = parse_optional_confidence(raw_provenance.get("confidence")) + if confidence is None: + confidence = _DEFAULT_CONFIDENCE + + dedupe_payload: JsonObject = { + "workspace_id": workspace_id, + "source_identifier": source_identifier, + "object_type": object_type, + "status": status, + "title": title, + "body": _build_body(object_type=object_type, text=text, raw_entry=raw_entry), + "source_provenance": source_provenance, + } + dedupe_key = sha256(canonical_json_string(dedupe_payload).encode("utf-8")).hexdigest() + + return OpenClawNormalizedItem( + source_item_id=source_item_id, + source_file=source_file, + object_type=object_type, + status=status, + raw_content=_build_raw_content(object_type=object_type, text=text), + title=title, + body=_build_body(object_type=object_type, text=text, raw_entry=raw_entry), + confidence=confidence, + source_provenance=source_provenance, + dedupe_key=dedupe_key, + ) + + +def _extract_context( + *, + source_path: Path, + workspace_payload: JsonObject | None, + fallback_fixture_id: str | None, +) -> OpenClawWorkspaceContext: + payload = workspace_payload or {} + workspace_id = pick_first_text( + payload.get("id"), + payload.get("workspace_id"), + fallback_fixture_id, + source_path.stem, + ) + if workspace_id is None: + raise OpenClawAdapterValidationError("workspace id could not be resolved") + + return OpenClawWorkspaceContext( + fixture_id=fallback_fixture_id, + workspace_id=workspace_id, + workspace_name=pick_first_text(payload.get("name"), payload.get("title")), + source_path=str(source_path), + ) + + +def load_openclaw_payload(source: str | Path) -> OpenClawNormalizedBatch: + source_path = Path(source).expanduser().resolve() + if not source_path.exists(): + raise OpenClawAdapterValidationError(f"OpenClaw source path does not exist: {source_path}") + + entries_by_file: list[tuple[str, list[JsonObject]]] = [] + workspace_payload: JsonObject | None = None + fixture_id: str | None = None + + if source_path.is_file(): + payload = _read_json(source_path) + parsed_workspace, entries = _extract_workspace_payloads(payload) + if isinstance(payload, dict): + fixture_id = normalize_optional_text(payload.get("fixture_id")) + workspace_payload = parsed_workspace + entries_by_file.append((source_path.name, entries)) + else: + for filename in _SUPPORTED_WORKSPACE_FILENAMES: + candidate = source_path / filename + if not candidate.exists(): + continue + payload = _read_json(candidate) + parsed_workspace, _ = _extract_workspace_payloads(payload) + workspace_payload = parsed_workspace or workspace_payload + if isinstance(payload, dict): + fixture_id = fixture_id or normalize_optional_text(payload.get("fixture_id")) + break + + for filename in _SUPPORTED_MEMORY_FILENAMES: + candidate = source_path / filename + if not candidate.exists(): + continue + payload = _read_json(candidate) + parsed_workspace, entries = _extract_workspace_payloads(payload) + if parsed_workspace is not None: + workspace_payload = parsed_workspace + if isinstance(payload, dict): + fixture_id = fixture_id or normalize_optional_text(payload.get("fixture_id")) + entries_by_file.append((filename, entries)) + + if not entries_by_file: + json_files = sorted(path for path in source_path.iterdir() if path.suffix == ".json") + for path in json_files: + payload = _read_json(path) + parsed_workspace, entries = _extract_workspace_payloads(payload) + if parsed_workspace is not None: + workspace_payload = parsed_workspace + if isinstance(payload, dict): + fixture_id = fixture_id or normalize_optional_text(payload.get("fixture_id")) + if entries: + entries_by_file.append((path.name, entries)) + + if not entries_by_file: + raise OpenClawAdapterValidationError("no OpenClaw memory entries were found at the source path") + + context = _extract_context( + source_path=source_path, + workspace_payload=workspace_payload, + fallback_fixture_id=fixture_id, + ) + + normalized_items: list[OpenClawNormalizedItem] = [] + for source_file, entries in entries_by_file: + for index, entry in enumerate(entries): + normalized_items.append( + _normalize_entry( + entry=entry, + source_file=source_file, + entry_index=index, + workspace_id=context.workspace_id, + ) + ) + + if not normalized_items: + raise OpenClawAdapterValidationError("OpenClaw source did not contain any importable entries") + + return OpenClawNormalizedBatch( + context=context, + items=normalized_items, + ) + + +__all__ = [ + "OpenClawAdapterValidationError", + "load_openclaw_payload", +] diff --git a/apps/api/src/alicebot_api/openclaw_import.py b/apps/api/src/alicebot_api/openclaw_import.py new file mode 100644 index 0000000..fbd9d92 --- /dev/null +++ b/apps/api/src/alicebot_api/openclaw_import.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from pathlib import Path +from uuid import UUID + +from alicebot_api.importer_models import ( + ImporterNormalizedBatch, + ImporterNormalizedItem, + ImporterWorkspaceContext, +) +from alicebot_api.importers.common import ImportPersistenceConfig, import_normalized_batch +from alicebot_api.openclaw_adapter import load_openclaw_payload +from alicebot_api.store import ContinuityStore, JsonObject + + +_OPENCLAW_DEDUPE_POSTURE = "workspace_and_payload_fingerprint" + + +def _to_generic_batch(source: str | Path) -> ImporterNormalizedBatch: + batch = load_openclaw_payload(source) + return ImporterNormalizedBatch( + context=ImporterWorkspaceContext( + fixture_id=batch.context.fixture_id, + workspace_id=batch.context.workspace_id, + workspace_name=batch.context.workspace_name, + source_path=batch.context.source_path, + ), + items=[ + ImporterNormalizedItem( + source_item_id=item.source_item_id, + source_file=item.source_file, + object_type=item.object_type, + status=item.status, + raw_content=item.raw_content, + title=item.title, + body=item.body, + confidence=item.confidence, + source_provenance=item.source_provenance, + dedupe_key=item.dedupe_key, + ) + for item in batch.items + ], + ) + + +def import_openclaw_source( + store: ContinuityStore, + *, + user_id: UUID, + source: str | Path, +) -> JsonObject: + generic_batch = _to_generic_batch(source) + return import_normalized_batch( + store, + user_id=user_id, + batch=generic_batch, + config=ImportPersistenceConfig( + source_kind="openclaw_import", + source_prefix="openclaw", + admission_reason="openclaw_import", + dedupe_key_field="openclaw_dedupe_key", + dedupe_posture=_OPENCLAW_DEDUPE_POSTURE, + source_label="OpenClaw", + ), + ) + + +__all__ = ["import_openclaw_source"] diff --git a/apps/api/src/alicebot_api/openclaw_models.py b/apps/api/src/alicebot_api/openclaw_models.py new file mode 100644 index 0000000..1badb4f --- /dev/null +++ b/apps/api/src/alicebot_api/openclaw_models.py @@ -0,0 +1,200 @@ +from __future__ import annotations + +from dataclasses import dataclass +import json + +from alicebot_api.store import JsonObject + + +CONTINUITY_IMPORT_STATUSES = { + "active", + "stale", + "completed", + "cancelled", + "superseded", +} + + +class OpenClawAdapterValidationError(ValueError): + """Raised when an OpenClaw import payload is invalid.""" + + +@dataclass(frozen=True, slots=True) +class OpenClawWorkspaceContext: + fixture_id: str | None + workspace_id: str + workspace_name: str | None + source_path: str + + +@dataclass(frozen=True, slots=True) +class OpenClawNormalizedItem: + source_item_id: str + source_file: str + object_type: str + status: str + raw_content: str + title: str + body: JsonObject + confidence: float + source_provenance: JsonObject + dedupe_key: str + + +@dataclass(frozen=True, slots=True) +class OpenClawNormalizedBatch: + context: OpenClawWorkspaceContext + items: list[OpenClawNormalizedItem] + + +def normalize_optional_text(value: object) -> str | None: + if not isinstance(value, str): + return None + normalized = " ".join(value.split()).strip() + if normalized == "": + return None + return normalized + + +def normalize_required_text(value: object, *, field_name: str) -> str: + normalized = normalize_optional_text(value) + if normalized is None: + raise OpenClawAdapterValidationError(f"{field_name} must be a non-empty string") + return normalized + + +def parse_optional_confidence(value: object) -> float | None: + if value is None: + return None + + if isinstance(value, bool): + raise OpenClawAdapterValidationError("confidence must be a number") + + if isinstance(value, (int, float)): + parsed = float(value) + elif isinstance(value, str): + stripped = value.strip() + if stripped == "": + return None + try: + parsed = float(stripped) + except ValueError as exc: + raise OpenClawAdapterValidationError("confidence must be a number") from exc + else: + raise OpenClawAdapterValidationError("confidence must be a number") + + if parsed < 0.0 or parsed > 1.0: + raise OpenClawAdapterValidationError("confidence must be between 0.0 and 1.0") + return parsed + + +def parse_optional_status(value: object) -> str | None: + normalized = normalize_optional_text(value) + if normalized is None: + return None + lowered = normalized.casefold() + if lowered not in CONTINUITY_IMPORT_STATUSES: + supported = ", ".join(sorted(CONTINUITY_IMPORT_STATUSES)) + raise OpenClawAdapterValidationError( + f"status must be one of: {supported}" + ) + return lowered + + +def ensure_json_object(value: object, *, field_name: str) -> JsonObject: + if not isinstance(value, dict): + raise OpenClawAdapterValidationError(f"{field_name} must be a JSON object") + return value + + +def canonicalize_json(value: object) -> object: + if isinstance(value, dict): + return { + str(key): canonicalize_json(value[key]) + for key in sorted(value) + } + if isinstance(value, list): + return [canonicalize_json(item) for item in value] + return value + + +def canonical_json_string(value: object) -> str: + return json.dumps( + canonicalize_json(value), + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + ) + + +def as_json_object(value: object) -> JsonObject: + if not isinstance(value, dict): + return {} + output: JsonObject = {} + for key, child in value.items(): + if not isinstance(key, str): + continue + output[key] = _as_json_value(child) + return output + + +def _as_json_value(value: object): + if value is None or isinstance(value, (str, int, float, bool)): + return value + if isinstance(value, list): + return [_as_json_value(item) for item in value] + if isinstance(value, dict): + return as_json_object(value) + return str(value) + + +def merge_json_objects(*payloads: JsonObject) -> JsonObject: + merged: JsonObject = {} + for payload in payloads: + merged.update(payload) + return merged + + +def pick_first_text(*candidates: object) -> str | None: + for candidate in candidates: + normalized = normalize_optional_text(candidate) + if normalized is not None: + return normalized + return None + + +def to_string_list(value: object) -> list[str]: + if isinstance(value, str): + normalized = normalize_optional_text(value) + return [] if normalized is None else [normalized] + + if isinstance(value, list): + items: list[str] = [] + seen: set[str] = set() + for raw in value: + normalized = normalize_optional_text(raw) + if normalized is None or normalized in seen: + continue + items.append(normalized) + seen.add(normalized) + return items + + return [] + + +__all__ = [ + "OpenClawAdapterValidationError", + "OpenClawNormalizedBatch", + "OpenClawNormalizedItem", + "OpenClawWorkspaceContext", + "as_json_object", + "canonical_json_string", + "ensure_json_object", + "merge_json_objects", + "normalize_optional_text", + "normalize_required_text", + "parse_optional_confidence", + "parse_optional_status", + "pick_first_text", + "to_string_list", +] diff --git a/apps/api/src/alicebot_api/phase3_profiles.py b/apps/api/src/alicebot_api/phase3_profiles.py new file mode 100644 index 0000000..1854106 --- /dev/null +++ b/apps/api/src/alicebot_api/phase3_profiles.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from typing import cast + +from alicebot_api.contracts import AgentProfileRecord, DEFAULT_AGENT_PROFILE_ID, ModelProvider +from alicebot_api.store import ContinuityStore + + +def list_agent_profiles(store: ContinuityStore) -> list[AgentProfileRecord]: + return [ + { + "id": profile["id"], + "name": profile["name"], + "description": profile["description"], + "model_provider": cast(ModelProvider | None, profile["model_provider"]), + "model_name": profile["model_name"], + } + for profile in store.list_agent_profiles() + ] + + +def list_agent_profile_ids(store: ContinuityStore) -> list[str]: + return [profile["id"] for profile in store.list_agent_profiles()] + + +def get_agent_profile(store: ContinuityStore, profile_id: str) -> AgentProfileRecord | None: + profile = store.get_agent_profile_optional(profile_id) + if profile is None: + return None + return { + "id": profile["id"], + "name": profile["name"], + "description": profile["description"], + "model_provider": cast(ModelProvider | None, profile["model_provider"]), + "model_name": profile["model_name"], + } + + +def get_default_agent_profile_id() -> str: + return DEFAULT_AGENT_PROFILE_ID diff --git a/apps/api/src/alicebot_api/policy.py b/apps/api/src/alicebot_api/policy.py new file mode 100644 index 0000000..450bfd4 --- /dev/null +++ b/apps/api/src/alicebot_api/policy.py @@ -0,0 +1,439 @@ +from __future__ import annotations + +from dataclasses import dataclass +from uuid import UUID + +from alicebot_api.contracts import ( + CONSENT_LIST_ORDER, + DEFAULT_AGENT_PROFILE_ID, + POLICY_EVALUATION_VERSION_V0, + POLICY_LIST_ORDER, + TRACE_KIND_POLICY_EVALUATE, + ConsentListResponse, + ConsentListSummary, + ConsentRecord, + ConsentUpsertInput, + ConsentUpsertResponse, + PolicyCreateInput, + PolicyCreateResponse, + PolicyDetailResponse, + PolicyEvaluationReason, + PolicyEvaluationRequestInput, + PolicyEvaluationResponse, + PolicyEvaluationSummary, + PolicyEvaluationTraceSummary, + PolicyListResponse, + PolicyListSummary, + PolicyRecord, + isoformat_or_none, +) +from alicebot_api.store import ConsentRow, ContinuityStore, PolicyRow + + +class PolicyValidationError(ValueError): + """Raised when a policy or consent request fails explicit validation.""" + + +class PolicyNotFoundError(LookupError): + """Raised when a requested policy is not visible inside the current user scope.""" + + +class PolicyEvaluationValidationError(ValueError): + """Raised when a policy-evaluation request fails explicit validation.""" + + +@dataclass(frozen=True, slots=True) +class PolicyEvaluationContext: + active_policies: tuple[PolicyRow, ...] + consents_by_key: dict[str, ConsentRow] + + +@dataclass(frozen=True, slots=True) +class PolicyEvaluationCoreDecision: + decision: str + matched_policy: PolicyRow | None + reasons: list[PolicyEvaluationReason] + + +def _serialize_consent(consent: ConsentRow) -> ConsentRecord: + return { + "id": str(consent["id"]), + "consent_key": consent["consent_key"], + "status": consent["status"], + "metadata": consent["metadata"], + "created_at": consent["created_at"].isoformat(), + "updated_at": consent["updated_at"].isoformat(), + } + + +def _serialize_policy(policy: PolicyRow) -> PolicyRecord: + return { + "id": str(policy["id"]), + "agent_profile_id": policy["agent_profile_id"], + "name": policy["name"], + "action": policy["action"], + "scope": policy["scope"], + "effect": policy["effect"], + "priority": policy["priority"], + "active": policy["active"], + "conditions": policy["conditions"], + "required_consents": policy["required_consents"], + "created_at": policy["created_at"].isoformat(), + "updated_at": policy["updated_at"].isoformat(), + } + + +def _dedupe_required_consents(required_consents: tuple[str, ...]) -> list[str]: + deduped: list[str] = [] + seen: set[str] = set() + for consent_key in required_consents: + if consent_key in seen: + continue + seen.add(consent_key) + deduped.append(consent_key) + return deduped + + +def _policy_matches(policy: PolicyRow, request: PolicyEvaluationRequestInput) -> bool: + if policy["action"] != request.action or policy["scope"] != request.scope: + return False + + conditions = policy["conditions"] + for key, expected_value in conditions.items(): + if key not in request.attributes: + return False + if request.attributes[key] != expected_value: + return False + + return True + + +def _build_reason( + *, + code: str, + source: str, + message: str, + policy_id: UUID | None = None, + consent_key: str | None = None, +) -> PolicyEvaluationReason: + return { + "code": code, + "source": source, + "message": message, + "policy_id": None if policy_id is None else str(policy_id), + "consent_key": consent_key, + } + + +def upsert_consent_record( + store: ContinuityStore, + *, + user_id: UUID, + consent: ConsentUpsertInput, +) -> ConsentUpsertResponse: + del user_id + + existing = store.get_consent_by_key_optional(consent.consent_key) + if existing is None: + created = store.create_consent( + consent_key=consent.consent_key, + status=consent.status, + metadata=consent.metadata, + ) + return { + "consent": _serialize_consent(created), + "write_mode": "created", + } + + updated = store.update_consent( + consent_id=existing["id"], + status=consent.status, + metadata=consent.metadata, + ) + return { + "consent": _serialize_consent(updated), + "write_mode": "updated", + } + + +def list_consent_records( + store: ContinuityStore, + *, + user_id: UUID, +) -> ConsentListResponse: + del user_id + + items = [_serialize_consent(consent) for consent in store.list_consents()] + summary: ConsentListSummary = { + "total_count": len(items), + "order": list(CONSENT_LIST_ORDER), + } + return { + "items": items, + "summary": summary, + } + + +def create_policy_record( + store: ContinuityStore, + *, + user_id: UUID, + policy: PolicyCreateInput, +) -> PolicyCreateResponse: + del user_id + + required_consents = _dedupe_required_consents(policy.required_consents) + created = store.create_policy( + agent_profile_id=policy.agent_profile_id, + name=policy.name, + action=policy.action, + scope=policy.scope, + effect=policy.effect, + priority=policy.priority, + active=policy.active, + conditions=policy.conditions, + required_consents=required_consents, + ) + return {"policy": _serialize_policy(created)} + + +def list_policy_records( + store: ContinuityStore, + *, + user_id: UUID, +) -> PolicyListResponse: + del user_id + + items = [_serialize_policy(policy) for policy in store.list_policies()] + summary: PolicyListSummary = { + "total_count": len(items), + "order": list(POLICY_LIST_ORDER), + } + return { + "items": items, + "summary": summary, + } + + +def get_policy_record( + store: ContinuityStore, + *, + user_id: UUID, + policy_id: UUID, +) -> PolicyDetailResponse: + del user_id + + policy = store.get_policy_optional(policy_id) + if policy is None: + raise PolicyNotFoundError(f"policy {policy_id} was not found") + + return {"policy": _serialize_policy(policy)} + + +def load_policy_evaluation_context( + store: ContinuityStore, + *, + thread_agent_profile_id: str, +) -> PolicyEvaluationContext: + try: + active_policies = store.list_active_policies(agent_profile_id=thread_agent_profile_id) + except TypeError as exc: + if "agent_profile_id" not in str(exc): + raise + # Backward-compatible fallback for unit stubs that haven't adopted the scoped signature yet. + active_policies = store.list_active_policies() + + return PolicyEvaluationContext( + active_policies=tuple(active_policies), + consents_by_key={consent["consent_key"]: consent for consent in store.list_consents()}, + ) + + +def evaluate_policy_against_context( + context: PolicyEvaluationContext, + *, + request: PolicyEvaluationRequestInput, +) -> PolicyEvaluationCoreDecision: + matched_policy = next( + (policy for policy in context.active_policies if _policy_matches(policy, request)), + None, + ) + + reasons: list[PolicyEvaluationReason] = [] + decision = "deny" + + if matched_policy is None: + reasons.append( + _build_reason( + code="no_matching_policy", + source="system", + message="No active policy matched the requested action, scope, and attributes.", + ) + ) + return PolicyEvaluationCoreDecision( + decision=decision, + matched_policy=None, + reasons=reasons, + ) + + reasons.append( + _build_reason( + code="matched_policy", + source="policy", + message=f"Matched policy '{matched_policy['name']}' at priority {matched_policy['priority']}.", + policy_id=matched_policy["id"], + ) + ) + + missing_or_revoked = False + for consent_key in matched_policy["required_consents"]: + consent = context.consents_by_key.get(consent_key) + if consent is None: + missing_or_revoked = True + reasons.append( + _build_reason( + code="consent_missing", + source="consent", + message=f"Required consent '{consent_key}' is missing.", + policy_id=matched_policy["id"], + consent_key=consent_key, + ) + ) + continue + if consent["status"] != "granted": + missing_or_revoked = True + reasons.append( + _build_reason( + code="consent_revoked", + source="consent", + message=f"Required consent '{consent_key}' is not granted (status={consent['status']}).", + policy_id=matched_policy["id"], + consent_key=consent_key, + ) + ) + + if not missing_or_revoked: + decision = matched_policy["effect"] + effect_code = { + "allow": "policy_effect_allow", + "deny": "policy_effect_deny", + "require_approval": "policy_effect_require_approval", + }[decision] + reasons.append( + _build_reason( + code=effect_code, + source="policy", + message=f"Policy effect resolved the decision to '{decision}'.", + policy_id=matched_policy["id"], + ) + ) + + return PolicyEvaluationCoreDecision( + decision=decision, + matched_policy=matched_policy, + reasons=reasons, + ) + + +def evaluate_policy_request( + store: ContinuityStore, + *, + user_id: UUID, + request: PolicyEvaluationRequestInput, +) -> PolicyEvaluationResponse: + del user_id + + thread = store.get_thread_optional(request.thread_id) + if thread is None: + raise PolicyEvaluationValidationError( + "thread_id must reference an existing thread owned by the user" + ) + + context = load_policy_evaluation_context( + store, + thread_agent_profile_id=thread.get("agent_profile_id", DEFAULT_AGENT_PROFILE_ID), + ) + core_decision = evaluate_policy_against_context( + context, + request=request, + ) + + trace = store.create_trace( + user_id=thread["user_id"], + thread_id=thread["id"], + kind=TRACE_KIND_POLICY_EVALUATE, + compiler_version=POLICY_EVALUATION_VERSION_V0, + status="completed", + limits={ + "order": list(POLICY_LIST_ORDER), + "active_policy_count": len(context.active_policies), + "consent_count": len(context.consents_by_key), + }, + ) + + trace_events = [ + ( + "policy.evaluate.request", + { + "thread_id": str(request.thread_id), + "action": request.action, + "scope": request.scope, + "attributes": request.attributes, + }, + ), + ( + "policy.evaluate.order", + { + "order": list(POLICY_LIST_ORDER), + "policy_ids": [str(policy["id"]) for policy in context.active_policies], + }, + ), + ( + "policy.evaluate.decision", + { + "decision": core_decision.decision, + "matched_policy_id": ( + None if core_decision.matched_policy is None else str(core_decision.matched_policy["id"]) + ), + "reasons": core_decision.reasons, + "evaluated_policy_count": len(context.active_policies), + "consent_states": { + consent_key: { + "status": consent["status"], + "updated_at": isoformat_or_none(consent["updated_at"]), + } + for consent_key, consent in context.consents_by_key.items() + }, + }, + ), + ] + for sequence_no, (kind, payload) in enumerate(trace_events, start=1): + store.append_trace_event( + trace_id=trace["id"], + sequence_no=sequence_no, + kind=kind, + payload=payload, + ) + + evaluation: PolicyEvaluationSummary = { + "action": request.action, + "scope": request.scope, + "evaluated_policy_count": len(context.active_policies), + "matched_policy_id": ( + None if core_decision.matched_policy is None else str(core_decision.matched_policy["id"]) + ), + "order": list(POLICY_LIST_ORDER), + } + trace_summary: PolicyEvaluationTraceSummary = { + "trace_id": str(trace["id"]), + "trace_event_count": len(trace_events), + } + return { + "decision": core_decision.decision, + "matched_policy": ( + None if core_decision.matched_policy is None else _serialize_policy(core_decision.matched_policy) + ), + "reasons": core_decision.reasons, + "evaluation": evaluation, + "trace": trace_summary, + } diff --git a/apps/api/src/alicebot_api/proxy_execution.py b/apps/api/src/alicebot_api/proxy_execution.py new file mode 100644 index 0000000..0c819f8 --- /dev/null +++ b/apps/api/src/alicebot_api/proxy_execution.py @@ -0,0 +1,953 @@ +from __future__ import annotations + +import hashlib +import json +from collections.abc import Callable +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import cast +from uuid import UUID + +from alicebot_api.approvals import ApprovalNotFoundError, serialize_approval_row +from alicebot_api.contracts import ( + PROXY_EXECUTION_VERSION_V0, + EXECUTION_BUDGET_MATCH_ORDER, + TRACE_KIND_PROXY_EXECUTE, + ApprovalRecord, + ProxyExecutionApprovalTracePayload, + ProxyExecutionBudgetContextTracePayload, + ProxyExecutionBudgetPrecheckTracePayload, + ProxyExecutionDispatchTracePayload, + ProxyExecutionEventSummary, + ProxyExecutionRequestEventPayload, + ProxyExecutionRequestInput, + ProxyExecutionRequestRecord, + ProxyExecutionResponse, + ProxyExecutionResultEventPayload, + ProxyExecutionResultRecord, + ProxyExecutionStatus, + ProxyExecutionSummaryTracePayload, + ProxyExecutionTraceSummary, + ToolRecord, + ToolExecutionCreateInput, + ToolExecutionResultRecord, + ToolRoutingRequestRecord, +) +from alicebot_api.execution_budgets import evaluate_execution_budget +from alicebot_api.store import ContinuityStore, JsonObject, TaskRunRow, ToolExecutionRow +from alicebot_api.tasks import ( + validate_linked_task_step_for_approval, + sync_task_step_with_execution, + sync_task_with_execution, + task_lifecycle_trace_events, + task_step_lifecycle_trace_events, +) + +PROXY_EXECUTION_REQUEST_EVENT_KIND = "tool.proxy.execution.request" +PROXY_EXECUTION_RESULT_EVENT_KIND = "tool.proxy.execution.result" + + +class ProxyExecutionApprovalStateError(RuntimeError): + """Raised when an approval is visible but not executable in its current state.""" + + +class ProxyExecutionHandlerNotFoundError(RuntimeError): + """Raised when an approved tool has no registered proxy handler.""" + + +class ProxyExecutionIdempotencyError(RuntimeError): + """Raised when a side-effect-capable execution request cannot satisfy idempotency guards.""" + + +ProxyHandler = Callable[[ToolRoutingRequestRecord, ToolRecord], ProxyExecutionResultRecord] + + +@dataclass(frozen=True, slots=True) +class ProxyHandlerSpec: + handler: ProxyHandler + side_effect_capable: bool + rollout_mode: str + + +def _append_trace_events( + store: ContinuityStore, + *, + trace_id: UUID, + trace_events: list[tuple[str, dict[str, object]]], +) -> None: + for sequence_no, (kind, payload) in enumerate(trace_events, start=1): + store.append_trace_event( + trace_id=trace_id, + sequence_no=sequence_no, + kind=kind, + payload=payload, + ) + + +def _proxy_echo_handler( + request: ToolRoutingRequestRecord, + tool: ToolRecord, +) -> ProxyExecutionResultRecord: + output: JsonObject = { + "mode": "no_side_effect", + "tool_key": tool["tool_key"], + "action": request["action"], + "scope": request["scope"], + "domain_hint": request["domain_hint"], + "risk_hint": request["risk_hint"], + "attributes": request["attributes"], + } + return { + "handler_key": "proxy.echo", + "status": "completed", + "output": output, + } + + +def _proxy_thread_audit_handler( + request: ToolRoutingRequestRecord, + tool: ToolRecord, +) -> ProxyExecutionResultRecord: + attributes = request["attributes"] + output: JsonObject = { + "mode": "internal_low_risk", + "tool_key": tool["tool_key"], + "summary": { + "attribute_count": len(attributes), + "attribute_keys": sorted(attributes.keys()), + "action": request["action"], + "scope": request["scope"], + }, + } + return { + "handler_key": "proxy.thread_audit", + "status": "completed", + "output": output, + } + + +def _proxy_calendar_draft_handler( + request: ToolRoutingRequestRecord, + tool: ToolRecord, +) -> ProxyExecutionResultRecord: + title = cast(str, request["attributes"].get("title", "Untitled draft event")) + output: JsonObject = { + "mode": "external_draft", + "tool_key": tool["tool_key"], + "provider": "google_calendar", + "draft": { + "title": title, + "scope": request["scope"], + "domain_hint": request["domain_hint"], + "risk_hint": request["risk_hint"], + "attributes": request["attributes"], + }, + } + return { + "handler_key": "proxy.calendar.draft_event", + "status": "completed", + "output": output, + } + + +REGISTERED_PROXY_HANDLERS: dict[str, ProxyHandlerSpec] = { + "proxy.echo": ProxyHandlerSpec( + handler=_proxy_echo_handler, + side_effect_capable=False, + rollout_mode="internal", + ), + "proxy.thread_audit": ProxyHandlerSpec( + handler=_proxy_thread_audit_handler, + side_effect_capable=False, + rollout_mode="internal", + ), + "proxy.calendar.draft_event": ProxyHandlerSpec( + handler=_proxy_calendar_draft_handler, + side_effect_capable=True, + rollout_mode="external_draft", + ), +} + + +def registered_proxy_handler_keys() -> tuple[str, ...]: + return tuple(sorted(REGISTERED_PROXY_HANDLERS)) + + +def _trace_summary(trace_id: UUID, trace_events: list[tuple[str, dict[str, object]]]) -> ProxyExecutionTraceSummary: + return { + "trace_id": str(trace_id), + "trace_event_count": len(trace_events), + } + + +def _blocked_state_error(*, approval: ApprovalRecord) -> ProxyExecutionApprovalStateError: + return ProxyExecutionApprovalStateError( + f"approval {approval['id']} is {approval['status']} and cannot be executed" + ) + + +def _missing_handler_error(*, tool: ToolRecord) -> ProxyExecutionHandlerNotFoundError: + return ProxyExecutionHandlerNotFoundError( + f"tool '{tool['tool_key']}' has no registered proxy handler" + ) + + +def _tool_execution_result( + *, + handler_key: str | None, + status: ProxyExecutionStatus, + output: JsonObject | None, + reason: str | None, + budget_decision: dict[str, object] | None = None, +) -> ToolExecutionResultRecord: + payload: ToolExecutionResultRecord = { + "handler_key": handler_key, + "status": status, + "output": output, + "reason": reason, + } + if budget_decision is not None: + payload["budget_decision"] = cast(dict[str, object], budget_decision) + return payload + + +def _build_idempotency_key( + *, + task_run_id: UUID, + approval_id: UUID, + request: ToolRoutingRequestRecord, + tool: ToolRecord, +) -> str: + canonical_payload = { + "task_run_id": str(task_run_id), + "approval_id": str(approval_id), + "tool_key": tool["tool_key"], + "action": request["action"], + "scope": request["scope"], + "domain_hint": request["domain_hint"], + "risk_hint": request["risk_hint"], + "attributes": request["attributes"], + } + canonical = json.dumps(canonical_payload, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +def _resolve_task_run_linkage( + store: ContinuityStore, + *, + approval_row: dict[str, object], + linked_task_step: dict[str, object], + requested_task_run_id: UUID | None, +) -> UUID | None: + approval_task_run_id = cast(UUID | None, approval_row.get("task_run_id")) + linked_task_run_id = requested_task_run_id or approval_task_run_id + if linked_task_run_id is None: + return None + + if not hasattr(store, "get_task_run_optional"): + return linked_task_run_id + task_run = store.get_task_run_optional(linked_task_run_id) + if task_run is None: + raise ProxyExecutionApprovalStateError( + f"approval {approval_row['id']} links task run {linked_task_run_id} that was not found" + ) + if task_run["task_id"] != linked_task_step["task_id"]: + raise ProxyExecutionApprovalStateError( + f"approval {approval_row['id']} links task run {linked_task_run_id} outside task {linked_task_step['task_id']}" + ) + if approval_task_run_id is not None and approval_task_run_id != linked_task_run_id: + raise ProxyExecutionApprovalStateError( + f"approval {approval_row['id']} is already linked to task run {approval_task_run_id}" + ) + + if approval_task_run_id is None and hasattr(store, "update_approval_task_run_optional"): + store.update_approval_task_run_optional( + approval_id=cast(UUID, approval_row["id"]), + task_run_id=linked_task_run_id, + ) + + return linked_task_run_id + + +def _sync_task_run_after_execution( + store: ContinuityStore, + *, + task_run_id: UUID, + approval_id: UUID, + execution: ToolExecutionRow, +) -> TaskRunRow | None: + task_run = store.get_task_run_optional(task_run_id) + if task_run is None: + return None + if cast(str, task_run["status"]) in {"done", "cancelled"}: + return task_run + + checkpoint = cast(JsonObject, task_run["checkpoint"]) + if not isinstance(checkpoint, dict): + checkpoint = {} + next_checkpoint = dict(checkpoint) + next_checkpoint["wait_for_signal"] = False + next_checkpoint["waiting_approval_id"] = None + next_checkpoint["resolved_approval_id"] = str(approval_id) + next_checkpoint["resumed_from_approval_id"] = str(approval_id) + next_checkpoint["last_execution_id"] = str(execution["id"]) + next_checkpoint["last_execution_status"] = cast(str, execution["status"]) + next_checkpoint["last_execution_at"] = execution["executed_at"].isoformat() + + execution_status = cast(str, execution["status"]) + next_status = "done" + next_stop_reason = "done" + next_failure_class = None + next_retry_posture = "terminal" + if execution_status == "blocked": + next_status = "failed" + next_stop_reason = "policy_blocked" + next_failure_class = "policy" + next_retry_posture = "terminal" + execution_result = cast(dict[str, object], execution.get("result", {})) + budget_decision = execution_result.get("budget_decision") + if isinstance(budget_decision, dict) and budget_decision.get("reason") == "budget_exceeded": + next_stop_reason = "budget_exhausted" + next_failure_class = "budget" + + transitions = next_checkpoint.get("transitions") + if isinstance(transitions, list): + history = [entry for entry in transitions if isinstance(entry, dict)] + else: + history = [] + transition_entry = { + "sequence_no": len(history) + 1, + "source": "proxy_execution", + "at": datetime.now(UTC).isoformat(), + "previous_status": cast(str, task_run["status"]), + "status": next_status, + "previous_stop_reason": cast(str | None, task_run["stop_reason"]), + "stop_reason": next_stop_reason, + "failure_class": next_failure_class, + "retry_count": int(task_run["retry_count"]), + "retry_cap": int(task_run["retry_cap"]), + "retry_posture": next_retry_posture, + } + history.append(transition_entry) + next_checkpoint["transitions"] = history + next_checkpoint["last_transition"] = transition_entry + + return store.update_task_run_optional( + task_run_id=task_run_id, + status=next_status, + checkpoint=next_checkpoint, + tick_count=max(1, int(task_run["tick_count"])), + step_count=max(int(task_run["step_count"]), 1), + retry_count=int(task_run["retry_count"]), + retry_cap=int(task_run["retry_cap"]), + retry_posture=next_retry_posture, + failure_class=next_failure_class, + stop_reason=next_stop_reason, + ) + + +def _budget_context_trace_payload( + budget_decision: ProxyExecutionBudgetPrecheckTracePayload, +) -> ProxyExecutionBudgetContextTracePayload | None: + if budget_decision["reason"] != "invalid_request_context": + return None + return { + "request_thread_id": cast(str | None, budget_decision.get("request_thread_id")), + "context_resolution": "invalid", + "context_reason": cast(str | None, budget_decision.get("context_reason")), + } + + +def _task_run_trace_payload(task_run: TaskRunRow) -> dict[str, object]: + return { + "task_run_id": str(task_run["id"]), + "status": cast(str, task_run["status"]), + "stop_reason": cast(str | None, task_run["stop_reason"]), + "failure_class": cast(str | None, task_run["failure_class"]), + "retry_count": int(task_run["retry_count"]), + "retry_cap": int(task_run["retry_cap"]), + "retry_posture": cast(str, task_run["retry_posture"]), + } + + +def _persist_tool_execution( + store: ContinuityStore, + *, + approval_row: dict[str, object], + task_run_id: UUID | None, + task_step_id: UUID, + trace_id: UUID, + handler_key: str | None, + idempotency_key: str | None, + request: ToolRoutingRequestRecord, + tool: ToolRecord, + result: ToolExecutionResultRecord, + request_event_id: UUID | None, + result_event_id: UUID | None, +) -> ToolExecutionRow: + execution = ToolExecutionCreateInput( + approval_id=cast(UUID, approval_row["id"]), + task_run_id=task_run_id, + task_step_id=task_step_id, + thread_id=cast(UUID, approval_row["thread_id"]), + tool_id=cast(UUID, approval_row["tool_id"]), + trace_id=trace_id, + request_event_id=request_event_id, + result_event_id=result_event_id, + status=result["status"], + handler_key=handler_key, + idempotency_key=idempotency_key, + request=request, + tool=tool, + result=result, + ) + try: + return store.create_tool_execution( + approval_id=execution.approval_id, + task_run_id=execution.task_run_id, + task_step_id=execution.task_step_id, + thread_id=execution.thread_id, + tool_id=execution.tool_id, + trace_id=execution.trace_id, + request_event_id=execution.request_event_id, + result_event_id=execution.result_event_id, + status=execution.status, + handler_key=execution.handler_key, + idempotency_key=execution.idempotency_key, + request=cast(JsonObject, execution.request), + tool=cast(JsonObject, execution.tool), + result=cast(JsonObject, execution.result), + ) + except TypeError: + return store.create_tool_execution( + approval_id=execution.approval_id, + task_step_id=execution.task_step_id, + thread_id=execution.thread_id, + tool_id=execution.tool_id, + trace_id=execution.trace_id, + request_event_id=execution.request_event_id, + result_event_id=execution.result_event_id, + status=execution.status, + handler_key=execution.handler_key, + request=cast(JsonObject, execution.request), + tool=cast(JsonObject, execution.tool), + result=cast(JsonObject, execution.result), + ) + + +def execute_approved_proxy_request( + store: ContinuityStore, + *, + user_id: UUID, + request: ProxyExecutionRequestInput, +) -> ProxyExecutionResponse: + del user_id + + approval_row = store.get_approval_optional(request.approval_id) + if approval_row is None: + raise ApprovalNotFoundError(f"approval {request.approval_id} was not found") + _, linked_task_step = validate_linked_task_step_for_approval( + store, + approval_id=request.approval_id, + task_step_id=cast(UUID | None, approval_row["task_step_id"]), + ) + + approval = serialize_approval_row(approval_row) + linked_task_step_id = cast(str, approval["task_step_id"]) + tool = cast(ToolRecord, approval["tool"]) + routed_request = cast(ToolRoutingRequestRecord, approval["request"]) + handler_spec = REGISTERED_PROXY_HANDLERS.get(tool["tool_key"]) + linked_task_run_id = _resolve_task_run_linkage( + store, + approval_row=cast(dict[str, object], approval_row), + linked_task_step=cast(dict[str, object], linked_task_step), + requested_task_run_id=request.task_run_id, + ) + approval["task_run_id"] = None if linked_task_run_id is None else str(linked_task_run_id) + idempotency_key: str | None = None + if handler_spec is not None and handler_spec.side_effect_capable: + if linked_task_run_id is None: + raise ProxyExecutionIdempotencyError( + f"tool '{tool['tool_key']}' requires a linked task run for idempotent execution" + ) + idempotency_key = _build_idempotency_key( + task_run_id=linked_task_run_id, + approval_id=cast(UUID, approval_row["id"]), + request=routed_request, + tool=tool, + ) + + trace = store.create_trace( + user_id=approval_row["user_id"], + thread_id=approval_row["thread_id"], + kind=TRACE_KIND_PROXY_EXECUTE, + compiler_version=PROXY_EXECUTION_VERSION_V0, + status="completed", + limits={ + "approval_status": approval["status"], + "enabled_handler_keys": [tool["tool_key"]], + "budget_match_order": list(EXECUTION_BUDGET_MATCH_ORDER), + }, + ) + + approval_trace_payload: ProxyExecutionApprovalTracePayload = { + "approval_id": approval["id"], + "task_step_id": linked_task_step_id, + "approval_status": approval["status"], + "eligible_for_execution": approval["status"] == "approved", + } + + request_trace_payload: dict[str, object] = { + "approval_id": approval["id"], + "task_step_id": linked_task_step_id, + } + if linked_task_run_id is not None: + request_trace_payload["task_run_id"] = str(linked_task_run_id) + + trace_events: list[tuple[str, dict[str, object]]] = [ + ("tool.proxy.execute.request", request_trace_payload), + ("tool.proxy.execute.approval", cast(dict[str, object], approval_trace_payload)), + ] + + if approval["status"] != "approved": + error = _blocked_state_error(approval=approval) + dispatch_payload: ProxyExecutionDispatchTracePayload = { + "approval_id": approval["id"], + "task_step_id": linked_task_step_id, + "tool_id": tool["id"], + "tool_key": tool["tool_key"], + "handler_key": None, + "dispatch_status": "blocked", + "reason": str(error), + "result_status": None, + "output": None, + } + summary_payload: ProxyExecutionSummaryTracePayload = { + "approval_id": approval["id"], + "task_step_id": linked_task_step_id, + "tool_id": tool["id"], + "tool_key": tool["tool_key"], + "approval_status": approval["status"], + "execution_status": "blocked", + "handler_key": None, + "request_event_id": None, + "result_event_id": None, + } + trace_events.extend( + [ + ("tool.proxy.execute.dispatch", cast(dict[str, object], dispatch_payload)), + ("tool.proxy.execute.summary", cast(dict[str, object], summary_payload)), + ] + ) + _append_trace_events(store, trace_id=trace["id"], trace_events=trace_events) + raise error + + if idempotency_key is not None and linked_task_run_id is not None: + existing_execution = ( + store.get_tool_execution_by_idempotency_optional( + task_run_id=linked_task_run_id, + approval_id=cast(UUID, approval_row["id"]), + idempotency_key=idempotency_key, + ) + if hasattr(store, "get_tool_execution_by_idempotency_optional") + else None + ) + if existing_execution is not None: + trace_events.append( + ( + "tool.proxy.execute.idempotency", + { + "approval_id": approval["id"], + "task_run_id": str(linked_task_run_id), + "idempotency_key": idempotency_key, + "replayed_execution_id": str(existing_execution["id"]), + "decision": "replay_existing", + }, + ) + ) + run_after_sync = _sync_task_run_after_execution( + store, + task_run_id=linked_task_run_id, + approval_id=cast(UUID, approval_row["id"]), + execution=existing_execution, + ) + if run_after_sync is not None: + trace_events.append( + ( + "tool.proxy.execute.run", + _task_run_trace_payload(run_after_sync), + ) + ) + _append_trace_events(store, trace_id=trace["id"], trace_events=trace_events) + return { + "request": cast( + ProxyExecutionRequestRecord, + { + "approval_id": approval["id"], + "task_step_id": linked_task_step_id, + "task_run_id": str(linked_task_run_id), + }, + ), + "approval": approval, + "tool": tool, + "result": cast(ToolExecutionResultRecord, existing_execution["result"]), + "events": None, + "trace": _trace_summary(trace["id"], trace_events), + } + + budget_decision = evaluate_execution_budget( + store, + tool=tool, + request=routed_request, + ) + budget_trace_payload: ProxyExecutionBudgetPrecheckTracePayload = budget_decision.record + trace_events.append( + ("tool.proxy.execute.budget", cast(dict[str, object], budget_trace_payload)) + ) + + if budget_decision.blocked_result is not None: + dispatch_payload: ProxyExecutionDispatchTracePayload = { + "approval_id": approval["id"], + "task_step_id": linked_task_step_id, + "tool_id": tool["id"], + "tool_key": tool["tool_key"], + "handler_key": None, + "dispatch_status": "blocked", + "reason": budget_decision.blocked_result["reason"], + "result_status": budget_decision.blocked_result["status"], + "output": budget_decision.blocked_result["output"], + } + budget_context = _budget_context_trace_payload(budget_trace_payload) + if budget_context is not None: + dispatch_payload["budget_context"] = budget_context + summary_payload: ProxyExecutionSummaryTracePayload = { + "approval_id": approval["id"], + "task_step_id": linked_task_step_id, + "tool_id": tool["id"], + "tool_key": tool["tool_key"], + "approval_status": approval["status"], + "execution_status": "blocked", + "handler_key": None, + "request_event_id": None, + "result_event_id": None, + } + trace_events.extend( + [ + ("tool.proxy.execute.dispatch", cast(dict[str, object], dispatch_payload)), + ("tool.proxy.execute.summary", cast(dict[str, object], summary_payload)), + ] + ) + execution = _persist_tool_execution( + store, + approval_row=cast(dict[str, object], approval_row), + task_run_id=linked_task_run_id, + task_step_id=cast(UUID, linked_task_step["id"]), + trace_id=trace["id"], + handler_key=None, + idempotency_key=idempotency_key, + request=routed_request, + tool=tool, + result=budget_decision.blocked_result, + request_event_id=None, + result_event_id=None, + ) + task_transition = sync_task_with_execution( + store, + approval_id=cast(UUID, approval_row["id"]), + execution_id=execution["id"], + execution_status=execution["status"], + ) + task_step_transition = sync_task_step_with_execution( + store, + task_id=UUID(task_transition.task["id"]), + execution=execution, + trace_id=trace["id"], + trace_kind=TRACE_KIND_PROXY_EXECUTE, + ) + trace_events.extend( + task_lifecycle_trace_events( + task=task_transition.task, + previous_status=task_transition.previous_status, + source="proxy_execution", + ) + ) + trace_events.extend( + task_step_lifecycle_trace_events( + task_step=task_step_transition.task_step, + previous_status=task_step_transition.previous_status, + source="proxy_execution", + ) + ) + if linked_task_run_id is not None: + run_after_sync = _sync_task_run_after_execution( + store, + task_run_id=linked_task_run_id, + approval_id=cast(UUID, approval_row["id"]), + execution=execution, + ) + if run_after_sync is not None: + trace_events.append( + ( + "tool.proxy.execute.run", + _task_run_trace_payload(run_after_sync), + ) + ) + _append_trace_events(store, trace_id=trace["id"], trace_events=trace_events) + return { + "request": cast( + ProxyExecutionRequestRecord, + { + "approval_id": approval["id"], + "task_step_id": linked_task_step_id, + **( + {"task_run_id": str(linked_task_run_id)} + if linked_task_run_id is not None + else {} + ), + }, + ), + "approval": approval, + "tool": tool, + "result": budget_decision.blocked_result, + "events": None, + "trace": _trace_summary(trace["id"], trace_events), + } + + if handler_spec is None: + error = _missing_handler_error(tool=tool) + result = _tool_execution_result( + handler_key=None, + status="blocked", + output=None, + reason=str(error), + ) + dispatch_payload: ProxyExecutionDispatchTracePayload = { + "approval_id": approval["id"], + "task_step_id": linked_task_step_id, + "tool_id": tool["id"], + "tool_key": tool["tool_key"], + "handler_key": None, + "dispatch_status": "blocked", + "reason": str(error), + "result_status": result["status"], + "output": None, + } + summary_payload: ProxyExecutionSummaryTracePayload = { + "approval_id": approval["id"], + "task_step_id": linked_task_step_id, + "tool_id": tool["id"], + "tool_key": tool["tool_key"], + "approval_status": approval["status"], + "execution_status": "blocked", + "handler_key": None, + "request_event_id": None, + "result_event_id": None, + } + trace_events.extend( + [ + ("tool.proxy.execute.dispatch", cast(dict[str, object], dispatch_payload)), + ("tool.proxy.execute.summary", cast(dict[str, object], summary_payload)), + ] + ) + execution = _persist_tool_execution( + store, + approval_row=cast(dict[str, object], approval_row), + task_run_id=linked_task_run_id, + task_step_id=cast(UUID, linked_task_step["id"]), + trace_id=trace["id"], + handler_key=None, + idempotency_key=idempotency_key, + request=routed_request, + tool=tool, + result=result, + request_event_id=None, + result_event_id=None, + ) + task_transition = sync_task_with_execution( + store, + approval_id=cast(UUID, approval_row["id"]), + execution_id=execution["id"], + execution_status=execution["status"], + ) + task_step_transition = sync_task_step_with_execution( + store, + task_id=UUID(task_transition.task["id"]), + execution=execution, + trace_id=trace["id"], + trace_kind=TRACE_KIND_PROXY_EXECUTE, + ) + trace_events.extend( + task_lifecycle_trace_events( + task=task_transition.task, + previous_status=task_transition.previous_status, + source="proxy_execution", + ) + ) + trace_events.extend( + task_step_lifecycle_trace_events( + task_step=task_step_transition.task_step, + previous_status=task_step_transition.previous_status, + source="proxy_execution", + ) + ) + if linked_task_run_id is not None: + run_after_sync = _sync_task_run_after_execution( + store, + task_run_id=linked_task_run_id, + approval_id=cast(UUID, approval_row["id"]), + execution=execution, + ) + if run_after_sync is not None: + trace_events.append( + ( + "tool.proxy.execute.run", + _task_run_trace_payload(run_after_sync), + ) + ) + _append_trace_events(store, trace_id=trace["id"], trace_events=trace_events) + raise error + + request_event_payload: ProxyExecutionRequestEventPayload = { + "approval_id": approval["id"], + "task_step_id": linked_task_step_id, + "tool_id": tool["id"], + "tool_key": tool["tool_key"], + "request": routed_request, + } + if linked_task_run_id is not None: + request_event_payload["task_run_id"] = str(linked_task_run_id) + request_event = store.append_event( + approval_row["thread_id"], + None, + PROXY_EXECUTION_REQUEST_EVENT_KIND, + cast(JsonObject, request_event_payload), + ) + + result = handler_spec.handler(routed_request, tool) + result_event_payload: ProxyExecutionResultEventPayload = { + "approval_id": approval["id"], + "task_step_id": linked_task_step_id, + "tool_id": tool["id"], + "tool_key": tool["tool_key"], + "handler_key": result["handler_key"], + "status": result["status"], + "output": result["output"], + } + result_event = store.append_event( + approval_row["thread_id"], + None, + PROXY_EXECUTION_RESULT_EVENT_KIND, + cast(JsonObject, result_event_payload), + ) + execution = _persist_tool_execution( + store, + approval_row=cast(dict[str, object], approval_row), + task_run_id=linked_task_run_id, + task_step_id=cast(UUID, linked_task_step["id"]), + trace_id=trace["id"], + handler_key=result["handler_key"], + idempotency_key=idempotency_key, + request=routed_request, + tool=tool, + result=_tool_execution_result( + handler_key=result["handler_key"], + status=result["status"], + output=result["output"], + reason=None, + ), + request_event_id=request_event["id"], + result_event_id=result_event["id"], + ) + + events: ProxyExecutionEventSummary = { + "request_event_id": str(request_event["id"]), + "request_sequence_no": request_event["sequence_no"], + "result_event_id": str(result_event["id"]), + "result_sequence_no": result_event["sequence_no"], + } + dispatch_payload: ProxyExecutionDispatchTracePayload = { + "approval_id": approval["id"], + "task_step_id": linked_task_step_id, + "tool_id": tool["id"], + "tool_key": tool["tool_key"], + "handler_key": result["handler_key"], + "dispatch_status": "executed", + "reason": None, + "result_status": result["status"], + "output": result["output"], + } + summary_payload: ProxyExecutionSummaryTracePayload = { + "approval_id": approval["id"], + "task_step_id": linked_task_step_id, + "tool_id": tool["id"], + "tool_key": tool["tool_key"], + "approval_status": approval["status"], + "execution_status": "completed", + "handler_key": result["handler_key"], + "request_event_id": events["request_event_id"], + "result_event_id": events["result_event_id"], + } + trace_events.extend( + [ + ("tool.proxy.execute.dispatch", cast(dict[str, object], dispatch_payload)), + ("tool.proxy.execute.summary", cast(dict[str, object], summary_payload)), + ] + ) + task_transition = sync_task_with_execution( + store, + approval_id=cast(UUID, approval_row["id"]), + execution_id=execution["id"], + execution_status=execution["status"], + ) + task_step_transition = sync_task_step_with_execution( + store, + task_id=UUID(task_transition.task["id"]), + execution=execution, + trace_id=trace["id"], + trace_kind=TRACE_KIND_PROXY_EXECUTE, + ) + trace_events.extend( + task_lifecycle_trace_events( + task=task_transition.task, + previous_status=task_transition.previous_status, + source="proxy_execution", + ) + ) + trace_events.extend( + task_step_lifecycle_trace_events( + task_step=task_step_transition.task_step, + previous_status=task_step_transition.previous_status, + source="proxy_execution", + ) + ) + if linked_task_run_id is not None: + run_after_sync = _sync_task_run_after_execution( + store, + task_run_id=linked_task_run_id, + approval_id=cast(UUID, approval_row["id"]), + execution=execution, + ) + if run_after_sync is not None: + trace_events.append( + ( + "tool.proxy.execute.run", + _task_run_trace_payload(run_after_sync), + ) + ) + _append_trace_events(store, trace_id=trace["id"], trace_events=trace_events) + + return { + "request": cast( + ProxyExecutionRequestRecord, + { + "approval_id": approval["id"], + "task_step_id": linked_task_step_id, + **({"task_run_id": str(linked_task_run_id)} if linked_task_run_id is not None else {}), + }, + ), + "approval": approval, + "tool": tool, + "result": result, + "events": events, + "trace": _trace_summary(trace["id"], trace_events), + } diff --git a/apps/api/src/alicebot_api/response_generation.py b/apps/api/src/alicebot_api/response_generation.py new file mode 100644 index 0000000..f87bdd4 --- /dev/null +++ b/apps/api/src/alicebot_api/response_generation.py @@ -0,0 +1,522 @@ +from __future__ import annotations + +from dataclasses import dataclass +import hashlib +import json +from typing import Any, TypedDict, cast +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen +from uuid import UUID + +from alicebot_api.compiler import compile_and_persist_trace +from alicebot_api.config import Settings +from alicebot_api.contracts import ( + AssistantResponseEventPayload, + CompiledContextPack, + ContextCompilerLimits, + DEFAULT_AGENT_PROFILE_ID, + GenerateResponseSuccess, + ModelInvocationRequest, + ModelInvocationResponse, + ModelUsagePayload, + PROMPT_ASSEMBLY_VERSION_V0, + PromptAssemblyInput, + PromptAssemblyResult, + PromptAssemblyTracePayload, + PromptSection, + RESPONSE_GENERATION_VERSION_V0, + ResponseTraceSummary, + TRACE_KIND_RESPONSE_GENERATE, + TraceEventRecord, +) +from alicebot_api.store import ContinuityStore, JsonObject, ThreadRow + +PROMPT_TRACE_EVENT_KIND = "response.prompt.assembled" +MODEL_COMPLETED_TRACE_EVENT_KIND = "response.model.completed" +MODEL_FAILED_TRACE_EVENT_KIND = "response.model.failed" +SYSTEM_INSTRUCTION = ( + "You are AliceBot. Reply to the latest user message using the provided durable context. " + "If the context is insufficient, say so briefly instead of inventing facts." +) +DEVELOPER_INSTRUCTION = ( + "Treat the CONTEXT and CONVERSATION sections as authoritative durable state. " + "Do not call tools, do not describe hidden chain-of-thought, and keep the reply concise." +) + + +class ModelInvocationError(RuntimeError): + """Raised when the configured model provider cannot produce a response.""" + + +@dataclass(frozen=True, slots=True) +class ResponseFailure: + detail: str + trace: ResponseTraceSummary + + +class _OpenAIResponseContentItem(TypedDict, total=False): + type: str + text: str + + +class _OpenAIResponseOutputItem(TypedDict, total=False): + type: str + content: list[_OpenAIResponseContentItem] + + +class _OpenAIInputTokenDetails(TypedDict, total=False): + cached_tokens: int | None + + +class _OpenAIResponseUsage(TypedDict, total=False): + input_tokens: int | None + output_tokens: int | None + total_tokens: int | None + input_tokens_details: _OpenAIInputTokenDetails | None + prompt_tokens_details: _OpenAIInputTokenDetails | None + + +class _OpenAIResponsePayload(TypedDict, total=False): + id: str + status: str + output: list[_OpenAIResponseOutputItem] + usage: _OpenAIResponseUsage + + +def _deterministic_json(value: JsonObject | list[object]) -> str: + return json.dumps(value, sort_keys=True, ensure_ascii=True, separators=(",", ":")) + + +def _context_section_payload(context_pack: CompiledContextPack) -> JsonObject: + return { + "compiler_version": context_pack["compiler_version"], + "scope": context_pack["scope"], + "limits": context_pack["limits"], + "user": context_pack["user"], + "thread": context_pack["thread"], + "sessions": context_pack["sessions"], + "memories": context_pack["memories"], + "memory_summary": context_pack["memory_summary"], + "artifact_chunks": context_pack["artifact_chunks"], + "artifact_chunk_summary": context_pack["artifact_chunk_summary"], + "entities": context_pack["entities"], + "entity_summary": context_pack["entity_summary"], + "entity_edges": context_pack["entity_edges"], + "entity_edge_summary": context_pack["entity_edge_summary"], + } + + +def assemble_prompt( + *, + request: PromptAssemblyInput, + compile_trace_id: str, +) -> PromptAssemblyResult: + sections = ( + PromptSection(name="system", content=request.system_instruction), + PromptSection(name="developer", content=request.developer_instruction), + PromptSection( + name="context", + content=_deterministic_json(_context_section_payload(request.context_pack)), + ), + PromptSection( + name="conversation", + content=_deterministic_json({"events": request.context_pack["events"]}), + ), + ) + prompt_text = "\n\n".join( + f"[{section.name.upper()}]\n{section.content}" for section in sections + ) + prompt_sha256 = hashlib.sha256(prompt_text.encode("utf-8")).hexdigest() + trace_payload: PromptAssemblyTracePayload = { + "version": PROMPT_ASSEMBLY_VERSION_V0, + "compile_trace_id": compile_trace_id, + "compiler_version": request.context_pack["compiler_version"], + "prompt_sha256": prompt_sha256, + "prompt_char_count": len(prompt_text), + "section_order": [section.name for section in sections], + "section_characters": {section.name: len(section.content) for section in sections}, + "included_session_count": len(request.context_pack["sessions"]), + "included_event_count": len(request.context_pack["events"]), + "included_memory_count": len(request.context_pack["memories"]), + "included_entity_count": len(request.context_pack["entities"]), + "included_entity_edge_count": len(request.context_pack["entity_edges"]), + } + return PromptAssemblyResult( + sections=sections, + prompt_text=prompt_text, + prompt_sha256=prompt_sha256, + trace_payload=trace_payload, + ) + + +def _openai_input_message(role: str, content: str) -> JsonObject: + return { + "role": role, + "content": [{"type": "input_text", "text": content}], + } + + +def _build_openai_responses_payload(request: ModelInvocationRequest) -> JsonObject: + sections = {section.name: section.content for section in request.prompt.sections} + return { + "model": request.model, + "store": request.store, + "tool_choice": request.tool_choice, + "tools": [], + "input": [ + _openai_input_message("system", sections["system"]), + _openai_input_message("developer", sections["developer"]), + _openai_input_message("user", f"[CONTEXT]\n{sections['context']}"), + _openai_input_message("user", f"[CONVERSATION]\n{sections['conversation']}"), + ], + "text": {"format": {"type": "text"}}, + } + + +def _extract_output_text(response_payload: _OpenAIResponsePayload) -> str: + output_items = response_payload.get("output", []) + for output_item in output_items: + if output_item.get("type") != "message": + continue + for content_item in output_item.get("content", []): + if content_item.get("type") == "output_text": + text = content_item.get("text") + if isinstance(text, str) and text: + return text + raise ModelInvocationError("model response did not include assistant output text") + + +def _parse_usage(response_payload: _OpenAIResponsePayload) -> ModelUsagePayload: + usage = response_payload.get("usage", {}) + if not isinstance(usage, dict): + return {"input_tokens": None, "output_tokens": None, "total_tokens": None} + usage_payload: ModelUsagePayload = { + "input_tokens": usage.get("input_tokens"), + "output_tokens": usage.get("output_tokens"), + "total_tokens": usage.get("total_tokens"), + } + for details_key in ("input_tokens_details", "prompt_tokens_details"): + details = usage.get(details_key) + if not isinstance(details, dict): + continue + cached_tokens = details.get("cached_tokens") + if isinstance(cached_tokens, int): + usage_payload["cached_input_tokens"] = cached_tokens + break + return usage_payload + + +def _parse_openai_response_payload(raw_payload: bytes) -> _OpenAIResponsePayload: + try: + parsed_payload = json.loads(raw_payload) + except json.JSONDecodeError as exc: + raise ModelInvocationError("model provider returned invalid JSON") from exc + + if not isinstance(parsed_payload, dict): + raise ModelInvocationError("model provider returned invalid JSON") + + return cast(_OpenAIResponsePayload, parsed_payload) + + +def _extract_http_error_detail(exc: HTTPError) -> str | None: + raw_body = exc.read().decode("utf-8", errors="replace") + try: + parsed_error = json.loads(raw_body) + except json.JSONDecodeError: + return None + + if not isinstance(parsed_error, dict): + return None + + error = parsed_error.get("error", {}) + if not isinstance(error, dict): + return None + + detail = error.get("message") + if isinstance(detail, str) and detail: + return detail + return None + + +def _build_model_http_request(*, settings: Settings, payload: JsonObject) -> Request: + endpoint = settings.model_base_url.rstrip("/") + "/responses" + return Request( + endpoint, + data=json.dumps(payload).encode("utf-8"), + headers={ + "Authorization": f"Bearer {settings.model_api_key}", + "Content-Type": "application/json", + }, + method="POST", + ) + + +def _model_failure_trace_payload( + *, + request: ModelInvocationRequest, + error_message: str, +) -> JsonObject: + return { + "provider": request.provider, + "model": request.model, + "tool_choice": "none", + "tools_enabled": False, + "response_id": None, + "finish_reason": "incomplete", + "output_text_char_count": 0, + "usage": { + "input_tokens": None, + "output_tokens": None, + "total_tokens": None, + }, + "error_message": error_message, + } + + +def _create_linked_response_trace( + *, + store: ContinuityStore, + user_id: UUID, + thread_id: UUID, + limits: ContextCompilerLimits, + compiled_trace_id: str, + compiled_trace_event_count: int, + status: str, + trace_events: list[TraceEventRecord], +) -> ResponseTraceSummary: + trace = _create_response_trace( + store=store, + user_id=user_id, + thread_id=thread_id, + limits=limits, + status=status, + trace_events=trace_events, + ) + trace["compile_trace_id"] = compiled_trace_id + trace["compile_trace_event_count"] = compiled_trace_event_count + return trace + + +def invoke_model( + *, + settings: Settings, + request: ModelInvocationRequest, +) -> ModelInvocationResponse: + if request.provider != "openai_responses": + raise ModelInvocationError(f"unsupported model provider: {request.provider}") + if not settings.model_api_key: + raise ModelInvocationError("MODEL_API_KEY is not configured") + + payload = _build_openai_responses_payload(request) + http_request = _build_model_http_request(settings=settings, payload=payload) + + try: + with urlopen(http_request, timeout=settings.model_timeout_seconds) as response: + raw_payload = response.read() + except HTTPError as exc: + detail = _extract_http_error_detail(exc) + if detail is not None: + raise ModelInvocationError(detail) from exc + raise ModelInvocationError(f"model provider returned HTTP {exc.code}") from exc + except URLError as exc: + raise ModelInvocationError(f"model provider request failed: {exc.reason}") from exc + + response_payload = _parse_openai_response_payload(raw_payload) + output_text = _extract_output_text(response_payload) + finish_reason = "completed" if response_payload.get("status") == "completed" else "incomplete" + return ModelInvocationResponse( + provider=request.provider, + model=request.model, + response_id=response_payload.get("id"), + finish_reason=finish_reason, + output_text=output_text, + usage=_parse_usage(response_payload), + ) + + +def build_assistant_response_payload( + *, + prompt: PromptAssemblyResult, + model_response: ModelInvocationResponse, +) -> AssistantResponseEventPayload: + return { + "text": model_response.output_text, + "model": { + "provider": model_response.provider, + "model": model_response.model, + "response_id": model_response.response_id, + "finish_reason": model_response.finish_reason, + "usage": model_response.usage, + }, + "prompt": { + "assembly_version": PROMPT_ASSEMBLY_VERSION_V0, + "prompt_sha256": prompt.prompt_sha256, + "section_order": [section.name for section in prompt.sections], + }, + } + + +def _create_response_trace( + *, + store: ContinuityStore, + user_id: UUID, + thread_id: UUID, + limits: ContextCompilerLimits, + status: str, + trace_events: list[TraceEventRecord], +) -> ResponseTraceSummary: + trace = store.create_trace( + user_id=user_id, + thread_id=thread_id, + kind=TRACE_KIND_RESPONSE_GENERATE, + compiler_version=RESPONSE_GENERATION_VERSION_V0, + status=status, + limits=limits.as_payload(), + ) + for sequence_no, trace_event in enumerate(trace_events, start=1): + store.append_trace_event( + trace_id=trace["id"], + sequence_no=sequence_no, + kind=trace_event.kind, + payload=trace_event.payload, + ) + return { + "compile_trace_id": "", + "compile_trace_event_count": 0, + "response_trace_id": str(trace["id"]), + "response_trace_event_count": len(trace_events), + } + + +def resolve_thread_model_runtime( + *, + store: ContinuityStore, + thread: ThreadRow, + settings: Settings, +) -> tuple[str, str]: + agent_profile_id = str(thread.get("agent_profile_id", DEFAULT_AGENT_PROFILE_ID)) + profile = store.get_agent_profile_optional(agent_profile_id) + + if profile is None: + return settings.model_provider, settings.model_name + + profile_provider = profile.get("model_provider") + profile_model = profile.get("model_name") + if ( + isinstance(profile_provider, str) + and profile_provider + and isinstance(profile_model, str) + and profile_model + ): + return profile_provider, profile_model + + return settings.model_provider, settings.model_name + + +def generate_response( + *, + store: ContinuityStore, + settings: Settings, + user_id: UUID, + thread_id: UUID, + message_text: str, + limits: ContextCompilerLimits, +) -> GenerateResponseSuccess | ResponseFailure: + store.get_user(user_id) + thread = store.get_thread(thread_id) + + store.append_event( + thread_id, + None, + "message.user", + {"text": message_text}, + ) + compiled_trace = compile_and_persist_trace( + store, + user_id=user_id, + thread_id=thread_id, + limits=limits, + ) + prompt = assemble_prompt( + request=PromptAssemblyInput( + context_pack=compiled_trace.context_pack, + system_instruction=SYSTEM_INSTRUCTION, + developer_instruction=DEVELOPER_INSTRUCTION, + ), + compile_trace_id=compiled_trace.trace_id, + ) + model_provider, model_name = resolve_thread_model_runtime( + store=store, + thread=thread, + settings=settings, + ) + request = ModelInvocationRequest( + provider=model_provider, # type: ignore[arg-type] + model=model_name, + prompt=prompt, + ) + prompt_trace_event = TraceEventRecord( + kind=PROMPT_TRACE_EVENT_KIND, + payload=prompt.trace_payload, + ) + + try: + model_response = invoke_model(settings=settings, request=request) + except ModelInvocationError as exc: + trace = _create_linked_response_trace( + store=store, + user_id=user_id, + thread_id=thread_id, + limits=limits, + compiled_trace_id=compiled_trace.trace_id, + compiled_trace_event_count=compiled_trace.trace_event_count, + status="failed", + trace_events=[ + prompt_trace_event, + TraceEventRecord( + kind=MODEL_FAILED_TRACE_EVENT_KIND, + payload=_model_failure_trace_payload( + request=request, + error_message=str(exc), + ), + ), + ], + ) + return ResponseFailure(detail=str(exc), trace=trace) + + assistant_payload = build_assistant_response_payload( + prompt=prompt, + model_response=model_response, + ) + assistant_event = store.append_event( + thread_id, + None, + "message.assistant", + assistant_payload, + ) + trace = _create_linked_response_trace( + store=store, + user_id=user_id, + thread_id=thread_id, + limits=limits, + compiled_trace_id=compiled_trace.trace_id, + compiled_trace_event_count=compiled_trace.trace_event_count, + status="completed", + trace_events=[ + prompt_trace_event, + TraceEventRecord( + kind=MODEL_COMPLETED_TRACE_EVENT_KIND, + payload=model_response.to_trace_payload(), + ), + ], + ) + return { + "assistant": { + "event_id": str(assistant_event["id"]), + "sequence_no": assistant_event["sequence_no"], + "text": model_response.output_text, + "model_provider": model_response.provider, + "model": model_response.model, + }, + "trace": trace, + } diff --git a/apps/api/src/alicebot_api/retrieval_evaluation.py b/apps/api/src/alicebot_api/retrieval_evaluation.py new file mode 100644 index 0000000..af01143 --- /dev/null +++ b/apps/api/src/alicebot_api/retrieval_evaluation.py @@ -0,0 +1,710 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime +import json +from pathlib import Path +from typing import Callable +from uuid import UUID + +from alicebot_api.continuity_resumption import compile_continuity_resumption_brief +from alicebot_api.continuity_review import apply_continuity_correction +from alicebot_api.continuity_recall import query_continuity_recall +from alicebot_api.contracts import ( + RETRIEVAL_EVALUATION_FIXTURE_ORDER, + RETRIEVAL_EVALUATION_RESULT_ORDER, + ContinuityCorrectionInput, + ContinuityRecallQueryInput, + ContinuityResumptionBriefRequestInput, + RetrievalEvaluationStatus, + RetrievalEvaluationFixtureResult, + RetrievalEvaluationResponse, + RetrievalEvaluationSummary, +) +from alicebot_api.semantic_retrieval import calculate_mean_precision, calculate_precision_at_k +from alicebot_api.store import ContinuityRecallCandidateRow, ContinuityStore, JsonObject + +RETRIEVAL_EVALUATION_PRECISION_TARGET = 0.8 + + +@dataclass(frozen=True, slots=True) +class RetrievalEvaluationFixture: + fixture_id: str + title: str + request: ContinuityRecallQueryInput + candidates: tuple[ContinuityRecallCandidateRow, ...] + expected_relevant_ids: tuple[str, ...] + top_k: int = 1 + + +class _FixtureStore: + def __init__(self, rows: tuple[ContinuityRecallCandidateRow, ...]) -> None: + self._rows = rows + + def list_continuity_recall_candidates(self) -> list[ContinuityRecallCandidateRow]: + return list(self._rows) + + +def _candidate( + *, + object_id: str, + capture_event_id: str, + title: str, + body: dict[str, object], + provenance: dict[str, object], + confidence: float, + status: str = "active", + admission_posture: str = "DERIVED", + created_at: datetime, + last_confirmed_at: datetime | None = None, + supersedes_object_id: str | None = None, + superseded_by_object_id: str | None = None, +) -> ContinuityRecallCandidateRow: + parsed_supersedes = None if supersedes_object_id is None else UUID(supersedes_object_id) + parsed_superseded_by = None if superseded_by_object_id is None else UUID(superseded_by_object_id) + row: ContinuityRecallCandidateRow = { + "id": UUID(object_id), + "user_id": UUID("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"), + "capture_event_id": UUID(capture_event_id), + "object_type": "Decision", + "status": status, + "title": title, + "body": body, + "provenance": provenance, + "confidence": confidence, + "last_confirmed_at": last_confirmed_at, + "supersedes_object_id": parsed_supersedes, + "superseded_by_object_id": parsed_superseded_by, + "object_created_at": created_at, + "object_updated_at": created_at, + "admission_posture": admission_posture, + "admission_reason": "retrieval_evaluation_fixture", + "explicit_signal": None, + "capture_created_at": created_at, + } + return row + + +def _fixture_suite() -> tuple[RetrievalEvaluationFixture, ...]: + return ( + RetrievalEvaluationFixture( + fixture_id="confirmed_fresh_truth_preferred", + title="Confirmed fresher active truth outranks stale/superseded alternatives", + request=ContinuityRecallQueryInput(query="rollout", limit=5), + candidates=( + _candidate( + object_id="00000000-0000-4000-8000-000000000001", + capture_event_id="10000000-0000-4000-8000-000000000001", + title="Decision: Keep phased rollout", + body={"decision_text": "Keep phased rollout"}, + provenance={ + "project": "Project Phoenix", + "source_event_ids": ["e-1"], + "confirmation_status": "confirmed", + }, + confidence=0.91, + created_at=datetime(2026, 3, 29, 10, 0, tzinfo=UTC), + last_confirmed_at=datetime(2026, 3, 29, 10, 30, tzinfo=UTC), + ), + _candidate( + object_id="00000000-0000-4000-8000-000000000002", + capture_event_id="10000000-0000-4000-8000-000000000002", + title="Decision: Prior rollout note", + body={"decision_text": "rollout from last month"}, + provenance={"project": "Project Phoenix", "confirmation_status": "confirmed"}, + confidence=0.99, + status="stale", + created_at=datetime(2026, 2, 20, 10, 0, tzinfo=UTC), + last_confirmed_at=datetime(2026, 2, 21, 10, 0, tzinfo=UTC), + ), + _candidate( + object_id="00000000-0000-4000-8000-000000000003", + capture_event_id="10000000-0000-4000-8000-000000000003", + title="Decision: Superseded rollout approach", + body={"decision_text": "rollout through legacy pipeline"}, + provenance={"project": "Project Phoenix", "confirmation_status": "confirmed"}, + confidence=1.0, + status="superseded", + created_at=datetime(2026, 1, 30, 9, 0, tzinfo=UTC), + last_confirmed_at=datetime(2026, 1, 30, 9, 30, tzinfo=UTC), + superseded_by_object_id="00000000-0000-4000-8000-000000000001", + ), + ), + expected_relevant_ids=("00000000-0000-4000-8000-000000000001",), + ), + RetrievalEvaluationFixture( + fixture_id="provenance_breaks_tie", + title="Provenance quality breaks ranking ties deterministically", + request=ContinuityRecallQueryInput(query="pricing", limit=5), + candidates=( + _candidate( + object_id="00000000-0000-4000-8000-000000000011", + capture_event_id="10000000-0000-4000-8000-000000000011", + title="Decision: Keep pricing guardrail", + body={"decision_text": "pricing guardrail for enterprise"}, + provenance={ + "thread_id": "thread-1", + "source_event_ids": ["e-11"], + "confirmation_status": "confirmed", + }, + confidence=0.85, + created_at=datetime(2026, 3, 28, 10, 0, tzinfo=UTC), + last_confirmed_at=datetime(2026, 3, 28, 11, 0, tzinfo=UTC), + ), + _candidate( + object_id="00000000-0000-4000-8000-000000000012", + capture_event_id="10000000-0000-4000-8000-000000000012", + title="Decision: Pricing note", + body={"decision_text": "pricing guardrail for enterprise"}, + provenance={"confirmation_status": "confirmed"}, + confidence=0.95, + created_at=datetime(2026, 3, 28, 10, 0, tzinfo=UTC), + last_confirmed_at=datetime(2026, 3, 28, 11, 0, tzinfo=UTC), + ), + ), + expected_relevant_ids=("00000000-0000-4000-8000-000000000011",), + ), + RetrievalEvaluationFixture( + fixture_id="supersession_chain_prefers_current_truth", + title="Current active truth outranks superseded chain links", + request=ContinuityRecallQueryInput(query="api timeout", limit=5), + candidates=( + _candidate( + object_id="00000000-0000-4000-8000-000000000021", + capture_event_id="10000000-0000-4000-8000-000000000021", + title="Decision: API timeout is 30s", + body={"decision_text": "api timeout is 30 seconds"}, + provenance={"source_event_ids": ["e-21"], "confirmation_status": "confirmed"}, + confidence=0.88, + created_at=datetime(2026, 3, 29, 8, 0, tzinfo=UTC), + last_confirmed_at=datetime(2026, 3, 29, 8, 30, tzinfo=UTC), + supersedes_object_id="00000000-0000-4000-8000-000000000022", + ), + _candidate( + object_id="00000000-0000-4000-8000-000000000022", + capture_event_id="10000000-0000-4000-8000-000000000022", + title="Decision: API timeout is 45s", + body={"decision_text": "api timeout is 45 seconds"}, + provenance={"source_event_ids": ["e-22"], "confirmation_status": "confirmed"}, + confidence=0.99, + status="superseded", + created_at=datetime(2026, 3, 20, 8, 0, tzinfo=UTC), + last_confirmed_at=datetime(2026, 3, 20, 8, 30, tzinfo=UTC), + superseded_by_object_id="00000000-0000-4000-8000-000000000021", + ), + ), + expected_relevant_ids=("00000000-0000-4000-8000-000000000021",), + ), + ) + + +def _evaluate_fixture(fixture: RetrievalEvaluationFixture) -> tuple[RetrievalEvaluationFixtureResult, float]: + payload = query_continuity_recall( + _FixtureStore(fixture.candidates), # type: ignore[arg-type] + user_id=UUID("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"), + request=fixture.request, + apply_limit=False, + ) + returned_ids = [item["id"] for item in payload["items"]] + relevant_ids = set(fixture.expected_relevant_ids) + precision_at_k = calculate_precision_at_k( + returned_ids=returned_ids, + relevant_ids=relevant_ids, + top_k=fixture.top_k, + ) + evaluated_window = returned_ids[: fixture.top_k] + hit_count = sum(1 for candidate_id in evaluated_window if candidate_id in relevant_ids) + top_result = payload["items"][0] if payload["items"] else None + result: RetrievalEvaluationFixtureResult = { + "fixture_id": fixture.fixture_id, + "title": fixture.title, + "query": fixture.request.query or "", + "top_k": fixture.top_k, + "expected_relevant_ids": list(fixture.expected_relevant_ids), + "returned_ids": returned_ids, + "hit_count": hit_count, + "precision_at_k": precision_at_k, + "top_result_id": None if top_result is None else top_result["id"], + "top_result_ordering": None if top_result is None else top_result["ordering"], + } + precision_at_1 = calculate_precision_at_k( + returned_ids=returned_ids, + relevant_ids=relevant_ids, + top_k=1, + ) + return result, precision_at_1 + + +def get_retrieval_evaluation_summary( + store: ContinuityStore, + *, + user_id: UUID, +) -> RetrievalEvaluationResponse: + del store + del user_id + + fixture_suite = _fixture_suite() + evaluated_results: list[RetrievalEvaluationFixtureResult] = [] + precision_values: list[float] = [] + precision_at_1_values: list[float] = [] + + for fixture in fixture_suite: + result, precision_at_1 = _evaluate_fixture(fixture) + evaluated_results.append(result) + precision_values.append(result["precision_at_k"]) + precision_at_1_values.append(precision_at_1) + + fixture_count = len(fixture_suite) + precision_at_k_mean = calculate_mean_precision(precision_values) + precision_at_1_mean = calculate_mean_precision(precision_at_1_values) + passing_fixture_count = sum( + 1 + for result in evaluated_results + if result["precision_at_k"] >= RETRIEVAL_EVALUATION_PRECISION_TARGET + ) + status: RetrievalEvaluationStatus = ( + "pass" + if precision_at_k_mean >= RETRIEVAL_EVALUATION_PRECISION_TARGET + else "fail" + ) + summary: RetrievalEvaluationSummary = { + "fixture_count": fixture_count, + "evaluated_fixture_count": len(evaluated_results), + "passing_fixture_count": passing_fixture_count, + "precision_at_k_mean": precision_at_k_mean, + "precision_at_1_mean": precision_at_1_mean, + "precision_target": RETRIEVAL_EVALUATION_PRECISION_TARGET, + "status": status, + "fixture_order": list(RETRIEVAL_EVALUATION_FIXTURE_ORDER), + "result_order": list(RETRIEVAL_EVALUATION_RESULT_ORDER), + } + return { + "fixtures": evaluated_results, + "summary": summary, + } + + +PHASE9_EVALUATION_SCHEMA_VERSION = "phase9_eval_v1" +PHASE9_EVALUATION_PASS_THRESHOLD = 1.0 + + +@dataclass(frozen=True, slots=True) +class Phase9ImporterDefinition: + importer_name: str + source_kind: str + source_path: Path + project: str + thread_id: UUID | None + recall_query: str + import_fn: Callable[[ContinuityStore, UUID, Path], JsonObject] + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[4] + + +def _public_source_path(source_path: Path) -> str: + resolved = source_path.expanduser().resolve() + repo_root = _repo_root() + try: + return resolved.relative_to(repo_root).as_posix() + except ValueError: + return f"external/{resolved.name}" + + +def _as_int(value: object) -> int: + if isinstance(value, bool): + return int(value) + if isinstance(value, int): + return value + if isinstance(value, float): + return int(value) + if isinstance(value, str): + try: + return int(value.strip()) + except ValueError: + return 0 + return 0 + + +def calculate_phase9_metric_ratio(*, passed_count: int, total_count: int) -> float: + if total_count <= 0: + return 0.0 + return passed_count / total_count + + +def _build_phase9_importer_definitions( + *, + openclaw_source: str | Path | None, + markdown_source: str | Path | None, + chatgpt_source: str | Path | None, +) -> tuple[Phase9ImporterDefinition, ...]: + from alicebot_api.chatgpt_import import import_chatgpt_source + from alicebot_api.markdown_import import import_markdown_source + from alicebot_api.openclaw_import import import_openclaw_source + + repo_root = _repo_root() + resolved_openclaw = Path(openclaw_source) if openclaw_source is not None else ( + repo_root / "fixtures" / "openclaw" / "workspace_v1.json" + ) + resolved_markdown = Path(markdown_source) if markdown_source is not None else ( + repo_root / "fixtures" / "importers" / "markdown" / "workspace_v1.md" + ) + resolved_chatgpt = Path(chatgpt_source) if chatgpt_source is not None else ( + repo_root / "fixtures" / "importers" / "chatgpt" / "workspace_v1.json" + ) + + return ( + Phase9ImporterDefinition( + importer_name="openclaw", + source_kind="openclaw_import", + source_path=resolved_openclaw, + project="Alice Public Core", + thread_id=UUID("cccccccc-cccc-4ccc-8ccc-cccccccccccc"), + recall_query="MCP tool surface", + import_fn=lambda store, user_id, path: import_openclaw_source( + store, + user_id=user_id, + source=path, + ), + ), + Phase9ImporterDefinition( + importer_name="markdown", + source_kind="markdown_import", + source_path=resolved_markdown, + project="Markdown Import Project", + thread_id=UUID("eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee"), + recall_query="markdown importer deterministic", + import_fn=lambda store, user_id, path: import_markdown_source( + store, + user_id=user_id, + source=path, + ), + ), + Phase9ImporterDefinition( + importer_name="chatgpt", + source_kind="chatgpt_import", + source_path=resolved_chatgpt, + project="ChatGPT Import Project", + thread_id=UUID("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"), + recall_query="ChatGPT import provenance explicit", + import_fn=lambda store, user_id, path: import_chatgpt_source( + store, + user_id=user_id, + source=path, + ), + ), + ) + + +def _run_phase9_importer_evidence( + store: ContinuityStore, + *, + user_id: UUID, + definitions: tuple[Phase9ImporterDefinition, ...], +) -> list[JsonObject]: + evidence: list[JsonObject] = [] + for definition in definitions: + first_run = definition.import_fn(store, user_id, definition.source_path) + second_run = definition.import_fn(store, user_id, definition.source_path) + + import_success = ( + first_run.get("status") == "ok" + and _as_int(first_run.get("imported_count")) > 0 + ) + duplicate_posture_ok = ( + second_run.get("status") == "noop" + and _as_int(second_run.get("skipped_duplicates")) == _as_int(first_run.get("total_candidates")) + ) + evidence.append( + { + "importer": definition.importer_name, + "source_kind": definition.source_kind, + "source_path": _public_source_path(definition.source_path), + "first_run": first_run, + "second_run": second_run, + "import_success": import_success, + "duplicate_posture_ok": duplicate_posture_ok, + } + ) + return evidence + + +def _run_phase9_recall_precision( + store: ContinuityStore, + *, + user_id: UUID, + definitions: tuple[Phase9ImporterDefinition, ...], +) -> tuple[list[JsonObject], float]: + checks: list[JsonObject] = [] + hit_count = 0 + + for definition in definitions: + payload = query_continuity_recall( + store, + user_id=user_id, + request=ContinuityRecallQueryInput( + query=definition.recall_query, + thread_id=definition.thread_id, + project=definition.project, + limit=5, + ), + ) + top_item = payload["items"][0] if payload["items"] else None + top_source_kind = None + if top_item is not None and isinstance(top_item.get("provenance"), dict): + top_source_kind = top_item["provenance"].get("source_kind") + + hit = top_source_kind == definition.source_kind + if hit: + hit_count += 1 + + checks.append( + { + "importer": definition.importer_name, + "query": definition.recall_query, + "expected_source_kind": definition.source_kind, + "top_source_kind": top_source_kind, + "returned_count": payload["summary"]["returned_count"], + "hit": hit, + } + ) + + precision = calculate_phase9_metric_ratio( + passed_count=hit_count, + total_count=len(definitions), + ) + return checks, precision + + +def _run_phase9_resumption_usefulness( + store: ContinuityStore, + *, + user_id: UUID, + definitions: tuple[Phase9ImporterDefinition, ...], +) -> tuple[list[JsonObject], float]: + checks: list[JsonObject] = [] + useful_count = 0 + + for definition in definitions: + payload = compile_continuity_resumption_brief( + store, + user_id=user_id, + request=ContinuityResumptionBriefRequestInput( + query=None, + thread_id=definition.thread_id, + project=definition.project, + max_recent_changes=5, + max_open_loops=5, + ), + ) + brief = payload["brief"] + last_decision = brief["last_decision"]["item"] + next_action = brief["next_action"]["item"] + last_source_kind = ( + None + if last_decision is None + else last_decision["provenance"].get("source_kind") + ) + next_source_kind = ( + None + if next_action is None + else next_action["provenance"].get("source_kind") + ) + useful = ( + last_decision is not None + and next_action is not None + and last_source_kind == definition.source_kind + and next_source_kind == definition.source_kind + ) + if useful: + useful_count += 1 + checks.append( + { + "importer": definition.importer_name, + "expected_source_kind": definition.source_kind, + "last_decision_source_kind": last_source_kind, + "next_action_source_kind": next_source_kind, + "useful": useful, + } + ) + + usefulness_rate = calculate_phase9_metric_ratio( + passed_count=useful_count, + total_count=len(definitions), + ) + return checks, usefulness_rate + + +def _run_phase9_correction_effectiveness( + store: ContinuityStore, + *, + user_id: UUID, + target_definition: Phase9ImporterDefinition, +) -> JsonObject: + before = query_continuity_recall( + store, + user_id=user_id, + request=ContinuityRecallQueryInput( + query=target_definition.recall_query, + thread_id=target_definition.thread_id, + project=target_definition.project, + limit=5, + ), + ) + if not before["items"]: + return { + "target_importer": target_definition.importer_name, + "effective": False, + "reason": "no_recall_items_before_correction", + } + + before_top = before["items"][0] + before_top_id = str(before_top["id"]) + before_provenance = before_top.get("provenance") + replacement_provenance = ( + dict(before_provenance) + if isinstance(before_provenance, dict) + else {} + ) + replacement_provenance["phase9_eval_correction"] = "supersede_verification" + + correction = apply_continuity_correction( + store, + user_id=user_id, + continuity_object_id=UUID(before_top_id), + request=ContinuityCorrectionInput( + action="supersede", + reason="phase9_eval_correction_effectiveness", + replacement_title="Decision: Keep MCP tool surface narrow after correction verification.", + replacement_body={ + "decision_text": "Keep MCP tool surface narrow after correction verification.", + }, + replacement_provenance=replacement_provenance, + replacement_confidence=0.99, + ), + ) + + replacement_object = correction["replacement_object"] + replacement_id = None if replacement_object is None else replacement_object["id"] + + after = query_continuity_recall( + store, + user_id=user_id, + request=ContinuityRecallQueryInput( + query=target_definition.recall_query, + thread_id=target_definition.thread_id, + project=target_definition.project, + limit=5, + ), + ) + after_top_id = None if not after["items"] else str(after["items"][0]["id"]) + effective = ( + replacement_id is not None + and after_top_id == replacement_id + and after_top_id != before_top_id + ) + + return { + "target_importer": target_definition.importer_name, + "before_top_id": before_top_id, + "replacement_id": replacement_id, + "after_top_id": after_top_id, + "effective": effective, + } + + +def run_phase9_evaluation( + store: ContinuityStore, + *, + user_id: UUID, + openclaw_source: str | Path | None = None, + markdown_source: str | Path | None = None, + chatgpt_source: str | Path | None = None, +) -> JsonObject: + definitions = _build_phase9_importer_definitions( + openclaw_source=openclaw_source, + markdown_source=markdown_source, + chatgpt_source=chatgpt_source, + ) + + importer_runs = _run_phase9_importer_evidence( + store, + user_id=user_id, + definitions=definitions, + ) + recall_checks, recall_precision = _run_phase9_recall_precision( + store, + user_id=user_id, + definitions=definitions, + ) + resumption_checks, resumption_usefulness = _run_phase9_resumption_usefulness( + store, + user_id=user_id, + definitions=definitions, + ) + correction_check = _run_phase9_correction_effectiveness( + store, + user_id=user_id, + target_definition=definitions[0], + ) + + importer_success_count = sum(1 for run in importer_runs if run["import_success"] is True) + duplicate_posture_count = sum(1 for run in importer_runs if run["duplicate_posture_ok"] is True) + importer_total = len(importer_runs) + + importer_success_rate = calculate_phase9_metric_ratio( + passed_count=importer_success_count, + total_count=importer_total, + ) + duplicate_posture_rate = calculate_phase9_metric_ratio( + passed_count=duplicate_posture_count, + total_count=importer_total, + ) + correction_effectiveness_rate = 1.0 if correction_check["effective"] is True else 0.0 + + threshold = PHASE9_EVALUATION_PASS_THRESHOLD + status = ( + "pass" + if ( + importer_success_rate >= threshold + and duplicate_posture_rate >= threshold + and recall_precision >= threshold + and resumption_usefulness >= threshold + and correction_effectiveness_rate >= threshold + ) + else "fail" + ) + + return { + "schema_version": PHASE9_EVALUATION_SCHEMA_VERSION, + "generated_at": datetime.now(UTC).isoformat(), + "summary": { + "status": status, + "importer_count": importer_total, + "importer_success_rate": importer_success_rate, + "duplicate_posture_rate": duplicate_posture_rate, + "recall_precision_at_1": recall_precision, + "resumption_usefulness_rate": resumption_usefulness, + "correction_effectiveness_rate": correction_effectiveness_rate, + "pass_threshold": threshold, + }, + "importer_runs": importer_runs, + "recall_precision_checks": recall_checks, + "resumption_usefulness_checks": resumption_checks, + "correction_effectiveness": correction_check, + } + + +def write_phase9_evaluation_report( + *, + report: JsonObject, + report_path: str | Path, +) -> Path: + output_path = Path(report_path).expanduser().resolve() + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text( + json.dumps(report, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + return output_path diff --git a/apps/api/src/alicebot_api/semantic_retrieval.py b/apps/api/src/alicebot_api/semantic_retrieval.py new file mode 100644 index 0000000..d0f3d39 --- /dev/null +++ b/apps/api/src/alicebot_api/semantic_retrieval.py @@ -0,0 +1,365 @@ +from __future__ import annotations + +import math +from pathlib import Path +from typing import cast +from uuid import UUID + +from alicebot_api.artifacts import TaskArtifactNotFoundError +from alicebot_api.contracts import ( + SEMANTIC_MEMORY_RETRIEVAL_ORDER, + TASK_ARTIFACT_CHUNK_SEMANTIC_RETRIEVAL_ORDER, + ArtifactScopedSemanticArtifactChunkRetrievalInput, + SemanticMemoryRetrievalRequestInput, + SemanticMemoryRetrievalResponse, + SemanticMemoryRetrievalResultItem, + SemanticMemoryRetrievalSummary, + TaskArtifactChunkRetrievalScope, + TaskArtifactChunkRetrievalScopeKind, + TaskArtifactChunkSemanticRetrievalItem, + TaskArtifactChunkSemanticRetrievalResponse, + TaskArtifactChunkSemanticRetrievalSummary, + TaskScopedSemanticArtifactChunkRetrievalInput, +) +from alicebot_api.store import ( + ContinuityStore, + SemanticMemoryRetrievalRow, + TaskArtifactChunkSemanticRetrievalRow, +) +from alicebot_api.tasks import TaskNotFoundError + +SUPPORTED_TEXT_ARTIFACT_EXTENSIONS = { + ".txt": "text/plain", + ".text": "text/plain", + ".md": "text/markdown", + ".markdown": "text/markdown", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".eml": "message/rfc822", +} + + +class SemanticMemoryRetrievalValidationError(ValueError): + """Raised when semantic memory retrieval fails explicit validation.""" + + +class SemanticArtifactChunkRetrievalValidationError(ValueError): + """Raised when semantic artifact chunk retrieval fails explicit validation.""" + + +def calculate_precision_at_k( + *, + returned_ids: list[str], + relevant_ids: set[str], + top_k: int, +) -> float: + if top_k < 1: + raise SemanticMemoryRetrievalValidationError("top_k must be greater than or equal to 1") + + top_results = returned_ids[:top_k] + if not top_results: + return 0.0 + + hit_count = sum(1 for result_id in top_results if result_id in relevant_ids) + return hit_count / float(len(top_results)) + + +def calculate_mean_precision(precision_values: list[float]) -> float: + if not precision_values: + return 0.0 + return sum(precision_values) / float(len(precision_values)) + + +def _validate_query_vector( + query_vector: tuple[float, ...], + *, + error_type: type[ValueError], +) -> list[float]: + if not query_vector: + raise error_type( + "query_vector must include at least one numeric value" + ) + + normalized: list[float] = [] + for value in query_vector: + normalized_value = float(value) + if not math.isfinite(normalized_value): + raise error_type( + "query_vector must contain only finite numeric values" + ) + normalized.append(normalized_value) + + return normalized + + +def _validate_embedding_config_and_query_vector( + store: ContinuityStore, + *, + embedding_config_id: UUID, + query_vector: tuple[float, ...], + error_type: type[ValueError], +) -> tuple[dict[str, object], list[float]]: + config = store.get_embedding_config_optional(embedding_config_id) + if config is None: + raise error_type( + "embedding_config_id must reference an existing embedding config owned by the user: " + f"{embedding_config_id}" + ) + + normalized_query_vector = _validate_query_vector(query_vector, error_type=error_type) + if len(normalized_query_vector) != config["dimensions"]: + raise error_type( + "query_vector length must match embedding config dimensions " + f"({config['dimensions']}): {len(normalized_query_vector)}" + ) + + return config, normalized_query_vector + + +def validate_semantic_memory_retrieval_request( + store: ContinuityStore, + *, + request: SemanticMemoryRetrievalRequestInput, +) -> tuple[dict[str, object], list[float]]: + return _validate_embedding_config_and_query_vector( + store, + embedding_config_id=request.embedding_config_id, + query_vector=request.query_vector, + error_type=SemanticMemoryRetrievalValidationError, + ) + + +def serialize_semantic_memory_result_item( + row: SemanticMemoryRetrievalRow, +) -> SemanticMemoryRetrievalResultItem: + if row["status"] != "active": + raise SemanticMemoryRetrievalValidationError( + f"semantic retrieval only supports active memories: {row['id']}" + ) + + payload: SemanticMemoryRetrievalResultItem = { + "memory_id": str(row["id"]), + "memory_key": row["memory_key"], + "value": row["value"], + "source_event_ids": row["source_event_ids"], + "created_at": row["created_at"].isoformat(), + "updated_at": row["updated_at"].isoformat(), + "score": float(row["score"]), + } + payload["memory_type"] = row["memory_type"] + payload["confidence"] = row["confidence"] + payload["salience"] = row["salience"] + payload["confirmation_status"] = row["confirmation_status"] + payload["trust_class"] = row["trust_class"] + payload["promotion_eligibility"] = row["promotion_eligibility"] + payload["evidence_count"] = row["evidence_count"] + payload["independent_source_count"] = row["independent_source_count"] + payload["extracted_by_model"] = row["extracted_by_model"] + payload["trust_reason"] = row["trust_reason"] + payload["valid_from"] = None if row["valid_from"] is None else row["valid_from"].isoformat() + payload["valid_to"] = None if row["valid_to"] is None else row["valid_to"].isoformat() + payload["last_confirmed_at"] = ( + None + if row["last_confirmed_at"] is None + else row["last_confirmed_at"].isoformat() + ) + return payload + + +def _infer_media_type(*, relative_path: str, media_type_hint: str | None) -> str: + if media_type_hint is not None: + return media_type_hint + return SUPPORTED_TEXT_ARTIFACT_EXTENSIONS.get(Path(relative_path).suffix.lower(), "unknown") + + +def _build_task_artifact_chunk_retrieval_scope( + *, + kind: str, + task_id: UUID, + task_artifact_id: UUID | None = None, +) -> TaskArtifactChunkRetrievalScope: + scope: TaskArtifactChunkRetrievalScope = { + "kind": cast(TaskArtifactChunkRetrievalScopeKind, kind), + "task_id": str(task_id), + } + if task_artifact_id is not None: + scope["task_artifact_id"] = str(task_artifact_id) + return scope + + +def serialize_semantic_artifact_chunk_result_item( + row: TaskArtifactChunkSemanticRetrievalRow, +) -> TaskArtifactChunkSemanticRetrievalItem: + return { + "id": str(row["id"]), + "task_id": str(row["task_id"]), + "task_artifact_id": str(row["task_artifact_id"]), + "relative_path": row["relative_path"], + "media_type": _infer_media_type( + relative_path=row["relative_path"], + media_type_hint=row["media_type_hint"], + ), + "sequence_no": row["sequence_no"], + "char_start": row["char_start"], + "char_end_exclusive": row["char_end_exclusive"], + "text": row["text"], + "score": float(row["score"]), + } + + +def validate_semantic_artifact_chunk_retrieval_request( + store: ContinuityStore, + *, + embedding_config_id: UUID, + query_vector: tuple[float, ...], +) -> tuple[dict[str, object], list[float]]: + return _validate_embedding_config_and_query_vector( + store, + embedding_config_id=embedding_config_id, + query_vector=query_vector, + error_type=SemanticArtifactChunkRetrievalValidationError, + ) + + +def _count_ingested_artifacts(artifact_rows: list[dict[str, object]]) -> int: + return sum(1 for artifact_row in artifact_rows if artifact_row["ingestion_status"] == "ingested") + + +def _build_semantic_artifact_chunk_summary( + *, + embedding_config_id: UUID, + query_vector_dimensions: int, + limit: int, + searched_artifact_count: int, + scope: TaskArtifactChunkRetrievalScope, + items: list[TaskArtifactChunkSemanticRetrievalItem], +) -> TaskArtifactChunkSemanticRetrievalSummary: + return { + "embedding_config_id": str(embedding_config_id), + "query_vector_dimensions": query_vector_dimensions, + "limit": limit, + "returned_count": len(items), + "searched_artifact_count": searched_artifact_count, + "similarity_metric": "cosine_similarity", + "order": list(TASK_ARTIFACT_CHUNK_SEMANTIC_RETRIEVAL_ORDER), + "scope": scope, + } + + +def retrieve_task_scoped_semantic_artifact_chunk_records( + store: ContinuityStore, + *, + user_id: UUID, + request: TaskScopedSemanticArtifactChunkRetrievalInput, +) -> TaskArtifactChunkSemanticRetrievalResponse: + del user_id + + task = store.get_task_optional(request.task_id) + if task is None: + raise TaskNotFoundError(f"task {request.task_id} was not found") + + _config, query_vector = validate_semantic_artifact_chunk_retrieval_request( + store, + embedding_config_id=request.embedding_config_id, + query_vector=request.query_vector, + ) + items = [ + serialize_semantic_artifact_chunk_result_item(row) + for row in store.retrieve_task_scoped_semantic_artifact_chunk_matches( + task_id=request.task_id, + embedding_config_id=request.embedding_config_id, + query_vector=query_vector, + limit=request.limit, + ) + ] + artifact_rows = store.list_task_artifacts_for_task(request.task_id) + scope = _build_task_artifact_chunk_retrieval_scope( + kind="task", + task_id=request.task_id, + ) + return { + "items": items, + "summary": _build_semantic_artifact_chunk_summary( + embedding_config_id=request.embedding_config_id, + query_vector_dimensions=len(query_vector), + limit=request.limit, + searched_artifact_count=_count_ingested_artifacts(artifact_rows), + scope=scope, + items=items, + ), + } + + +def retrieve_artifact_scoped_semantic_artifact_chunk_records( + store: ContinuityStore, + *, + user_id: UUID, + request: ArtifactScopedSemanticArtifactChunkRetrievalInput, +) -> TaskArtifactChunkSemanticRetrievalResponse: + del user_id + + artifact_row = store.get_task_artifact_optional(request.task_artifact_id) + if artifact_row is None: + raise TaskArtifactNotFoundError(f"task artifact {request.task_artifact_id} was not found") + + _config, query_vector = validate_semantic_artifact_chunk_retrieval_request( + store, + embedding_config_id=request.embedding_config_id, + query_vector=request.query_vector, + ) + items = [ + serialize_semantic_artifact_chunk_result_item(row) + for row in store.retrieve_artifact_scoped_semantic_artifact_chunk_matches( + task_artifact_id=request.task_artifact_id, + embedding_config_id=request.embedding_config_id, + query_vector=query_vector, + limit=request.limit, + ) + ] + scope = _build_task_artifact_chunk_retrieval_scope( + kind="artifact", + task_id=artifact_row["task_id"], + task_artifact_id=artifact_row["id"], + ) + searched_artifact_count = 1 if artifact_row["ingestion_status"] == "ingested" else 0 + return { + "items": items, + "summary": _build_semantic_artifact_chunk_summary( + embedding_config_id=request.embedding_config_id, + query_vector_dimensions=len(query_vector), + limit=request.limit, + searched_artifact_count=searched_artifact_count, + scope=scope, + items=items, + ), + } + + +def retrieve_semantic_memory_records( + store: ContinuityStore, + *, + user_id: UUID, + request: SemanticMemoryRetrievalRequestInput, +) -> SemanticMemoryRetrievalResponse: + del user_id + + _config, query_vector = validate_semantic_memory_retrieval_request(store, request=request) + + items = [ + serialize_semantic_memory_result_item(row) + for row in store.retrieve_semantic_memory_matches( + embedding_config_id=request.embedding_config_id, + query_vector=query_vector, + limit=request.limit, + ) + ] + summary: SemanticMemoryRetrievalSummary = { + "embedding_config_id": str(request.embedding_config_id), + "limit": request.limit, + "returned_count": len(items), + "similarity_metric": "cosine_similarity", + "order": list(SEMANTIC_MEMORY_RETRIEVAL_ORDER), + } + return { + "items": items, + "summary": summary, + } diff --git a/apps/api/src/alicebot_api/store.py b/apps/api/src/alicebot_api/store.py new file mode 100644 index 0000000..e5c850f --- /dev/null +++ b/apps/api/src/alicebot_api/store.py @@ -0,0 +1,5961 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, TypedDict, TypeVar, cast +from uuid import UUID + +import psycopg +from psycopg.types.json import Jsonb + +JsonScalar = str | int | float | bool | None +JsonValue = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"] +JsonObject = dict[str, JsonValue] +RowT = TypeVar("RowT") + + +class UserRow(TypedDict): + id: UUID + email: str + display_name: str | None + created_at: datetime + + +class ThreadRow(TypedDict): + id: UUID + user_id: UUID + title: str + agent_profile_id: str + created_at: datetime + updated_at: datetime + + +class AgentProfileRow(TypedDict): + id: str + name: str + description: str + model_provider: str | None + model_name: str | None + + +class SessionRow(TypedDict): + id: UUID + user_id: UUID + thread_id: UUID + status: str + started_at: datetime | None + ended_at: datetime | None + created_at: datetime + + +class EventRow(TypedDict): + id: UUID + user_id: UUID + thread_id: UUID + session_id: UUID | None + sequence_no: int + kind: str + payload: JsonObject + created_at: datetime + + +class TraceRow(TypedDict): + id: UUID + user_id: UUID + thread_id: UUID + kind: str + compiler_version: str + status: str + limits: JsonObject + created_at: datetime + + +class TraceEventRow(TypedDict): + id: UUID + user_id: UUID + trace_id: UUID + sequence_no: int + kind: str + payload: JsonObject + created_at: datetime + + +class TraceReviewRow(TypedDict): + id: UUID + user_id: UUID + thread_id: UUID + kind: str + compiler_version: str + status: str + limits: JsonObject + created_at: datetime + trace_event_count: int + + +class MemoryRow(TypedDict): + id: UUID + user_id: UUID + agent_profile_id: str + memory_key: str + value: JsonValue + status: str + source_event_ids: list[str] + memory_type: str + confidence: float | None + salience: float | None + confirmation_status: str + trust_class: str + promotion_eligibility: str + evidence_count: int | None + independent_source_count: int | None + extracted_by_model: str | None + trust_reason: str | None + valid_from: datetime | None + valid_to: datetime | None + last_confirmed_at: datetime | None + created_at: datetime + updated_at: datetime + deleted_at: datetime | None + + +class MemoryRevisionRow(TypedDict): + id: UUID + user_id: UUID + memory_id: UUID + sequence_no: int + action: str + memory_key: str + previous_value: JsonValue | None + new_value: JsonValue | None + source_event_ids: list[str] + candidate: JsonObject + created_at: datetime + + +class MemoryReviewLabelRow(TypedDict): + id: UUID + user_id: UUID + memory_id: UUID + label: str + note: str | None + created_at: datetime + + +class OpenLoopRow(TypedDict): + id: UUID + user_id: UUID + memory_id: UUID | None + title: str + status: str + opened_at: datetime + due_at: datetime | None + resolved_at: datetime | None + resolution_note: str | None + created_at: datetime + updated_at: datetime + + +class ContinuityCaptureEventRow(TypedDict): + id: UUID + user_id: UUID + raw_content: str + explicit_signal: str | None + admission_posture: str + admission_reason: str + created_at: datetime + + +class ContinuityObjectRow(TypedDict): + id: UUID + user_id: UUID + capture_event_id: UUID + object_type: str + status: str + is_preserved: bool + is_searchable: bool + is_promotable: bool + title: str + body: JsonObject + provenance: JsonObject + confidence: float + last_confirmed_at: datetime | None + supersedes_object_id: UUID | None + superseded_by_object_id: UUID | None + created_at: datetime + updated_at: datetime + + +class ContinuityCorrectionEventRow(TypedDict): + id: UUID + user_id: UUID + continuity_object_id: UUID + action: str + reason: str | None + before_snapshot: JsonObject + after_snapshot: JsonObject + payload: JsonObject + created_at: datetime + + +class ContinuityRecallCandidateRow(TypedDict): + id: UUID + user_id: UUID + capture_event_id: UUID + object_type: str + status: str + is_preserved: bool + is_searchable: bool + is_promotable: bool + title: str + body: JsonObject + provenance: JsonObject + confidence: float + last_confirmed_at: datetime | None + supersedes_object_id: UUID | None + superseded_by_object_id: UUID | None + object_created_at: datetime + object_updated_at: datetime + admission_posture: str + admission_reason: str + explicit_signal: str | None + capture_created_at: datetime + + +class EmbeddingConfigRow(TypedDict): + id: UUID + user_id: UUID + provider: str + model: str + version: str + dimensions: int + status: str + metadata: JsonObject + created_at: datetime + + +class MemoryEmbeddingRow(TypedDict): + id: UUID + user_id: UUID + memory_id: UUID + embedding_config_id: UUID + dimensions: int + vector: list[float] + created_at: datetime + updated_at: datetime + + +class SemanticMemoryRetrievalRow(TypedDict): + id: UUID + user_id: UUID + agent_profile_id: str + memory_key: str + value: JsonValue + status: str + source_event_ids: list[str] + memory_type: str + confidence: float | None + salience: float | None + confirmation_status: str + trust_class: str + promotion_eligibility: str + evidence_count: int | None + independent_source_count: int | None + extracted_by_model: str | None + trust_reason: str | None + valid_from: datetime | None + valid_to: datetime | None + last_confirmed_at: datetime | None + created_at: datetime + updated_at: datetime + deleted_at: datetime | None + score: float + + +class EntityRow(TypedDict): + id: UUID + user_id: UUID + entity_type: str + name: str + source_memory_ids: list[str] + created_at: datetime + + +class EntityEdgeRow(TypedDict): + id: UUID + user_id: UUID + from_entity_id: UUID + to_entity_id: UUID + relationship_type: str + valid_from: datetime | None + valid_to: datetime | None + source_memory_ids: list[str] + created_at: datetime + + +class ConsentRow(TypedDict): + id: UUID + user_id: UUID + consent_key: str + status: str + metadata: JsonObject + created_at: datetime + updated_at: datetime + + +class PolicyRow(TypedDict): + id: UUID + user_id: UUID + agent_profile_id: str | None + name: str + action: str + scope: str + effect: str + priority: int + active: bool + conditions: JsonObject + required_consents: list[str] + created_at: datetime + updated_at: datetime + + +class ToolRow(TypedDict): + id: UUID + user_id: UUID + tool_key: str + name: str + description: str + version: str + metadata_version: str + active: bool + tags: list[str] + action_hints: list[str] + scope_hints: list[str] + domain_hints: list[str] + risk_hints: list[str] + metadata: JsonObject + created_at: datetime + + +class ApprovalRow(TypedDict): + id: UUID + user_id: UUID + thread_id: UUID + tool_id: UUID + task_run_id: UUID | None + task_step_id: UUID | None + status: str + request: JsonObject + tool: JsonObject + routing: JsonObject + routing_trace_id: UUID + created_at: datetime + resolved_at: datetime | None + resolved_by_user_id: UUID | None + + +class TaskRow(TypedDict): + id: UUID + user_id: UUID + thread_id: UUID + tool_id: UUID + status: str + request: JsonObject + tool: JsonObject + latest_approval_id: UUID | None + latest_execution_id: UUID | None + created_at: datetime + updated_at: datetime + + +class TaskWorkspaceRow(TypedDict): + id: UUID + user_id: UUID + task_id: UUID + status: str + local_path: str + created_at: datetime + updated_at: datetime + + +class GmailAccountRow(TypedDict): + id: UUID + user_id: UUID + provider_account_id: str + email_address: str + display_name: str | None + scope: str + created_at: datetime + updated_at: datetime + + +class CalendarAccountRow(TypedDict): + id: UUID + user_id: UUID + provider_account_id: str + email_address: str + display_name: str | None + scope: str + created_at: datetime + updated_at: datetime + + +class ProtectedGmailCredentialRow(TypedDict): + gmail_account_id: UUID + user_id: UUID + auth_kind: str + credential_kind: str + secret_manager_kind: str + secret_ref: str | None + credential_blob: JsonObject | None + created_at: datetime + updated_at: datetime + + +class ProtectedCalendarCredentialRow(TypedDict): + calendar_account_id: UUID + user_id: UUID + auth_kind: str + credential_kind: str + secret_manager_kind: str + secret_ref: str | None + credential_blob: JsonObject | None + created_at: datetime + updated_at: datetime + + +class ChannelIdentityRow(TypedDict): + id: UUID + user_account_id: UUID + workspace_id: UUID + channel_type: str + external_user_id: str + external_chat_id: str + external_username: str | None + status: str + linked_at: datetime + unlinked_at: datetime | None + created_at: datetime + updated_at: datetime + + +class ChannelLinkChallengeRow(TypedDict): + id: UUID + user_account_id: UUID + workspace_id: UUID + channel_type: str + challenge_token_hash: str + link_code: str + status: str + expires_at: datetime + confirmed_at: datetime | None + channel_identity_id: UUID | None + created_at: datetime + + +class ChannelThreadRow(TypedDict): + id: UUID + workspace_id: UUID + channel_type: str + external_thread_key: str + channel_identity_id: UUID | None + last_message_at: datetime | None + created_at: datetime + updated_at: datetime + + +class ChannelMessageRow(TypedDict): + id: UUID + workspace_id: UUID | None + channel_thread_id: UUID | None + channel_identity_id: UUID | None + channel_type: str + direction: str + provider_update_id: str | None + provider_message_id: str | None + external_chat_id: str | None + external_user_id: str | None + message_text: str | None + normalized_payload: JsonObject + route_status: str + idempotency_key: str + created_at: datetime + received_at: datetime + + +class ChatIntentRow(TypedDict): + id: UUID + workspace_id: UUID + channel_message_id: UUID + channel_thread_id: UUID | None + intent_kind: str + status: str + intent_payload: JsonObject + result_payload: JsonObject + handled_at: datetime | None + created_at: datetime + + +class ChannelDeliveryReceiptRow(TypedDict): + id: UUID + workspace_id: UUID + channel_message_id: UUID + channel_type: str + status: str + provider_receipt_id: str | None + failure_code: str | None + failure_detail: str | None + scheduled_job_id: UUID | None + scheduler_job_kind: str | None + scheduled_for: datetime | None + schedule_slot: str | None + notification_policy: JsonObject + rollout_flag_state: str + support_evidence: JsonObject + rate_limit_evidence: JsonObject + incident_evidence: JsonObject + recorded_at: datetime + created_at: datetime + + +class ApprovalChallengeRow(TypedDict): + id: UUID + workspace_id: UUID + approval_id: UUID + channel_message_id: UUID | None + status: str + challenge_prompt: str + challenge_payload: JsonObject + resolved_at: datetime | None + created_at: datetime + updated_at: datetime + + +class OpenLoopReviewRow(TypedDict): + id: UUID + workspace_id: UUID + continuity_object_id: UUID + channel_message_id: UUID | None + correction_event_id: UUID | None + review_action: str + note: str | None + created_at: datetime + + +class NotificationSubscriptionRow(TypedDict): + id: UUID + workspace_id: UUID + channel_type: str + channel_identity_id: UUID + notifications_enabled: bool + daily_brief_enabled: bool + daily_brief_window_start: str + open_loop_prompts_enabled: bool + waiting_for_prompts_enabled: bool + stale_prompts_enabled: bool + timezone: str + quiet_hours_enabled: bool + quiet_hours_start: str + quiet_hours_end: str + created_at: datetime + updated_at: datetime + + +class ContinuityBriefRow(TypedDict): + id: UUID + workspace_id: UUID + channel_type: str + channel_identity_id: UUID + brief_kind: str + assembly_version: str + summary: JsonObject + brief_payload: JsonObject + message_text: str + compiled_at: datetime + created_at: datetime + + +class DailyBriefJobRow(TypedDict): + id: UUID + workspace_id: UUID + channel_type: str + channel_identity_id: UUID + job_kind: str + prompt_kind: str | None + prompt_id: str | None + continuity_object_id: UUID | None + continuity_brief_id: UUID | None + schedule_slot: str + idempotency_key: str + due_at: datetime + status: str + suppression_reason: str | None + attempt_count: int + delivery_receipt_id: UUID | None + payload: JsonObject + result_payload: JsonObject + rollout_flag_state: str + support_evidence: JsonObject + rate_limit_evidence: JsonObject + incident_evidence: JsonObject + attempted_at: datetime | None + completed_at: datetime | None + created_at: datetime + updated_at: datetime + + +class ChatTelemetryRow(TypedDict): + id: UUID + user_account_id: UUID + workspace_id: UUID | None + channel_message_id: UUID | None + daily_brief_job_id: UUID | None + delivery_receipt_id: UUID | None + flow_kind: str + event_kind: str + status: str + route_path: str + rollout_flag_key: str | None + rollout_flag_state: str | None + rate_limit_key: str | None + rate_limit_window_seconds: int | None + rate_limit_max_requests: int | None + retry_after_seconds: int | None + abuse_signal: str | None + evidence: JsonObject + created_at: datetime + + +class TaskArtifactRow(TypedDict): + id: UUID + user_id: UUID + task_id: UUID + task_workspace_id: UUID + status: str + ingestion_status: str + relative_path: str + media_type_hint: str | None + created_at: datetime + updated_at: datetime + + +class TaskArtifactChunkRow(TypedDict): + id: UUID + user_id: UUID + task_artifact_id: UUID + sequence_no: int + char_start: int + char_end_exclusive: int + text: str + created_at: datetime + updated_at: datetime + + +class TaskArtifactChunkEmbeddingRow(TypedDict): + id: UUID + user_id: UUID + task_artifact_id: UUID + task_artifact_chunk_id: UUID + task_artifact_chunk_sequence_no: int + embedding_config_id: UUID + dimensions: int + vector: list[float] + created_at: datetime + updated_at: datetime + + +class TaskArtifactChunkSemanticRetrievalRow(TypedDict): + id: UUID + user_id: UUID + task_id: UUID + task_artifact_id: UUID + relative_path: str + media_type_hint: str | None + sequence_no: int + char_start: int + char_end_exclusive: int + text: str + created_at: datetime + updated_at: datetime + embedding_config_id: UUID + score: float + + +class TaskStepRow(TypedDict): + id: UUID + user_id: UUID + task_id: UUID + sequence_no: int + parent_step_id: UUID | None + source_approval_id: UUID | None + source_execution_id: UUID | None + kind: str + status: str + request: JsonObject + outcome: JsonObject + trace_id: UUID + trace_kind: str + created_at: datetime + updated_at: datetime + + +class TaskRunRow(TypedDict): + id: UUID + user_id: UUID + task_id: UUID + status: str + checkpoint: JsonObject + tick_count: int + step_count: int + max_ticks: int + retry_count: int + retry_cap: int + retry_posture: str + failure_class: str | None + stop_reason: str | None + last_transitioned_at: datetime + created_at: datetime + updated_at: datetime + + +class ToolExecutionRow(TypedDict): + id: UUID + user_id: UUID + approval_id: UUID + task_run_id: UUID | None + task_step_id: UUID + thread_id: UUID + tool_id: UUID + trace_id: UUID + request_event_id: UUID | None + result_event_id: UUID | None + status: str + handler_key: str | None + idempotency_key: str | None + request: JsonObject + tool: JsonObject + result: JsonObject + executed_at: datetime + + +class ExecutionBudgetRow(TypedDict): + id: UUID + user_id: UUID + agent_profile_id: str | None + tool_key: str | None + domain_hint: str | None + max_completed_executions: int + rolling_window_seconds: int | None + status: str + deactivated_at: datetime | None + superseded_by_budget_id: UUID | None + supersedes_budget_id: UUID | None + created_at: datetime + + +class CountRow(TypedDict): + count: int + + +class LabelCountRow(TypedDict): + label: str + count: int + + +INSERT_USER_SQL = """ + INSERT INTO users (id, email, display_name) + VALUES (%s, %s, %s) + RETURNING id, email, display_name, created_at + """ + +GET_USER_SQL = """ + SELECT id, email, display_name, created_at + FROM users + WHERE id = %s + """ + +INSERT_THREAD_SQL = """ + INSERT INTO threads (user_id, title, agent_profile_id) + VALUES (app.current_user_id(), %s, %s) + RETURNING id, user_id, title, agent_profile_id, created_at, updated_at + """ + +GET_THREAD_SQL = """ + SELECT id, user_id, title, agent_profile_id, created_at, updated_at + FROM threads + WHERE id = %s + """ + +LIST_THREADS_SQL = """ + SELECT id, user_id, title, agent_profile_id, created_at, updated_at + FROM threads + ORDER BY created_at DESC, id DESC + """ + +LIST_AGENT_PROFILES_SQL = """ + SELECT id, name, description, model_provider, model_name + FROM agent_profiles + ORDER BY id ASC + """ + +GET_AGENT_PROFILE_SQL = """ + SELECT id, name, description, model_provider, model_name + FROM agent_profiles + WHERE id = %s + """ + +INSERT_SESSION_SQL = """ + INSERT INTO sessions (user_id, thread_id, status) + VALUES (app.current_user_id(), %s, %s) + RETURNING id, user_id, thread_id, status, started_at, ended_at, created_at + """ + +LIST_THREAD_SESSIONS_SQL = """ + SELECT id, user_id, thread_id, status, started_at, ended_at, created_at + FROM sessions + WHERE thread_id = %s + ORDER BY started_at ASC, created_at ASC, id ASC + """ + +LOCK_THREAD_EVENTS_SQL = "SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 0))" +LOCK_TASK_STEPS_SQL = "SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 2))" +LOCK_TASK_WORKSPACES_SQL = "SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 3))" +LOCK_TASK_ARTIFACTS_SQL = "SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 4))" +LOCK_TASK_RUNS_SQL = "SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 5))" + +INSERT_EVENT_SQL = """ + WITH next_sequence AS ( + SELECT COALESCE(MAX(sequence_no) + 1, 1) AS sequence_no + FROM events + WHERE thread_id = %s + AND user_id = app.current_user_id() + ) + INSERT INTO events (user_id, thread_id, session_id, sequence_no, kind, payload) + SELECT app.current_user_id(), %s, %s, next_sequence.sequence_no, %s, %s + FROM next_sequence + RETURNING id, user_id, thread_id, session_id, sequence_no, kind, payload, created_at + """ + +LIST_THREAD_EVENTS_SQL = """ + SELECT id, user_id, thread_id, session_id, sequence_no, kind, payload, created_at + FROM events + WHERE thread_id = %s + ORDER BY sequence_no ASC + """ + +LIST_EVENTS_BY_IDS_SQL = """ + SELECT id, user_id, thread_id, session_id, sequence_no, kind, payload, created_at + FROM events + WHERE id = ANY(%s) + ORDER BY sequence_no ASC + """ + +INSERT_TRACE_SQL = """ + INSERT INTO traces (user_id, thread_id, kind, compiler_version, status, limits) + VALUES (%s, %s, %s, %s, %s, %s) + RETURNING id, user_id, thread_id, kind, compiler_version, status, limits, created_at + """ + +GET_TRACE_SQL = """ + SELECT id, user_id, thread_id, kind, compiler_version, status, limits, created_at + FROM traces + WHERE id = %s + """ + +LIST_TRACE_REVIEWS_SQL = """ + SELECT + traces.id, + traces.user_id, + traces.thread_id, + traces.kind, + traces.compiler_version, + traces.status, + traces.limits, + traces.created_at, + COUNT(trace_events.id) AS trace_event_count + FROM traces + LEFT JOIN trace_events + ON trace_events.trace_id = traces.id + AND trace_events.user_id = traces.user_id + GROUP BY + traces.id, + traces.user_id, + traces.thread_id, + traces.kind, + traces.compiler_version, + traces.status, + traces.limits, + traces.created_at + ORDER BY traces.created_at DESC, traces.id DESC + """ + +GET_TRACE_REVIEW_SQL = """ + SELECT + traces.id, + traces.user_id, + traces.thread_id, + traces.kind, + traces.compiler_version, + traces.status, + traces.limits, + traces.created_at, + COUNT(trace_events.id) AS trace_event_count + FROM traces + LEFT JOIN trace_events + ON trace_events.trace_id = traces.id + AND trace_events.user_id = traces.user_id + WHERE traces.id = %s + GROUP BY + traces.id, + traces.user_id, + traces.thread_id, + traces.kind, + traces.compiler_version, + traces.status, + traces.limits, + traces.created_at + """ + +INSERT_TRACE_EVENT_SQL = """ + INSERT INTO trace_events (user_id, trace_id, sequence_no, kind, payload) + VALUES (app.current_user_id(), %s, %s, %s, %s) + RETURNING id, user_id, trace_id, sequence_no, kind, payload, created_at + """ + +LIST_TRACE_EVENTS_SQL = """ + SELECT id, user_id, trace_id, sequence_no, kind, payload, created_at + FROM trace_events + WHERE trace_id = %s + ORDER BY sequence_no ASC, id ASC + """ + +INSERT_MEMORY_SQL = """ + INSERT INTO memories ( + user_id, + memory_key, + value, + status, + source_event_ids, + memory_type, + confidence, + salience, + confirmation_status, + trust_class, + promotion_eligibility, + evidence_count, + independent_source_count, + extracted_by_model, + trust_reason, + valid_from, + valid_to, + last_confirmed_at, + agent_profile_id, + created_at, + updated_at + ) + VALUES ( + app.current_user_id(), + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + clock_timestamp(), + clock_timestamp() + ) + RETURNING + id, + user_id, + agent_profile_id, + memory_key, + value, + status, + source_event_ids, + memory_type, + confidence, + salience, + confirmation_status, + trust_class, + promotion_eligibility, + evidence_count, + independent_source_count, + extracted_by_model, + trust_reason, + valid_from, + valid_to, + last_confirmed_at, + created_at, + updated_at, + deleted_at + """ + +GET_MEMORY_SQL = """ + SELECT + id, + user_id, + agent_profile_id, + memory_key, + value, + status, + source_event_ids, + memory_type, + confidence, + salience, + confirmation_status, + trust_class, + promotion_eligibility, + evidence_count, + independent_source_count, + extracted_by_model, + trust_reason, + valid_from, + valid_to, + last_confirmed_at, + created_at, + updated_at, + deleted_at + FROM memories + WHERE id = %s + """ + +LIST_MEMORIES_BY_IDS_SQL = """ + SELECT + id, + user_id, + agent_profile_id, + memory_key, + value, + status, + source_event_ids, + memory_type, + confidence, + salience, + confirmation_status, + trust_class, + promotion_eligibility, + evidence_count, + independent_source_count, + extracted_by_model, + trust_reason, + valid_from, + valid_to, + last_confirmed_at, + created_at, + updated_at, + deleted_at + FROM memories + WHERE id = ANY(%s) + ORDER BY created_at ASC, id ASC + """ + +GET_MEMORY_BY_KEY_SQL = """ + SELECT + id, + user_id, + agent_profile_id, + memory_key, + value, + status, + source_event_ids, + memory_type, + confidence, + salience, + confirmation_status, + trust_class, + promotion_eligibility, + evidence_count, + independent_source_count, + extracted_by_model, + trust_reason, + valid_from, + valid_to, + last_confirmed_at, + created_at, + updated_at, + deleted_at + FROM memories + WHERE memory_key = %s + """ + +GET_MEMORY_BY_KEY_AND_PROFILE_SQL = """ + SELECT + id, + user_id, + agent_profile_id, + memory_key, + value, + status, + source_event_ids, + memory_type, + confidence, + salience, + confirmation_status, + trust_class, + promotion_eligibility, + evidence_count, + independent_source_count, + extracted_by_model, + trust_reason, + valid_from, + valid_to, + last_confirmed_at, + created_at, + updated_at, + deleted_at + FROM memories + WHERE memory_key = %s + AND agent_profile_id = %s + """ + +LIST_MEMORIES_SQL = """ + SELECT + id, + user_id, + agent_profile_id, + memory_key, + value, + status, + source_event_ids, + memory_type, + confidence, + salience, + confirmation_status, + trust_class, + promotion_eligibility, + evidence_count, + independent_source_count, + extracted_by_model, + trust_reason, + valid_from, + valid_to, + last_confirmed_at, + created_at, + updated_at, + deleted_at + FROM memories + ORDER BY created_at ASC, id ASC + """ + +COUNT_MEMORIES_SQL = """ + SELECT COUNT(*) AS count + FROM memories + """ + +COUNT_MEMORIES_BY_STATUS_SQL = """ + SELECT COUNT(*) AS count + FROM memories + WHERE status = %s + """ + +COUNT_UNLABELED_REVIEW_MEMORIES_SQL = """ + SELECT COUNT(*) AS count + FROM memories + WHERE status = 'active' + AND NOT EXISTS ( + SELECT 1 + FROM memory_review_labels + WHERE memory_review_labels.memory_id = memories.id + ) + """ + +LIST_REVIEW_MEMORIES_SQL = """ + SELECT + id, + user_id, + agent_profile_id, + memory_key, + value, + status, + source_event_ids, + memory_type, + confidence, + salience, + confirmation_status, + trust_class, + promotion_eligibility, + evidence_count, + independent_source_count, + extracted_by_model, + trust_reason, + valid_from, + valid_to, + last_confirmed_at, + created_at, + updated_at, + deleted_at + FROM memories + ORDER BY updated_at DESC, created_at DESC, id DESC + LIMIT %s + """ + +LIST_REVIEW_MEMORIES_BY_STATUS_SQL = """ + SELECT + id, + user_id, + agent_profile_id, + memory_key, + value, + status, + source_event_ids, + memory_type, + confidence, + salience, + confirmation_status, + trust_class, + promotion_eligibility, + evidence_count, + independent_source_count, + extracted_by_model, + trust_reason, + valid_from, + valid_to, + last_confirmed_at, + created_at, + updated_at, + deleted_at + FROM memories + WHERE status = %s + ORDER BY updated_at DESC, created_at DESC, id DESC + LIMIT %s + """ + +LIST_UNLABELED_REVIEW_MEMORIES_SQL = """ + SELECT + id, + user_id, + agent_profile_id, + memory_key, + value, + status, + source_event_ids, + memory_type, + confidence, + salience, + confirmation_status, + trust_class, + promotion_eligibility, + evidence_count, + independent_source_count, + extracted_by_model, + trust_reason, + valid_from, + valid_to, + last_confirmed_at, + created_at, + updated_at, + deleted_at + FROM memories + WHERE status = 'active' + AND NOT EXISTS ( + SELECT 1 + FROM memory_review_labels + WHERE memory_review_labels.memory_id = memories.id + ) + ORDER BY updated_at DESC, created_at DESC, id DESC + """ + +LIST_LIMITED_UNLABELED_REVIEW_MEMORIES_SQL = """ + SELECT + id, + user_id, + agent_profile_id, + memory_key, + value, + status, + source_event_ids, + memory_type, + confidence, + salience, + confirmation_status, + trust_class, + promotion_eligibility, + evidence_count, + independent_source_count, + extracted_by_model, + trust_reason, + valid_from, + valid_to, + last_confirmed_at, + created_at, + updated_at, + deleted_at + FROM memories + WHERE status = 'active' + AND NOT EXISTS ( + SELECT 1 + FROM memory_review_labels + WHERE memory_review_labels.memory_id = memories.id + ) + ORDER BY updated_at DESC, created_at DESC, id DESC + LIMIT %s + """ + +LIST_CONTEXT_MEMORIES_SQL = """ + SELECT + id, + user_id, + agent_profile_id, + memory_key, + value, + status, + source_event_ids, + memory_type, + confidence, + salience, + confirmation_status, + trust_class, + promotion_eligibility, + evidence_count, + independent_source_count, + extracted_by_model, + trust_reason, + valid_from, + valid_to, + last_confirmed_at, + created_at, + updated_at, + deleted_at + FROM memories + ORDER BY updated_at ASC, created_at ASC, id ASC + """ + +LIST_CONTEXT_MEMORIES_FOR_PROFILE_SQL = """ + SELECT + id, + user_id, + agent_profile_id, + memory_key, + value, + status, + source_event_ids, + memory_type, + confidence, + salience, + confirmation_status, + trust_class, + promotion_eligibility, + evidence_count, + independent_source_count, + extracted_by_model, + trust_reason, + valid_from, + valid_to, + last_confirmed_at, + created_at, + updated_at, + deleted_at + FROM memories + WHERE agent_profile_id = %s + ORDER BY updated_at ASC, created_at ASC, id ASC + """ + +UPDATE_MEMORY_SQL = """ + UPDATE memories + SET value = %s, + status = %s, + source_event_ids = %s, + memory_type = %s, + confidence = %s, + salience = %s, + confirmation_status = %s, + trust_class = %s, + promotion_eligibility = %s, + evidence_count = %s, + independent_source_count = %s, + extracted_by_model = %s, + trust_reason = %s, + valid_from = %s, + valid_to = %s, + last_confirmed_at = %s, + updated_at = clock_timestamp(), + deleted_at = CASE + WHEN %s = 'deleted' THEN clock_timestamp() + ELSE NULL + END + WHERE id = %s + RETURNING + id, + user_id, + agent_profile_id, + memory_key, + value, + status, + source_event_ids, + memory_type, + confidence, + salience, + confirmation_status, + trust_class, + promotion_eligibility, + evidence_count, + independent_source_count, + extracted_by_model, + trust_reason, + valid_from, + valid_to, + last_confirmed_at, + created_at, + updated_at, + deleted_at + """ + +LOCK_MEMORY_REVISIONS_SQL = "SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 1))" + +INSERT_MEMORY_REVISION_SQL = """ + WITH next_sequence AS ( + SELECT COALESCE(MAX(sequence_no) + 1, 1) AS sequence_no + FROM memory_revisions + WHERE memory_id = %s + AND user_id = app.current_user_id() + ) + INSERT INTO memory_revisions ( + user_id, + memory_id, + sequence_no, + action, + memory_key, + previous_value, + new_value, + source_event_ids, + candidate + ) + SELECT + app.current_user_id(), + %s, + next_sequence.sequence_no, + %s, + %s, + %s, + %s, + %s, + %s + FROM next_sequence + RETURNING id, user_id, memory_id, sequence_no, action, memory_key, previous_value, new_value, source_event_ids, candidate, created_at + """ + +LIST_MEMORY_REVISIONS_SQL = """ + SELECT id, user_id, memory_id, sequence_no, action, memory_key, previous_value, new_value, source_event_ids, candidate, created_at + FROM memory_revisions + WHERE memory_id = %s + ORDER BY sequence_no ASC + """ + +COUNT_MEMORY_REVISIONS_SQL = """ + SELECT COUNT(*) AS count + FROM memory_revisions + WHERE memory_id = %s + """ + +LIST_LIMITED_MEMORY_REVISIONS_SQL = """ + SELECT id, user_id, memory_id, sequence_no, action, memory_key, previous_value, new_value, source_event_ids, candidate, created_at + FROM memory_revisions + WHERE memory_id = %s + ORDER BY sequence_no ASC + LIMIT %s + """ + +INSERT_MEMORY_REVIEW_LABEL_SQL = """ + INSERT INTO memory_review_labels (user_id, memory_id, label, note) + VALUES (app.current_user_id(), %s, %s, %s) + RETURNING id, user_id, memory_id, label, note, created_at + """ + +LIST_MEMORY_REVIEW_LABELS_SQL = """ + SELECT id, user_id, memory_id, label, note, created_at + FROM memory_review_labels + WHERE memory_id = %s + ORDER BY created_at ASC, id ASC + """ + +LIST_MEMORY_REVIEW_LABEL_COUNTS_SQL = """ + SELECT label, COUNT(*) AS count + FROM memory_review_labels + WHERE memory_id = %s + GROUP BY label + ORDER BY label ASC + """ + +COUNT_LABELED_MEMORIES_SQL = """ + SELECT COUNT(*) AS count + FROM memories + WHERE EXISTS ( + SELECT 1 + FROM memory_review_labels + WHERE memory_review_labels.memory_id = memories.id + ) + """ + +COUNT_UNLABELED_MEMORIES_SQL = """ + SELECT COUNT(*) AS count + FROM memories + WHERE NOT EXISTS ( + SELECT 1 + FROM memory_review_labels + WHERE memory_review_labels.memory_id = memories.id + ) + """ + +LIST_ALL_MEMORY_REVIEW_LABEL_COUNTS_SQL = """ + SELECT label, COUNT(*) AS count + FROM memory_review_labels + GROUP BY label + ORDER BY label ASC + """ + +LIST_ACTIVE_MEMORY_REVIEW_LABEL_COUNTS_SQL = """ + SELECT memory_review_labels.label, COUNT(*) AS count + FROM memory_review_labels + INNER JOIN memories ON memories.id = memory_review_labels.memory_id + WHERE memories.status = 'active' + GROUP BY memory_review_labels.label + ORDER BY memory_review_labels.label ASC + """ + +INSERT_OPEN_LOOP_SQL = """ + INSERT INTO open_loops ( + user_id, + memory_id, + title, + status, + opened_at, + due_at, + resolved_at, + resolution_note, + created_at, + updated_at + ) + VALUES ( + app.current_user_id(), + %s, + %s, + %s, + COALESCE(%s, clock_timestamp()), + %s, + %s, + %s, + clock_timestamp(), + clock_timestamp() + ) + RETURNING + id, + user_id, + memory_id, + title, + status, + opened_at, + due_at, + resolved_at, + resolution_note, + created_at, + updated_at + """ + +GET_OPEN_LOOP_SQL = """ + SELECT + id, + user_id, + memory_id, + title, + status, + opened_at, + due_at, + resolved_at, + resolution_note, + created_at, + updated_at + FROM open_loops + WHERE id = %s + """ + +LIST_OPEN_LOOPS_SQL = """ + SELECT + id, + user_id, + memory_id, + title, + status, + opened_at, + due_at, + resolved_at, + resolution_note, + created_at, + updated_at + FROM open_loops + ORDER BY opened_at DESC, created_at DESC, id DESC + """ + +LIST_OPEN_LOOPS_BY_STATUS_SQL = """ + SELECT + id, + user_id, + memory_id, + title, + status, + opened_at, + due_at, + resolved_at, + resolution_note, + created_at, + updated_at + FROM open_loops + WHERE status = %s + ORDER BY opened_at DESC, created_at DESC, id DESC + """ + +LIST_LIMITED_OPEN_LOOPS_SQL = """ + SELECT + id, + user_id, + memory_id, + title, + status, + opened_at, + due_at, + resolved_at, + resolution_note, + created_at, + updated_at + FROM open_loops + ORDER BY opened_at DESC, created_at DESC, id DESC + LIMIT %s + """ + +LIST_LIMITED_OPEN_LOOPS_BY_STATUS_SQL = """ + SELECT + id, + user_id, + memory_id, + title, + status, + opened_at, + due_at, + resolved_at, + resolution_note, + created_at, + updated_at + FROM open_loops + WHERE status = %s + ORDER BY opened_at DESC, created_at DESC, id DESC + LIMIT %s + """ + +COUNT_OPEN_LOOPS_SQL = """ + SELECT COUNT(*) AS count + FROM open_loops + """ + +COUNT_OPEN_LOOPS_BY_STATUS_SQL = """ + SELECT COUNT(*) AS count + FROM open_loops + WHERE status = %s + """ + +UPDATE_OPEN_LOOP_STATUS_SQL = """ + UPDATE open_loops + SET status = %s, + resolved_at = CASE + WHEN %s = 'open' THEN NULL + ELSE COALESCE(%s, clock_timestamp()) + END, + resolution_note = CASE + WHEN %s = 'open' THEN NULL + ELSE %s + END, + updated_at = clock_timestamp() + WHERE id = %s + RETURNING + id, + user_id, + memory_id, + title, + status, + opened_at, + due_at, + resolved_at, + resolution_note, + created_at, + updated_at + """ + +INSERT_EMBEDDING_CONFIG_SQL = """ + INSERT INTO embedding_configs ( + user_id, + provider, + model, + version, + dimensions, + status, + metadata, + created_at + ) + VALUES (app.current_user_id(), %s, %s, %s, %s, %s, %s, clock_timestamp()) + RETURNING id, user_id, provider, model, version, dimensions, status, metadata, created_at + """ + +GET_EMBEDDING_CONFIG_SQL = """ + SELECT id, user_id, provider, model, version, dimensions, status, metadata, created_at + FROM embedding_configs + WHERE id = %s + """ + +GET_EMBEDDING_CONFIG_BY_IDENTITY_SQL = """ + SELECT id, user_id, provider, model, version, dimensions, status, metadata, created_at + FROM embedding_configs + WHERE provider = %s + AND model = %s + AND version = %s + """ + +LIST_EMBEDDING_CONFIGS_SQL = """ + SELECT id, user_id, provider, model, version, dimensions, status, metadata, created_at + FROM embedding_configs + ORDER BY created_at ASC, id ASC + """ + +INSERT_MEMORY_EMBEDDING_SQL = """ + INSERT INTO memory_embeddings ( + user_id, + memory_id, + embedding_config_id, + dimensions, + vector, + created_at, + updated_at + ) + VALUES (app.current_user_id(), %s, %s, %s, %s, clock_timestamp(), clock_timestamp()) + RETURNING + id, + user_id, + memory_id, + embedding_config_id, + dimensions, + vector, + created_at, + updated_at + """ + +GET_MEMORY_EMBEDDING_SQL = """ + SELECT + id, + user_id, + memory_id, + embedding_config_id, + dimensions, + vector, + created_at, + updated_at + FROM memory_embeddings + WHERE id = %s + """ + +GET_MEMORY_EMBEDDING_BY_MEMORY_AND_CONFIG_SQL = """ + SELECT + id, + user_id, + memory_id, + embedding_config_id, + dimensions, + vector, + created_at, + updated_at + FROM memory_embeddings + WHERE memory_id = %s + AND embedding_config_id = %s + """ + +LIST_MEMORY_EMBEDDINGS_FOR_MEMORY_SQL = """ + SELECT + id, + user_id, + memory_id, + embedding_config_id, + dimensions, + vector, + created_at, + updated_at + FROM memory_embeddings + WHERE memory_id = %s + ORDER BY created_at ASC, id ASC + """ + +LIST_MEMORY_EMBEDDINGS_FOR_CONFIG_SQL = """ + SELECT + id, + user_id, + memory_id, + embedding_config_id, + dimensions, + vector, + created_at, + updated_at + FROM memory_embeddings + WHERE embedding_config_id = %s + ORDER BY created_at ASC, id ASC + """ + +UPDATE_MEMORY_EMBEDDING_SQL = """ + UPDATE memory_embeddings + SET dimensions = %s, + vector = %s, + updated_at = clock_timestamp() + WHERE id = %s + RETURNING + id, + user_id, + memory_id, + embedding_config_id, + dimensions, + vector, + created_at, + updated_at + """ + +RETRIEVE_SEMANTIC_MEMORY_MATCHES_SQL = """ + SELECT + memories.id, + memories.user_id, + memories.agent_profile_id, + memories.memory_key, + memories.value, + memories.status, + memories.source_event_ids, + memories.memory_type, + memories.confidence, + memories.salience, + memories.confirmation_status, + memories.trust_class, + memories.promotion_eligibility, + memories.evidence_count, + memories.independent_source_count, + memories.extracted_by_model, + memories.trust_reason, + memories.valid_from, + memories.valid_to, + memories.last_confirmed_at, + memories.created_at, + memories.updated_at, + memories.deleted_at, + 1 - ( + replace(memory_embeddings.vector::text, ' ', '')::vector <=> %s::vector + ) AS score + FROM memory_embeddings + JOIN memories + ON memories.id = memory_embeddings.memory_id + AND memories.user_id = memory_embeddings.user_id + WHERE memory_embeddings.embedding_config_id = %s + AND memory_embeddings.dimensions = %s + AND memories.status = 'active' + ORDER BY score DESC, memories.created_at ASC, memories.id ASC + LIMIT %s + """ + +RETRIEVE_SEMANTIC_MEMORY_MATCHES_FOR_PROFILE_SQL = """ + SELECT + memories.id, + memories.user_id, + memories.agent_profile_id, + memories.memory_key, + memories.value, + memories.status, + memories.source_event_ids, + memories.memory_type, + memories.confidence, + memories.salience, + memories.confirmation_status, + memories.trust_class, + memories.promotion_eligibility, + memories.evidence_count, + memories.independent_source_count, + memories.extracted_by_model, + memories.trust_reason, + memories.valid_from, + memories.valid_to, + memories.last_confirmed_at, + memories.created_at, + memories.updated_at, + memories.deleted_at, + 1 - ( + replace(memory_embeddings.vector::text, ' ', '')::vector <=> %s::vector + ) AS score + FROM memory_embeddings + JOIN memories + ON memories.id = memory_embeddings.memory_id + AND memories.user_id = memory_embeddings.user_id + WHERE memory_embeddings.embedding_config_id = %s + AND memory_embeddings.dimensions = %s + AND memories.status = 'active' + AND memories.agent_profile_id = %s + ORDER BY score DESC, memories.created_at ASC, memories.id ASC + LIMIT %s + """ + +RETRIEVE_TASK_SCOPED_SEMANTIC_ARTIFACT_CHUNK_MATCHES_SQL = """ + SELECT + chunks.id, + chunks.user_id, + artifacts.task_id, + artifacts.id AS task_artifact_id, + artifacts.relative_path, + artifacts.media_type_hint, + chunks.sequence_no, + chunks.char_start, + chunks.char_end_exclusive, + chunks.text, + chunks.created_at, + chunks.updated_at, + embeddings.embedding_config_id, + 1 - ( + replace(embeddings.vector::text, ' ', '')::vector <=> %s::vector + ) AS score + FROM task_artifact_chunk_embeddings AS embeddings + JOIN task_artifact_chunks AS chunks + ON chunks.id = embeddings.task_artifact_chunk_id + AND chunks.user_id = embeddings.user_id + JOIN task_artifacts AS artifacts + ON artifacts.id = chunks.task_artifact_id + AND artifacts.user_id = chunks.user_id + WHERE embeddings.embedding_config_id = %s + AND embeddings.dimensions = %s + AND artifacts.task_id = %s + AND artifacts.ingestion_status = 'ingested' + ORDER BY score DESC, artifacts.relative_path ASC, chunks.sequence_no ASC, chunks.id ASC + LIMIT %s + """ + +RETRIEVE_ARTIFACT_SCOPED_SEMANTIC_ARTIFACT_CHUNK_MATCHES_SQL = """ + SELECT + chunks.id, + chunks.user_id, + artifacts.task_id, + artifacts.id AS task_artifact_id, + artifacts.relative_path, + artifacts.media_type_hint, + chunks.sequence_no, + chunks.char_start, + chunks.char_end_exclusive, + chunks.text, + chunks.created_at, + chunks.updated_at, + embeddings.embedding_config_id, + 1 - ( + replace(embeddings.vector::text, ' ', '')::vector <=> %s::vector + ) AS score + FROM task_artifact_chunk_embeddings AS embeddings + JOIN task_artifact_chunks AS chunks + ON chunks.id = embeddings.task_artifact_chunk_id + AND chunks.user_id = embeddings.user_id + JOIN task_artifacts AS artifacts + ON artifacts.id = chunks.task_artifact_id + AND artifacts.user_id = chunks.user_id + WHERE embeddings.embedding_config_id = %s + AND embeddings.dimensions = %s + AND artifacts.id = %s + AND artifacts.ingestion_status = 'ingested' + ORDER BY score DESC, artifacts.relative_path ASC, chunks.sequence_no ASC, chunks.id ASC + LIMIT %s + """ + +INSERT_ENTITY_SQL = """ + INSERT INTO entities (user_id, entity_type, name, source_memory_ids, created_at) + VALUES (app.current_user_id(), %s, %s, %s, clock_timestamp()) + RETURNING id, user_id, entity_type, name, source_memory_ids, created_at + """ + +GET_ENTITY_SQL = """ + SELECT id, user_id, entity_type, name, source_memory_ids, created_at + FROM entities + WHERE id = %s + """ + +LIST_ENTITIES_SQL = """ + SELECT id, user_id, entity_type, name, source_memory_ids, created_at + FROM entities + ORDER BY created_at ASC, id ASC + """ + +INSERT_ENTITY_EDGE_SQL = """ + INSERT INTO entity_edges ( + user_id, + from_entity_id, + to_entity_id, + relationship_type, + valid_from, + valid_to, + source_memory_ids, + created_at + ) + VALUES (app.current_user_id(), %s, %s, %s, %s, %s, %s, clock_timestamp()) + RETURNING + id, + user_id, + from_entity_id, + to_entity_id, + relationship_type, + valid_from, + valid_to, + source_memory_ids, + created_at + """ + +LIST_ENTITY_EDGES_FOR_ENTITY_SQL = """ + SELECT + id, + user_id, + from_entity_id, + to_entity_id, + relationship_type, + valid_from, + valid_to, + source_memory_ids, + created_at + FROM entity_edges + WHERE from_entity_id = %s OR to_entity_id = %s + ORDER BY created_at ASC, id ASC + """ + +LIST_ENTITY_EDGES_FOR_ENTITIES_SQL = """ + SELECT + id, + user_id, + from_entity_id, + to_entity_id, + relationship_type, + valid_from, + valid_to, + source_memory_ids, + created_at + FROM entity_edges + WHERE from_entity_id = ANY(%s) OR to_entity_id = ANY(%s) + ORDER BY created_at ASC, id ASC + """ + +INSERT_CONSENT_SQL = """ + INSERT INTO consents ( + user_id, + consent_key, + status, + metadata, + created_at, + updated_at + ) + VALUES (app.current_user_id(), %s, %s, %s, clock_timestamp(), clock_timestamp()) + RETURNING id, user_id, consent_key, status, metadata, created_at, updated_at + """ + +GET_CONSENT_BY_KEY_SQL = """ + SELECT id, user_id, consent_key, status, metadata, created_at, updated_at + FROM consents + WHERE consent_key = %s + """ + +LIST_CONSENTS_SQL = """ + SELECT id, user_id, consent_key, status, metadata, created_at, updated_at + FROM consents + ORDER BY consent_key ASC, created_at ASC, id ASC + """ + +UPDATE_CONSENT_SQL = """ + UPDATE consents + SET status = %s, + metadata = %s, + updated_at = clock_timestamp() + WHERE id = %s + RETURNING id, user_id, consent_key, status, metadata, created_at, updated_at + """ + +INSERT_POLICY_SQL = """ + INSERT INTO policies ( + user_id, + agent_profile_id, + name, + action, + scope, + effect, + priority, + active, + conditions, + required_consents, + created_at, + updated_at + ) + VALUES (app.current_user_id(), %s, %s, %s, %s, %s, %s, %s, %s, %s, clock_timestamp(), clock_timestamp()) + RETURNING + id, + user_id, + agent_profile_id, + name, + action, + scope, + effect, + priority, + active, + conditions, + required_consents, + created_at, + updated_at + """ + +GET_POLICY_SQL = """ + SELECT + id, + user_id, + agent_profile_id, + name, + action, + scope, + effect, + priority, + active, + conditions, + required_consents, + created_at, + updated_at + FROM policies + WHERE id = %s + """ + +LIST_POLICIES_SQL = """ + SELECT + id, + user_id, + agent_profile_id, + name, + action, + scope, + effect, + priority, + active, + conditions, + required_consents, + created_at, + updated_at + FROM policies + ORDER BY priority ASC, created_at ASC, id ASC + """ + +LIST_ACTIVE_POLICIES_SQL = """ + SELECT + id, + user_id, + agent_profile_id, + name, + action, + scope, + effect, + priority, + active, + conditions, + required_consents, + created_at, + updated_at + FROM policies + WHERE active = TRUE + ORDER BY priority ASC, created_at ASC, id ASC + """ + +LIST_ACTIVE_POLICIES_FOR_PROFILE_SQL = """ + SELECT + id, + user_id, + agent_profile_id, + name, + action, + scope, + effect, + priority, + active, + conditions, + required_consents, + created_at, + updated_at + FROM policies + WHERE active = TRUE + AND (agent_profile_id IS NULL OR agent_profile_id = %s) + ORDER BY priority ASC, created_at ASC, id ASC + """ + +INSERT_TOOL_SQL = """ + INSERT INTO tools ( + user_id, + tool_key, + name, + description, + version, + metadata_version, + active, + tags, + action_hints, + scope_hints, + domain_hints, + risk_hints, + metadata, + created_at + ) + VALUES ( + app.current_user_id(), + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + clock_timestamp() + ) + RETURNING + id, + user_id, + tool_key, + name, + description, + version, + metadata_version, + active, + tags, + action_hints, + scope_hints, + domain_hints, + risk_hints, + metadata, + created_at + """ + +GET_TOOL_SQL = """ + SELECT + id, + user_id, + tool_key, + name, + description, + version, + metadata_version, + active, + tags, + action_hints, + scope_hints, + domain_hints, + risk_hints, + metadata, + created_at + FROM tools + WHERE id = %s + """ + +LIST_TOOLS_SQL = """ + SELECT + id, + user_id, + tool_key, + name, + description, + version, + metadata_version, + active, + tags, + action_hints, + scope_hints, + domain_hints, + risk_hints, + metadata, + created_at + FROM tools + ORDER BY tool_key ASC, version ASC, created_at ASC, id ASC + """ + +LIST_ACTIVE_TOOLS_SQL = """ + SELECT + id, + user_id, + tool_key, + name, + description, + version, + metadata_version, + active, + tags, + action_hints, + scope_hints, + domain_hints, + risk_hints, + metadata, + created_at + FROM tools + WHERE active = TRUE + ORDER BY tool_key ASC, version ASC, created_at ASC, id ASC + """ + +INSERT_APPROVAL_SQL = """ + INSERT INTO approvals ( + user_id, + thread_id, + tool_id, + task_run_id, + task_step_id, + status, + request, + tool, + routing, + routing_trace_id, + created_at + ) + VALUES ( + app.current_user_id(), + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + clock_timestamp() + ) + RETURNING + id, + user_id, + thread_id, + tool_id, + task_run_id, + task_step_id, + status, + request, + tool, + routing, + routing_trace_id, + created_at, + resolved_at, + resolved_by_user_id + """ + +GET_APPROVAL_SQL = """ + SELECT + id, + user_id, + thread_id, + tool_id, + task_run_id, + task_step_id, + status, + request, + tool, + routing, + routing_trace_id, + created_at, + resolved_at, + resolved_by_user_id + FROM approvals + WHERE id = %s + """ + +LIST_APPROVALS_SQL = """ + SELECT + id, + user_id, + thread_id, + tool_id, + task_run_id, + task_step_id, + status, + request, + tool, + routing, + routing_trace_id, + created_at, + resolved_at, + resolved_by_user_id + FROM approvals + ORDER BY created_at ASC, id ASC + """ + +UPDATE_APPROVAL_RESOLUTION_SQL = """ + UPDATE approvals + SET status = %s, + resolved_at = clock_timestamp(), + resolved_by_user_id = app.current_user_id() + WHERE id = %s + AND status = 'pending' + RETURNING + id, + user_id, + thread_id, + tool_id, + task_run_id, + task_step_id, + status, + request, + tool, + routing, + routing_trace_id, + created_at, + resolved_at, + resolved_by_user_id + """ + +UPDATE_APPROVAL_TASK_STEP_SQL = """ + UPDATE approvals + SET task_step_id = %s + WHERE id = %s + RETURNING + id, + user_id, + thread_id, + tool_id, + task_run_id, + task_step_id, + status, + request, + tool, + routing, + routing_trace_id, + created_at, + resolved_at, + resolved_by_user_id + """ + +UPDATE_APPROVAL_TASK_RUN_SQL = """ + UPDATE approvals + SET task_run_id = %s + WHERE id = %s + RETURNING + id, + user_id, + thread_id, + tool_id, + task_run_id, + task_step_id, + status, + request, + tool, + routing, + routing_trace_id, + created_at, + resolved_at, + resolved_by_user_id + """ + +INSERT_TASK_SQL = """ + INSERT INTO tasks ( + user_id, + thread_id, + tool_id, + status, + request, + tool, + latest_approval_id, + latest_execution_id, + created_at, + updated_at + ) + VALUES ( + app.current_user_id(), + %s, + %s, + %s, + %s, + %s, + %s, + %s, + clock_timestamp(), + clock_timestamp() + ) + RETURNING + id, + user_id, + thread_id, + tool_id, + status, + request, + tool, + latest_approval_id, + latest_execution_id, + created_at, + updated_at + """ + +GET_TASK_SQL = """ + SELECT + id, + user_id, + thread_id, + tool_id, + status, + request, + tool, + latest_approval_id, + latest_execution_id, + created_at, + updated_at + FROM tasks + WHERE id = %s + """ + +GET_TASK_BY_APPROVAL_SQL = """ + SELECT + id, + user_id, + thread_id, + tool_id, + status, + request, + tool, + latest_approval_id, + latest_execution_id, + created_at, + updated_at + FROM tasks + WHERE latest_approval_id = %s + """ + +LIST_TASKS_SQL = """ + SELECT + id, + user_id, + thread_id, + tool_id, + status, + request, + tool, + latest_approval_id, + latest_execution_id, + created_at, + updated_at + FROM tasks + ORDER BY created_at ASC, id ASC + """ + +UPDATE_TASK_STATUS_BY_APPROVAL_SQL = """ + UPDATE tasks + SET status = %s, + updated_at = clock_timestamp() + WHERE latest_approval_id = %s + RETURNING + id, + user_id, + thread_id, + tool_id, + status, + request, + tool, + latest_approval_id, + latest_execution_id, + created_at, + updated_at + """ + +UPDATE_TASK_EXECUTION_BY_APPROVAL_SQL = """ + UPDATE tasks + SET status = %s, + latest_execution_id = %s, + updated_at = clock_timestamp() + WHERE latest_approval_id = %s + RETURNING + id, + user_id, + thread_id, + tool_id, + status, + request, + tool, + latest_approval_id, + latest_execution_id, + created_at, + updated_at + """ + +UPDATE_TASK_STATUS_SQL = """ + UPDATE tasks + SET status = %s, + latest_approval_id = %s, + latest_execution_id = %s, + updated_at = clock_timestamp() + WHERE id = %s + RETURNING + id, + user_id, + thread_id, + tool_id, + status, + request, + tool, + latest_approval_id, + latest_execution_id, + created_at, + updated_at + """ + +INSERT_GMAIL_ACCOUNT_SQL = """ + INSERT INTO gmail_accounts ( + user_id, + provider_account_id, + email_address, + display_name, + scope, + created_at, + updated_at + ) + VALUES ( + app.current_user_id(), + %s, + %s, + %s, + %s, + clock_timestamp(), + clock_timestamp() + ) + RETURNING + id, + user_id, + provider_account_id, + email_address, + display_name, + scope, + created_at, + updated_at + """ + +INSERT_GMAIL_ACCOUNT_CREDENTIAL_SQL = """ + INSERT INTO gmail_account_credentials ( + gmail_account_id, + user_id, + auth_kind, + credential_kind, + secret_manager_kind, + secret_ref, + credential_blob, + created_at, + updated_at + ) + VALUES ( + %s, + app.current_user_id(), + %s, + %s, + %s, + %s, + %s, + clock_timestamp(), + clock_timestamp() + ) + RETURNING + gmail_account_id, + user_id, + auth_kind, + credential_kind, + secret_manager_kind, + secret_ref, + credential_blob, + created_at, + updated_at + """ + +GET_GMAIL_ACCOUNT_SQL = """ + SELECT + id, + user_id, + provider_account_id, + email_address, + display_name, + scope, + created_at, + updated_at + FROM gmail_accounts + WHERE id = %s + """ + +GET_GMAIL_ACCOUNT_BY_PROVIDER_ACCOUNT_ID_SQL = """ + SELECT + id, + user_id, + provider_account_id, + email_address, + display_name, + scope, + created_at, + updated_at + FROM gmail_accounts + WHERE provider_account_id = %s + ORDER BY created_at ASC, id ASC + LIMIT 1 + """ + +GET_GMAIL_ACCOUNT_CREDENTIAL_SQL = """ + SELECT + gmail_account_id, + user_id, + auth_kind, + credential_kind, + secret_manager_kind, + secret_ref, + credential_blob, + created_at, + updated_at + FROM gmail_account_credentials + WHERE gmail_account_id = %s + """ + +UPDATE_GMAIL_ACCOUNT_CREDENTIAL_SQL = """ + UPDATE gmail_account_credentials + SET + auth_kind = %s, + credential_kind = %s, + secret_manager_kind = %s, + secret_ref = %s, + credential_blob = %s, + updated_at = clock_timestamp() + WHERE gmail_account_id = %s + RETURNING + gmail_account_id, + user_id, + auth_kind, + credential_kind, + secret_manager_kind, + secret_ref, + credential_blob, + created_at, + updated_at + """ + +LIST_GMAIL_ACCOUNTS_SQL = """ + SELECT + id, + user_id, + provider_account_id, + email_address, + display_name, + scope, + created_at, + updated_at + FROM gmail_accounts + ORDER BY created_at ASC, id ASC + """ + +INSERT_CALENDAR_ACCOUNT_SQL = """ + INSERT INTO calendar_accounts ( + user_id, + provider_account_id, + email_address, + display_name, + scope, + created_at, + updated_at + ) + VALUES ( + app.current_user_id(), + %s, + %s, + %s, + %s, + clock_timestamp(), + clock_timestamp() + ) + RETURNING + id, + user_id, + provider_account_id, + email_address, + display_name, + scope, + created_at, + updated_at + """ + +INSERT_CALENDAR_ACCOUNT_CREDENTIAL_SQL = """ + INSERT INTO calendar_account_credentials ( + calendar_account_id, + user_id, + auth_kind, + credential_kind, + secret_manager_kind, + secret_ref, + credential_blob, + created_at, + updated_at + ) + VALUES ( + %s, + app.current_user_id(), + %s, + %s, + %s, + %s, + %s, + clock_timestamp(), + clock_timestamp() + ) + RETURNING + calendar_account_id, + user_id, + auth_kind, + credential_kind, + secret_manager_kind, + secret_ref, + credential_blob, + created_at, + updated_at + """ + +GET_CALENDAR_ACCOUNT_SQL = """ + SELECT + id, + user_id, + provider_account_id, + email_address, + display_name, + scope, + created_at, + updated_at + FROM calendar_accounts + WHERE id = %s + """ + +GET_CALENDAR_ACCOUNT_BY_PROVIDER_ACCOUNT_ID_SQL = """ + SELECT + id, + user_id, + provider_account_id, + email_address, + display_name, + scope, + created_at, + updated_at + FROM calendar_accounts + WHERE provider_account_id = %s + ORDER BY created_at ASC, id ASC + LIMIT 1 + """ + +GET_CALENDAR_ACCOUNT_CREDENTIAL_SQL = """ + SELECT + calendar_account_id, + user_id, + auth_kind, + credential_kind, + secret_manager_kind, + secret_ref, + credential_blob, + created_at, + updated_at + FROM calendar_account_credentials + WHERE calendar_account_id = %s + """ + +LIST_CALENDAR_ACCOUNTS_SQL = """ + SELECT + id, + user_id, + provider_account_id, + email_address, + display_name, + scope, + created_at, + updated_at + FROM calendar_accounts + ORDER BY created_at ASC, id ASC + """ + +INSERT_TASK_WORKSPACE_SQL = """ + INSERT INTO task_workspaces ( + user_id, + task_id, + status, + local_path, + created_at, + updated_at + ) + VALUES ( + app.current_user_id(), + %s, + %s, + %s, + clock_timestamp(), + clock_timestamp() + ) + RETURNING + id, + user_id, + task_id, + status, + local_path, + created_at, + updated_at + """ + +GET_TASK_WORKSPACE_SQL = """ + SELECT + id, + user_id, + task_id, + status, + local_path, + created_at, + updated_at + FROM task_workspaces + WHERE id = %s + """ + +GET_ACTIVE_TASK_WORKSPACE_FOR_TASK_SQL = """ + SELECT + id, + user_id, + task_id, + status, + local_path, + created_at, + updated_at + FROM task_workspaces + WHERE task_id = %s + AND status = 'active' + ORDER BY created_at ASC, id ASC + LIMIT 1 + """ + +LIST_TASK_WORKSPACES_SQL = """ + SELECT + id, + user_id, + task_id, + status, + local_path, + created_at, + updated_at + FROM task_workspaces + ORDER BY created_at ASC, id ASC + """ + +INSERT_TASK_ARTIFACT_SQL = """ + INSERT INTO task_artifacts ( + user_id, + task_id, + task_workspace_id, + status, + ingestion_status, + relative_path, + media_type_hint, + created_at, + updated_at + ) + VALUES ( + app.current_user_id(), + %s, + %s, + %s, + %s, + %s, + %s, + clock_timestamp(), + clock_timestamp() + ) + RETURNING + id, + user_id, + task_id, + task_workspace_id, + status, + ingestion_status, + relative_path, + media_type_hint, + created_at, + updated_at + """ + +GET_TASK_ARTIFACT_SQL = """ + SELECT + id, + user_id, + task_id, + task_workspace_id, + status, + ingestion_status, + relative_path, + media_type_hint, + created_at, + updated_at + FROM task_artifacts + WHERE id = %s + """ + +GET_TASK_ARTIFACT_BY_WORKSPACE_RELATIVE_PATH_SQL = """ + SELECT + id, + user_id, + task_id, + task_workspace_id, + status, + ingestion_status, + relative_path, + media_type_hint, + created_at, + updated_at + FROM task_artifacts + WHERE task_workspace_id = %s + AND relative_path = %s + ORDER BY created_at ASC, id ASC + LIMIT 1 + """ + +LIST_TASK_ARTIFACTS_SQL = """ + SELECT + id, + user_id, + task_id, + task_workspace_id, + status, + ingestion_status, + relative_path, + media_type_hint, + created_at, + updated_at + FROM task_artifacts + ORDER BY created_at ASC, id ASC + """ + +LIST_TASK_ARTIFACTS_FOR_TASK_SQL = """ + SELECT + id, + user_id, + task_id, + task_workspace_id, + status, + ingestion_status, + relative_path, + media_type_hint, + created_at, + updated_at + FROM task_artifacts + WHERE task_id = %s + ORDER BY created_at ASC, id ASC + """ + +LOCK_TASK_ARTIFACT_INGESTION_SQL = "SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 5))" + +INSERT_TASK_ARTIFACT_CHUNK_SQL = """ + INSERT INTO task_artifact_chunks ( + user_id, + task_artifact_id, + sequence_no, + char_start, + char_end_exclusive, + text, + created_at, + updated_at + ) + VALUES ( + app.current_user_id(), + %s, + %s, + %s, + %s, + %s, + clock_timestamp(), + clock_timestamp() + ) + RETURNING + id, + user_id, + task_artifact_id, + sequence_no, + char_start, + char_end_exclusive, + text, + created_at, + updated_at + """ + +LIST_TASK_ARTIFACT_CHUNKS_SQL = """ + SELECT + id, + user_id, + task_artifact_id, + sequence_no, + char_start, + char_end_exclusive, + text, + created_at, + updated_at + FROM task_artifact_chunks + WHERE task_artifact_id = %s + ORDER BY sequence_no ASC, id ASC + """ + +GET_TASK_ARTIFACT_CHUNK_SQL = """ + SELECT + id, + user_id, + task_artifact_id, + sequence_no, + char_start, + char_end_exclusive, + text, + created_at, + updated_at + FROM task_artifact_chunks + WHERE id = %s + """ + +INSERT_TASK_ARTIFACT_CHUNK_EMBEDDING_SQL = """ + WITH inserted AS ( + INSERT INTO task_artifact_chunk_embeddings ( + user_id, + task_artifact_chunk_id, + embedding_config_id, + dimensions, + vector, + created_at, + updated_at + ) + VALUES ( + app.current_user_id(), + %s, + %s, + %s, + %s, + clock_timestamp(), + clock_timestamp() + ) + RETURNING + id, + user_id, + task_artifact_chunk_id, + embedding_config_id, + dimensions, + vector, + created_at, + updated_at + ) + SELECT + inserted.id, + inserted.user_id, + chunks.task_artifact_id, + inserted.task_artifact_chunk_id, + chunks.sequence_no AS task_artifact_chunk_sequence_no, + inserted.embedding_config_id, + inserted.dimensions, + inserted.vector, + inserted.created_at, + inserted.updated_at + FROM inserted + JOIN task_artifact_chunks AS chunks + ON chunks.id = inserted.task_artifact_chunk_id + AND chunks.user_id = inserted.user_id + """ + +GET_TASK_ARTIFACT_CHUNK_EMBEDDING_SQL = """ + SELECT + embeddings.id, + embeddings.user_id, + chunks.task_artifact_id, + embeddings.task_artifact_chunk_id, + chunks.sequence_no AS task_artifact_chunk_sequence_no, + embeddings.embedding_config_id, + embeddings.dimensions, + embeddings.vector, + embeddings.created_at, + embeddings.updated_at + FROM task_artifact_chunk_embeddings AS embeddings + JOIN task_artifact_chunks AS chunks + ON chunks.id = embeddings.task_artifact_chunk_id + AND chunks.user_id = embeddings.user_id + WHERE embeddings.id = %s + """ + +GET_TASK_ARTIFACT_CHUNK_EMBEDDING_BY_CHUNK_AND_CONFIG_SQL = """ + SELECT + embeddings.id, + embeddings.user_id, + chunks.task_artifact_id, + embeddings.task_artifact_chunk_id, + chunks.sequence_no AS task_artifact_chunk_sequence_no, + embeddings.embedding_config_id, + embeddings.dimensions, + embeddings.vector, + embeddings.created_at, + embeddings.updated_at + FROM task_artifact_chunk_embeddings AS embeddings + JOIN task_artifact_chunks AS chunks + ON chunks.id = embeddings.task_artifact_chunk_id + AND chunks.user_id = embeddings.user_id + WHERE embeddings.task_artifact_chunk_id = %s + AND embeddings.embedding_config_id = %s + """ + +LIST_TASK_ARTIFACT_CHUNK_EMBEDDINGS_FOR_CHUNK_SQL = """ + SELECT + embeddings.id, + embeddings.user_id, + chunks.task_artifact_id, + embeddings.task_artifact_chunk_id, + chunks.sequence_no AS task_artifact_chunk_sequence_no, + embeddings.embedding_config_id, + embeddings.dimensions, + embeddings.vector, + embeddings.created_at, + embeddings.updated_at + FROM task_artifact_chunk_embeddings AS embeddings + JOIN task_artifact_chunks AS chunks + ON chunks.id = embeddings.task_artifact_chunk_id + AND chunks.user_id = embeddings.user_id + WHERE embeddings.task_artifact_chunk_id = %s + ORDER BY chunks.sequence_no ASC, embeddings.created_at ASC, embeddings.id ASC + """ + +LIST_TASK_ARTIFACT_CHUNK_EMBEDDINGS_FOR_ARTIFACT_SQL = """ + SELECT + embeddings.id, + embeddings.user_id, + chunks.task_artifact_id, + embeddings.task_artifact_chunk_id, + chunks.sequence_no AS task_artifact_chunk_sequence_no, + embeddings.embedding_config_id, + embeddings.dimensions, + embeddings.vector, + embeddings.created_at, + embeddings.updated_at + FROM task_artifact_chunk_embeddings AS embeddings + JOIN task_artifact_chunks AS chunks + ON chunks.id = embeddings.task_artifact_chunk_id + AND chunks.user_id = embeddings.user_id + WHERE chunks.task_artifact_id = %s + ORDER BY chunks.sequence_no ASC, embeddings.created_at ASC, embeddings.id ASC + """ + +UPDATE_TASK_ARTIFACT_CHUNK_EMBEDDING_SQL = """ + WITH updated AS ( + UPDATE task_artifact_chunk_embeddings + SET dimensions = %s, + vector = %s, + updated_at = clock_timestamp() + WHERE id = %s + RETURNING + id, + user_id, + task_artifact_chunk_id, + embedding_config_id, + dimensions, + vector, + created_at, + updated_at + ) + SELECT + updated.id, + updated.user_id, + chunks.task_artifact_id, + updated.task_artifact_chunk_id, + chunks.sequence_no AS task_artifact_chunk_sequence_no, + updated.embedding_config_id, + updated.dimensions, + updated.vector, + updated.created_at, + updated.updated_at + FROM updated + JOIN task_artifact_chunks AS chunks + ON chunks.id = updated.task_artifact_chunk_id + AND chunks.user_id = updated.user_id + """ + +UPDATE_TASK_ARTIFACT_INGESTION_STATUS_SQL = """ + UPDATE task_artifacts + SET ingestion_status = %s, + updated_at = clock_timestamp() + WHERE id = %s + RETURNING + id, + user_id, + task_id, + task_workspace_id, + status, + ingestion_status, + relative_path, + media_type_hint, + created_at, + updated_at + """ + +INSERT_TASK_STEP_SQL = """ + INSERT INTO task_steps ( + user_id, + task_id, + sequence_no, + parent_step_id, + source_approval_id, + source_execution_id, + kind, + status, + request, + outcome, + trace_id, + trace_kind, + created_at, + updated_at + ) + VALUES ( + app.current_user_id(), + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + clock_timestamp(), + clock_timestamp() + ) + RETURNING + id, + user_id, + task_id, + sequence_no, + parent_step_id, + source_approval_id, + source_execution_id, + kind, + status, + request, + outcome, + trace_id, + trace_kind, + created_at, + updated_at + """ + +GET_TASK_STEP_SQL = """ + SELECT + id, + user_id, + task_id, + sequence_no, + parent_step_id, + source_approval_id, + source_execution_id, + kind, + status, + request, + outcome, + trace_id, + trace_kind, + created_at, + updated_at + FROM task_steps + WHERE id = %s + """ + +GET_TASK_STEP_FOR_TASK_SEQUENCE_SQL = """ + SELECT + id, + user_id, + task_id, + sequence_no, + parent_step_id, + source_approval_id, + source_execution_id, + kind, + status, + request, + outcome, + trace_id, + trace_kind, + created_at, + updated_at + FROM task_steps + WHERE task_id = %s + AND sequence_no = %s + """ + +LIST_TASK_STEPS_FOR_TASK_SQL = """ + SELECT + id, + user_id, + task_id, + sequence_no, + parent_step_id, + source_approval_id, + source_execution_id, + kind, + status, + request, + outcome, + trace_id, + trace_kind, + created_at, + updated_at + FROM task_steps + WHERE task_id = %s + ORDER BY sequence_no ASC, created_at ASC, id ASC + """ + +UPDATE_TASK_STEP_FOR_TASK_SEQUENCE_SQL = """ + UPDATE task_steps + SET status = %s, + outcome = %s, + trace_id = %s, + trace_kind = %s, + updated_at = clock_timestamp() + WHERE task_id = %s + AND sequence_no = %s + RETURNING + id, + user_id, + task_id, + sequence_no, + parent_step_id, + source_approval_id, + source_execution_id, + kind, + status, + request, + outcome, + trace_id, + trace_kind, + created_at, + updated_at + """ + +UPDATE_TASK_STEP_SQL = """ + UPDATE task_steps + SET status = %s, + outcome = %s, + trace_id = %s, + trace_kind = %s, + updated_at = clock_timestamp() + WHERE id = %s + RETURNING + id, + user_id, + task_id, + sequence_no, + parent_step_id, + source_approval_id, + source_execution_id, + kind, + status, + request, + outcome, + trace_id, + trace_kind, + created_at, + updated_at + """ + +INSERT_TASK_RUN_SQL = """ + INSERT INTO task_runs ( + user_id, + task_id, + status, + checkpoint, + tick_count, + step_count, + max_ticks, + retry_count, + retry_cap, + retry_posture, + failure_class, + stop_reason, + last_transitioned_at, + created_at, + updated_at + ) + VALUES ( + app.current_user_id(), + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + clock_timestamp(), + clock_timestamp(), + clock_timestamp() + ) + RETURNING + id, + user_id, + task_id, + status, + checkpoint, + tick_count, + step_count, + max_ticks, + retry_count, + retry_cap, + retry_posture, + failure_class, + stop_reason, + last_transitioned_at, + created_at, + updated_at + """ + +GET_TASK_RUN_SQL = """ + SELECT + id, + user_id, + task_id, + status, + checkpoint, + tick_count, + step_count, + max_ticks, + retry_count, + retry_cap, + retry_posture, + failure_class, + stop_reason, + last_transitioned_at, + created_at, + updated_at + FROM task_runs + WHERE id = %s + """ + +LIST_TASK_RUNS_FOR_TASK_SQL = """ + SELECT + id, + user_id, + task_id, + status, + checkpoint, + tick_count, + step_count, + max_ticks, + retry_count, + retry_cap, + retry_posture, + failure_class, + stop_reason, + last_transitioned_at, + created_at, + updated_at + FROM task_runs + WHERE task_id = %s + ORDER BY created_at ASC, id ASC + """ + +UPDATE_TASK_RUN_SQL = """ + UPDATE task_runs + SET status = %s, + checkpoint = %s, + tick_count = %s, + step_count = %s, + retry_count = %s, + retry_cap = %s, + retry_posture = %s, + failure_class = %s, + stop_reason = %s, + last_transitioned_at = clock_timestamp(), + updated_at = clock_timestamp() + WHERE id = %s + RETURNING + id, + user_id, + task_id, + status, + checkpoint, + tick_count, + step_count, + max_ticks, + retry_count, + retry_cap, + retry_posture, + failure_class, + stop_reason, + last_transitioned_at, + created_at, + updated_at + """ + +ACQUIRE_NEXT_TASK_RUN_SQL = """ + WITH candidate AS ( + SELECT id + FROM task_runs + WHERE status IN ('queued', 'running') + ORDER BY updated_at ASC, created_at ASC, id ASC + FOR UPDATE SKIP LOCKED + LIMIT 1 + ) + UPDATE task_runs + SET status = 'running', + retry_posture = 'none', + failure_class = NULL, + stop_reason = NULL, + last_transitioned_at = clock_timestamp(), + updated_at = clock_timestamp() + WHERE id = (SELECT id FROM candidate) + RETURNING + id, + user_id, + task_id, + status, + checkpoint, + tick_count, + step_count, + max_ticks, + retry_count, + retry_cap, + retry_posture, + failure_class, + stop_reason, + last_transitioned_at, + created_at, + updated_at + """ + +INSERT_TOOL_EXECUTION_SQL = """ + INSERT INTO tool_executions ( + user_id, + approval_id, + task_run_id, + task_step_id, + thread_id, + tool_id, + trace_id, + request_event_id, + result_event_id, + status, + handler_key, + idempotency_key, + request, + tool, + result, + executed_at + ) + VALUES ( + app.current_user_id(), + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + clock_timestamp() + ) + RETURNING + id, + user_id, + approval_id, + task_run_id, + task_step_id, + thread_id, + tool_id, + trace_id, + request_event_id, + result_event_id, + status, + handler_key, + idempotency_key, + request, + tool, + result, + executed_at + """ + +GET_TOOL_EXECUTION_SQL = """ + SELECT + id, + user_id, + approval_id, + task_run_id, + task_step_id, + thread_id, + tool_id, + trace_id, + request_event_id, + result_event_id, + status, + handler_key, + idempotency_key, + request, + tool, + result, + executed_at + FROM tool_executions + WHERE id = %s + """ + +LIST_TOOL_EXECUTIONS_SQL = """ + SELECT + id, + user_id, + approval_id, + task_run_id, + task_step_id, + thread_id, + tool_id, + trace_id, + request_event_id, + result_event_id, + status, + handler_key, + idempotency_key, + request, + tool, + result, + executed_at + FROM tool_executions + ORDER BY executed_at ASC, id ASC + """ + +GET_TOOL_EXECUTION_BY_IDEMPOTENCY_SQL = """ + SELECT + id, + user_id, + approval_id, + task_run_id, + task_step_id, + thread_id, + tool_id, + trace_id, + request_event_id, + result_event_id, + status, + handler_key, + idempotency_key, + request, + tool, + result, + executed_at + FROM tool_executions + WHERE task_run_id = %s + AND approval_id = %s + AND idempotency_key = %s + ORDER BY executed_at ASC, id ASC + LIMIT 1 + """ + +INSERT_EXECUTION_BUDGET_SQL = """ + INSERT INTO execution_budgets ( + id, + user_id, + agent_profile_id, + tool_key, + domain_hint, + max_completed_executions, + rolling_window_seconds, + supersedes_budget_id + ) + VALUES ( + COALESCE(%s, gen_random_uuid()), + app.current_user_id(), + %s, + %s, + %s, + %s, + %s, + %s + ) + RETURNING + id, + user_id, + agent_profile_id, + tool_key, + domain_hint, + max_completed_executions, + rolling_window_seconds, + status, + deactivated_at, + superseded_by_budget_id, + supersedes_budget_id, + created_at + """ + +GET_EXECUTION_BUDGET_SQL = """ + SELECT + id, + user_id, + agent_profile_id, + tool_key, + domain_hint, + max_completed_executions, + rolling_window_seconds, + status, + deactivated_at, + superseded_by_budget_id, + supersedes_budget_id, + created_at + FROM execution_budgets + WHERE id = %s + """ + +LIST_EXECUTION_BUDGETS_SQL = """ + SELECT + id, + user_id, + agent_profile_id, + tool_key, + domain_hint, + max_completed_executions, + rolling_window_seconds, + status, + deactivated_at, + superseded_by_budget_id, + supersedes_budget_id, + created_at + FROM execution_budgets + ORDER BY created_at ASC, id ASC + """ + +DEACTIVATE_EXECUTION_BUDGET_SQL = """ + UPDATE execution_budgets + SET status = 'inactive', + deactivated_at = now() + WHERE id = %s + AND status = 'active' + RETURNING + id, + user_id, + agent_profile_id, + tool_key, + domain_hint, + max_completed_executions, + rolling_window_seconds, + status, + deactivated_at, + superseded_by_budget_id, + supersedes_budget_id, + created_at + """ + +SUPERSEDE_EXECUTION_BUDGET_SQL = """ + UPDATE execution_budgets + SET status = 'superseded', + deactivated_at = now(), + superseded_by_budget_id = %s + WHERE id = %s + AND status = 'active' + RETURNING + id, + user_id, + agent_profile_id, + tool_key, + domain_hint, + max_completed_executions, + rolling_window_seconds, + status, + deactivated_at, + superseded_by_budget_id, + supersedes_budget_id, + created_at + """ + +INSERT_CONTINUITY_CAPTURE_EVENT_SQL = """ + INSERT INTO continuity_capture_events ( + user_id, + raw_content, + explicit_signal, + admission_posture, + admission_reason + ) + VALUES ( + app.current_user_id(), + %s, + %s, + %s, + %s + ) + RETURNING + id, + user_id, + raw_content, + explicit_signal, + admission_posture, + admission_reason, + created_at + """ + +GET_CONTINUITY_CAPTURE_EVENT_SQL = """ + SELECT + id, + user_id, + raw_content, + explicit_signal, + admission_posture, + admission_reason, + created_at + FROM continuity_capture_events + WHERE id = %s + """ + +LIST_CONTINUITY_CAPTURE_EVENTS_SQL = """ + SELECT + id, + user_id, + raw_content, + explicit_signal, + admission_posture, + admission_reason, + created_at + FROM continuity_capture_events + ORDER BY created_at DESC, id DESC + LIMIT %s + """ + +COUNT_CONTINUITY_CAPTURE_EVENTS_SQL = """ + SELECT COUNT(*) AS count + FROM continuity_capture_events + """ + +INSERT_CONTINUITY_OBJECT_SQL = """ + INSERT INTO continuity_objects ( + user_id, + capture_event_id, + object_type, + status, + is_preserved, + is_searchable, + is_promotable, + title, + body, + provenance, + confidence, + last_confirmed_at, + supersedes_object_id, + superseded_by_object_id + ) + VALUES ( + app.current_user_id(), + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s + ) + RETURNING + id, + user_id, + capture_event_id, + object_type, + status, + is_preserved, + is_searchable, + is_promotable, + title, + body, + provenance, + confidence, + last_confirmed_at, + supersedes_object_id, + superseded_by_object_id, + created_at, + updated_at + """ + +GET_CONTINUITY_OBJECT_BY_CAPTURE_EVENT_SQL = """ + SELECT + id, + user_id, + capture_event_id, + object_type, + status, + is_preserved, + is_searchable, + is_promotable, + title, + body, + provenance, + confidence, + last_confirmed_at, + supersedes_object_id, + superseded_by_object_id, + created_at, + updated_at + FROM continuity_objects + WHERE capture_event_id = %s + """ + +GET_CONTINUITY_OBJECT_SQL = """ + SELECT + id, + user_id, + capture_event_id, + object_type, + status, + is_preserved, + is_searchable, + is_promotable, + title, + body, + provenance, + confidence, + last_confirmed_at, + supersedes_object_id, + superseded_by_object_id, + created_at, + updated_at + FROM continuity_objects + WHERE id = %s + """ + +LIST_CONTINUITY_OBJECTS_FOR_CAPTURE_EVENTS_SQL = """ + SELECT + id, + user_id, + capture_event_id, + object_type, + status, + is_preserved, + is_searchable, + is_promotable, + title, + body, + provenance, + confidence, + last_confirmed_at, + supersedes_object_id, + superseded_by_object_id, + created_at, + updated_at + FROM continuity_objects + WHERE capture_event_id = ANY(%s) + ORDER BY created_at DESC, id DESC + """ + +LIST_CONTINUITY_REVIEW_QUEUE_SQL = """ + SELECT + id, + user_id, + capture_event_id, + object_type, + status, + is_preserved, + is_searchable, + is_promotable, + title, + body, + provenance, + confidence, + last_confirmed_at, + supersedes_object_id, + superseded_by_object_id, + created_at, + updated_at + FROM continuity_objects + WHERE status = ANY(%s) + ORDER BY updated_at DESC, created_at DESC, id DESC + LIMIT %s + """ + +COUNT_CONTINUITY_REVIEW_QUEUE_SQL = """ + SELECT COUNT(*) AS count + FROM continuity_objects + WHERE status = ANY(%s) + """ + +LIST_CONTINUITY_RECALL_CANDIDATES_SQL = """ + SELECT + continuity_objects.id, + continuity_objects.user_id, + continuity_objects.capture_event_id, + continuity_objects.object_type, + continuity_objects.status, + continuity_objects.is_preserved, + continuity_objects.is_searchable, + continuity_objects.is_promotable, + continuity_objects.title, + continuity_objects.body, + continuity_objects.provenance, + continuity_objects.confidence, + continuity_objects.last_confirmed_at, + continuity_objects.supersedes_object_id, + continuity_objects.superseded_by_object_id, + continuity_objects.created_at AS object_created_at, + continuity_objects.updated_at AS object_updated_at, + continuity_capture_events.admission_posture, + continuity_capture_events.admission_reason, + continuity_capture_events.explicit_signal, + continuity_capture_events.created_at AS capture_created_at + FROM continuity_objects + JOIN continuity_capture_events + ON continuity_capture_events.id = continuity_objects.capture_event_id + AND continuity_capture_events.user_id = continuity_objects.user_id + ORDER BY continuity_objects.created_at DESC, continuity_objects.id DESC + """ + +UPDATE_CONTINUITY_OBJECT_SQL = """ + UPDATE continuity_objects + SET status = %s, + is_preserved = %s, + is_searchable = %s, + is_promotable = %s, + title = %s, + body = %s, + provenance = %s, + confidence = %s, + last_confirmed_at = %s, + supersedes_object_id = %s, + superseded_by_object_id = %s, + updated_at = clock_timestamp() + WHERE id = %s + RETURNING + id, + user_id, + capture_event_id, + object_type, + status, + is_preserved, + is_searchable, + is_promotable, + title, + body, + provenance, + confidence, + last_confirmed_at, + supersedes_object_id, + superseded_by_object_id, + created_at, + updated_at + """ + +INSERT_CONTINUITY_CORRECTION_EVENT_SQL = """ + INSERT INTO continuity_correction_events ( + user_id, + continuity_object_id, + action, + reason, + before_snapshot, + after_snapshot, + payload + ) + VALUES ( + app.current_user_id(), + %s, + %s, + %s, + %s, + %s, + %s + ) + RETURNING + id, + user_id, + continuity_object_id, + action, + reason, + before_snapshot, + after_snapshot, + payload, + created_at + """ + +LIST_CONTINUITY_CORRECTION_EVENTS_SQL = """ + SELECT + id, + user_id, + continuity_object_id, + action, + reason, + before_snapshot, + after_snapshot, + payload, + created_at + FROM continuity_correction_events + WHERE continuity_object_id = %s + ORDER BY created_at DESC, id DESC + LIMIT %s + """ + +UPDATE_EVENT_ERROR = "events are append-only and must be superseded by new records" +DELETE_EVENT_ERROR = "events are append-only and must not be deleted in place" +UPDATE_TRACE_EVENT_ERROR = "trace events are append-only and must be superseded by new records" +DELETE_TRACE_EVENT_ERROR = "trace events are append-only and must not be deleted in place" + + +class AppendOnlyViolation(RuntimeError): + """Raised when a caller attempts to mutate an immutable event.""" + + +class ContinuityStoreInvariantError(RuntimeError): + """Raised when a write query does not return the row its contract promises.""" + + +class ContinuityStore: + def __init__(self, conn: psycopg.Connection): + self.conn = conn + + @staticmethod + def _default_continuity_searchable(object_type: str) -> bool: + return object_type != "Note" + + @staticmethod + def _default_continuity_promotable(object_type: str) -> bool: + return object_type in {"Decision", "Commitment", "WaitingFor", "Blocker", "NextAction"} + + def _acquire_advisory_lock(self, lock_query: str, lock_key: UUID) -> None: + with self.conn.cursor() as cur: + cur.execute(lock_query, (str(lock_key),)) + + def _fetch_one( + self, + operation_name: str, + query: str, + params: tuple[object, ...] | None = None, + ) -> RowT: + with self.conn.cursor() as cur: + cur.execute(query, params) + row = cur.fetchone() + + if row is None: + raise ContinuityStoreInvariantError( + f"{operation_name} did not return a row from the database", + ) + + return cast(RowT, row) + + def _fetch_one_with_lock( + self, + *, + operation_name: str, + lock_query: str, + lock_key: UUID, + query: str, + params: tuple[object, ...] | None = None, + ) -> RowT: + with self.conn.cursor() as cur: + cur.execute(lock_query, (str(lock_key),)) + cur.execute(query, params) + row = cur.fetchone() + + if row is None: + raise ContinuityStoreInvariantError( + f"{operation_name} did not return a row from the database", + ) + + return cast(RowT, row) + + def _fetch_all( + self, + query: str, + params: tuple[object, ...] | None = None, + ) -> list[RowT]: + with self.conn.cursor() as cur: + cur.execute(query, params) + return cast(list[RowT], list(cur.fetchall())) + + def _fetch_optional_one( + self, + query: str, + params: tuple[object, ...] | None = None, + ) -> RowT | None: + with self.conn.cursor() as cur: + cur.execute(query, params) + row = cur.fetchone() + return cast(RowT | None, row) + + def _fetch_count( + self, + query: str, + params: tuple[object, ...] | None = None, + ) -> int: + with self.conn.cursor() as cur: + cur.execute(query, params) + row = cur.fetchone() + + if row is None: + raise ContinuityStoreInvariantError( + "count query did not return a row from the database", + ) + + return cast(CountRow, row)["count"] + + @staticmethod + def _vector_literal(vector: list[float]) -> str: + return "[" + ",".join(repr(value) for value in vector) + "]" + + def create_user(self, user_id: UUID, email: str, display_name: str | None = None) -> UserRow: + return self._fetch_one( + "create_user", + INSERT_USER_SQL, + (user_id, email, display_name), + ) + + def get_user(self, user_id: UUID) -> UserRow: + return self._fetch_one("get_user", GET_USER_SQL, (user_id,)) + + def create_thread(self, title: str, agent_profile_id: str = "assistant_default") -> ThreadRow: + return self._fetch_one("create_thread", INSERT_THREAD_SQL, (title, agent_profile_id)) + + def get_thread(self, thread_id: UUID) -> ThreadRow: + return self._fetch_one("get_thread", GET_THREAD_SQL, (thread_id,)) + + def get_thread_optional(self, thread_id: UUID) -> ThreadRow | None: + return self._fetch_optional_one(GET_THREAD_SQL, (thread_id,)) + + def list_threads(self) -> list[ThreadRow]: + return self._fetch_all(LIST_THREADS_SQL) + + def list_agent_profiles(self) -> list[AgentProfileRow]: + return self._fetch_all(LIST_AGENT_PROFILES_SQL) + + def get_agent_profile_optional(self, profile_id: str) -> AgentProfileRow | None: + return self._fetch_optional_one(GET_AGENT_PROFILE_SQL, (profile_id,)) + + def create_session(self, thread_id: UUID, status: str = "active") -> SessionRow: + return self._fetch_one("create_session", INSERT_SESSION_SQL, (thread_id, status)) + + def list_thread_sessions(self, thread_id: UUID) -> list[SessionRow]: + return self._fetch_all(LIST_THREAD_SESSIONS_SQL, (thread_id,)) + + def append_event( + self, + thread_id: UUID, + session_id: UUID | None, + kind: str, + payload: JsonObject, + ) -> EventRow: + return self._fetch_one_with_lock( + operation_name="append_event", + lock_query=LOCK_THREAD_EVENTS_SQL, + lock_key=thread_id, + query=INSERT_EVENT_SQL, + params=(thread_id, thread_id, session_id, kind, Jsonb(payload)), + ) + + def list_thread_events(self, thread_id: UUID) -> list[EventRow]: + return self._fetch_all(LIST_THREAD_EVENTS_SQL, (thread_id,)) + + def list_events_by_ids(self, event_ids: list[UUID]) -> list[EventRow]: + if not event_ids: + return [] + return self._fetch_all(LIST_EVENTS_BY_IDS_SQL, (event_ids,)) + + def create_trace( + self, + *, + user_id: UUID, + thread_id: UUID, + kind: str, + compiler_version: str, + status: str, + limits: JsonObject, + ) -> TraceRow: + return self._fetch_one( + "create_trace", + INSERT_TRACE_SQL, + (user_id, thread_id, kind, compiler_version, status, Jsonb(limits)), + ) + + def get_trace(self, trace_id: UUID) -> TraceRow: + return self._fetch_one("get_trace", GET_TRACE_SQL, (trace_id,)) + + def get_trace_review_optional(self, trace_id: UUID) -> TraceReviewRow | None: + return self._fetch_optional_one(GET_TRACE_REVIEW_SQL, (trace_id,)) + + def list_trace_reviews(self) -> list[TraceReviewRow]: + return self._fetch_all(LIST_TRACE_REVIEWS_SQL) + + def append_trace_event( + self, + *, + trace_id: UUID, + sequence_no: int, + kind: str, + payload: JsonObject, + ) -> TraceEventRow: + return self._fetch_one( + "append_trace_event", + INSERT_TRACE_EVENT_SQL, + (trace_id, sequence_no, kind, Jsonb(payload)), + ) + + def list_trace_events(self, trace_id: UUID) -> list[TraceEventRow]: + return self._fetch_all(LIST_TRACE_EVENTS_SQL, (trace_id,)) + + def create_memory( + self, + *, + memory_key: str, + value: JsonValue, + status: str, + source_event_ids: list[str], + memory_type: str = "preference", + confidence: float | None = None, + salience: float | None = None, + confirmation_status: str = "unconfirmed", + trust_class: str = "deterministic", + promotion_eligibility: str = "promotable", + evidence_count: int | None = None, + independent_source_count: int | None = None, + extracted_by_model: str | None = None, + trust_reason: str | None = None, + valid_from: datetime | None = None, + valid_to: datetime | None = None, + last_confirmed_at: datetime | None = None, + agent_profile_id: str = "assistant_default", + ) -> MemoryRow: + return self._fetch_one( + "create_memory", + INSERT_MEMORY_SQL, + ( + memory_key, + Jsonb(value), + status, + Jsonb(source_event_ids), + memory_type, + confidence, + salience, + confirmation_status, + trust_class, + promotion_eligibility, + evidence_count, + independent_source_count, + extracted_by_model, + trust_reason, + valid_from, + valid_to, + last_confirmed_at, + agent_profile_id, + ), + ) + + def get_memory(self, memory_id: UUID) -> MemoryRow: + return self._fetch_one("get_memory", GET_MEMORY_SQL, (memory_id,)) + + def get_memory_optional(self, memory_id: UUID) -> MemoryRow | None: + return self._fetch_optional_one(GET_MEMORY_SQL, (memory_id,)) + + def list_memories_by_ids(self, memory_ids: list[UUID]) -> list[MemoryRow]: + if not memory_ids: + return [] + return self._fetch_all(LIST_MEMORIES_BY_IDS_SQL, (memory_ids,)) + + def get_memory_by_key(self, memory_key: str) -> MemoryRow | None: + return self._fetch_optional_one(GET_MEMORY_BY_KEY_SQL, (memory_key,)) + + def get_memory_by_key_and_profile( + self, + *, + memory_key: str, + agent_profile_id: str, + ) -> MemoryRow | None: + return self._fetch_optional_one( + GET_MEMORY_BY_KEY_AND_PROFILE_SQL, + (memory_key, agent_profile_id), + ) + + def list_memories(self) -> list[MemoryRow]: + return self._fetch_all(LIST_MEMORIES_SQL) + + def count_memories(self, *, status: str | None = None) -> int: + if status is None: + return self._fetch_count(COUNT_MEMORIES_SQL) + return self._fetch_count(COUNT_MEMORIES_BY_STATUS_SQL, (status,)) + + def count_unlabeled_review_memories(self) -> int: + return self._fetch_count(COUNT_UNLABELED_REVIEW_MEMORIES_SQL) + + def list_review_memories(self, *, status: str | None = None, limit: int) -> list[MemoryRow]: + if status is None: + return self._fetch_all(LIST_REVIEW_MEMORIES_SQL, (limit,)) + return self._fetch_all(LIST_REVIEW_MEMORIES_BY_STATUS_SQL, (status, limit)) + + def list_unlabeled_review_memories(self, *, limit: int | None = None) -> list[MemoryRow]: + if limit is None: + return self._fetch_all(LIST_UNLABELED_REVIEW_MEMORIES_SQL) + return self._fetch_all(LIST_LIMITED_UNLABELED_REVIEW_MEMORIES_SQL, (limit,)) + + def list_context_memories(self) -> list[MemoryRow]: + return self._fetch_all(LIST_CONTEXT_MEMORIES_SQL) + + def list_context_memories_for_profile(self, *, agent_profile_id: str) -> list[MemoryRow]: + return self._fetch_all(LIST_CONTEXT_MEMORIES_FOR_PROFILE_SQL, (agent_profile_id,)) + + def update_memory( + self, + *, + memory_id: UUID, + value: JsonValue, + status: str, + source_event_ids: list[str], + memory_type: str = "preference", + confidence: float | None = None, + salience: float | None = None, + confirmation_status: str = "unconfirmed", + trust_class: str = "deterministic", + promotion_eligibility: str = "promotable", + evidence_count: int | None = None, + independent_source_count: int | None = None, + extracted_by_model: str | None = None, + trust_reason: str | None = None, + valid_from: datetime | None = None, + valid_to: datetime | None = None, + last_confirmed_at: datetime | None = None, + ) -> MemoryRow: + return self._fetch_one( + "update_memory", + UPDATE_MEMORY_SQL, + ( + Jsonb(value), + status, + Jsonb(source_event_ids), + memory_type, + confidence, + salience, + confirmation_status, + trust_class, + promotion_eligibility, + evidence_count, + independent_source_count, + extracted_by_model, + trust_reason, + valid_from, + valid_to, + last_confirmed_at, + status, + memory_id, + ), + ) + + def append_memory_revision( + self, + *, + memory_id: UUID, + action: str, + memory_key: str, + previous_value: JsonValue | None, + new_value: JsonValue | None, + source_event_ids: list[str], + candidate: JsonObject, + ) -> MemoryRevisionRow: + return self._fetch_one_with_lock( + operation_name="append_memory_revision", + lock_query=LOCK_MEMORY_REVISIONS_SQL, + lock_key=memory_id, + query=INSERT_MEMORY_REVISION_SQL, + params=( + memory_id, + memory_id, + action, + memory_key, + Jsonb(previous_value), + Jsonb(new_value), + Jsonb(source_event_ids), + Jsonb(candidate), + ), + ) + + def count_memory_revisions(self, memory_id: UUID) -> int: + return self._fetch_count(COUNT_MEMORY_REVISIONS_SQL, (memory_id,)) + + def list_memory_revisions( + self, + memory_id: UUID, + *, + limit: int | None = None, + ) -> list[MemoryRevisionRow]: + if limit is None: + return self._fetch_all(LIST_MEMORY_REVISIONS_SQL, (memory_id,)) + return self._fetch_all(LIST_LIMITED_MEMORY_REVISIONS_SQL, (memory_id, limit)) + + def create_memory_review_label( + self, + *, + memory_id: UUID, + label: str, + note: str | None, + ) -> MemoryReviewLabelRow: + return self._fetch_one( + "create_memory_review_label", + INSERT_MEMORY_REVIEW_LABEL_SQL, + (memory_id, label, note), + ) + + def list_memory_review_labels(self, memory_id: UUID) -> list[MemoryReviewLabelRow]: + return self._fetch_all(LIST_MEMORY_REVIEW_LABELS_SQL, (memory_id,)) + + def list_memory_review_label_counts(self, memory_id: UUID) -> list[LabelCountRow]: + return self._fetch_all(LIST_MEMORY_REVIEW_LABEL_COUNTS_SQL, (memory_id,)) + + def count_labeled_memories(self) -> int: + return self._fetch_count(COUNT_LABELED_MEMORIES_SQL) + + def count_unlabeled_memories(self) -> int: + return self._fetch_count(COUNT_UNLABELED_MEMORIES_SQL) + + def list_all_memory_review_label_counts(self) -> list[LabelCountRow]: + return self._fetch_all(LIST_ALL_MEMORY_REVIEW_LABEL_COUNTS_SQL) + + def list_active_memory_review_label_counts(self) -> list[LabelCountRow]: + return self._fetch_all(LIST_ACTIVE_MEMORY_REVIEW_LABEL_COUNTS_SQL) + + def create_open_loop( + self, + *, + memory_id: UUID | None, + title: str, + status: str, + opened_at: datetime | None, + due_at: datetime | None, + resolved_at: datetime | None, + resolution_note: str | None, + ) -> OpenLoopRow: + return self._fetch_one( + "create_open_loop", + INSERT_OPEN_LOOP_SQL, + ( + memory_id, + title, + status, + opened_at, + due_at, + resolved_at, + resolution_note, + ), + ) + + def get_open_loop(self, open_loop_id: UUID) -> OpenLoopRow: + return self._fetch_one("get_open_loop", GET_OPEN_LOOP_SQL, (open_loop_id,)) + + def get_open_loop_optional(self, open_loop_id: UUID) -> OpenLoopRow | None: + return self._fetch_optional_one(GET_OPEN_LOOP_SQL, (open_loop_id,)) + + def list_open_loops( + self, + *, + status: str | None = None, + limit: int | None = None, + ) -> list[OpenLoopRow]: + if status is None and limit is None: + return self._fetch_all(LIST_OPEN_LOOPS_SQL) + if status is None: + return self._fetch_all(LIST_LIMITED_OPEN_LOOPS_SQL, (limit,)) + if limit is None: + return self._fetch_all(LIST_OPEN_LOOPS_BY_STATUS_SQL, (status,)) + return self._fetch_all(LIST_LIMITED_OPEN_LOOPS_BY_STATUS_SQL, (status, limit)) + + def count_open_loops(self, *, status: str | None = None) -> int: + if status is None: + return self._fetch_count(COUNT_OPEN_LOOPS_SQL) + return self._fetch_count(COUNT_OPEN_LOOPS_BY_STATUS_SQL, (status,)) + + def update_open_loop_status_optional( + self, + *, + open_loop_id: UUID, + status: str, + resolved_at: datetime | None, + resolution_note: str | None, + ) -> OpenLoopRow | None: + return self._fetch_optional_one( + UPDATE_OPEN_LOOP_STATUS_SQL, + ( + status, + status, + resolved_at, + status, + resolution_note, + open_loop_id, + ), + ) + + def create_continuity_capture_event( + self, + *, + raw_content: str, + explicit_signal: str | None, + admission_posture: str, + admission_reason: str, + ) -> ContinuityCaptureEventRow: + return self._fetch_one( + "create_continuity_capture_event", + INSERT_CONTINUITY_CAPTURE_EVENT_SQL, + ( + raw_content, + explicit_signal, + admission_posture, + admission_reason, + ), + ) + + def get_continuity_capture_event_optional( + self, + capture_event_id: UUID, + ) -> ContinuityCaptureEventRow | None: + return self._fetch_optional_one( + GET_CONTINUITY_CAPTURE_EVENT_SQL, + (capture_event_id,), + ) + + def list_continuity_capture_events(self, *, limit: int) -> list[ContinuityCaptureEventRow]: + return self._fetch_all(LIST_CONTINUITY_CAPTURE_EVENTS_SQL, (limit,)) + + def count_continuity_capture_events(self) -> int: + return self._fetch_count(COUNT_CONTINUITY_CAPTURE_EVENTS_SQL) + + def create_continuity_object( + self, + *, + capture_event_id: UUID, + object_type: str, + status: str, + title: str, + body: JsonObject, + provenance: JsonObject, + confidence: float, + is_preserved: bool | None = None, + is_searchable: bool | None = None, + is_promotable: bool | None = None, + last_confirmed_at: datetime | None = None, + supersedes_object_id: UUID | None = None, + superseded_by_object_id: UUID | None = None, + ) -> ContinuityObjectRow: + resolved_is_preserved = True if is_preserved is None else is_preserved + resolved_is_searchable = ( + self._default_continuity_searchable(object_type) + if is_searchable is None + else is_searchable + ) + resolved_is_promotable = ( + self._default_continuity_promotable(object_type) + if is_promotable is None + else is_promotable + ) + return self._fetch_one( + "create_continuity_object", + INSERT_CONTINUITY_OBJECT_SQL, + ( + capture_event_id, + object_type, + status, + resolved_is_preserved, + resolved_is_searchable, + resolved_is_promotable, + title, + Jsonb(body), + Jsonb(provenance), + confidence, + last_confirmed_at, + supersedes_object_id, + superseded_by_object_id, + ), + ) + + def get_continuity_object_by_capture_event_optional( + self, + capture_event_id: UUID, + ) -> ContinuityObjectRow | None: + return self._fetch_optional_one( + GET_CONTINUITY_OBJECT_BY_CAPTURE_EVENT_SQL, + (capture_event_id,), + ) + + def list_continuity_objects_for_capture_events( + self, + capture_event_ids: list[UUID], + ) -> list[ContinuityObjectRow]: + if not capture_event_ids: + return [] + return self._fetch_all( + LIST_CONTINUITY_OBJECTS_FOR_CAPTURE_EVENTS_SQL, + (capture_event_ids,), + ) + + def get_continuity_object_optional( + self, + continuity_object_id: UUID, + ) -> ContinuityObjectRow | None: + return self._fetch_optional_one( + GET_CONTINUITY_OBJECT_SQL, + (continuity_object_id,), + ) + + def list_continuity_review_queue( + self, + *, + statuses: list[str], + limit: int, + ) -> list[ContinuityObjectRow]: + return self._fetch_all( + LIST_CONTINUITY_REVIEW_QUEUE_SQL, + (statuses, limit), + ) + + def count_continuity_review_queue( + self, + *, + statuses: list[str], + ) -> int: + return self._fetch_count( + COUNT_CONTINUITY_REVIEW_QUEUE_SQL, + (statuses,), + ) + + def list_continuity_recall_candidates(self) -> list[ContinuityRecallCandidateRow]: + return self._fetch_all(LIST_CONTINUITY_RECALL_CANDIDATES_SQL) + + def update_continuity_object_optional( + self, + *, + continuity_object_id: UUID, + status: str, + is_preserved: bool, + is_searchable: bool, + is_promotable: bool, + title: str, + body: JsonObject, + provenance: JsonObject, + confidence: float, + last_confirmed_at: datetime | None, + supersedes_object_id: UUID | None, + superseded_by_object_id: UUID | None, + ) -> ContinuityObjectRow | None: + return self._fetch_optional_one( + UPDATE_CONTINUITY_OBJECT_SQL, + ( + status, + is_preserved, + is_searchable, + is_promotable, + title, + Jsonb(body), + Jsonb(provenance), + confidence, + last_confirmed_at, + supersedes_object_id, + superseded_by_object_id, + continuity_object_id, + ), + ) + + def create_continuity_correction_event( + self, + *, + continuity_object_id: UUID, + action: str, + reason: str | None, + before_snapshot: JsonObject, + after_snapshot: JsonObject, + payload: JsonObject, + ) -> ContinuityCorrectionEventRow: + return self._fetch_one( + "create_continuity_correction_event", + INSERT_CONTINUITY_CORRECTION_EVENT_SQL, + ( + continuity_object_id, + action, + reason, + Jsonb(before_snapshot), + Jsonb(after_snapshot), + Jsonb(payload), + ), + ) + + def list_continuity_correction_events( + self, + *, + continuity_object_id: UUID, + limit: int, + ) -> list[ContinuityCorrectionEventRow]: + return self._fetch_all( + LIST_CONTINUITY_CORRECTION_EVENTS_SQL, + (continuity_object_id, limit), + ) + + def create_embedding_config( + self, + *, + provider: str, + model: str, + version: str, + dimensions: int, + status: str, + metadata: JsonObject, + ) -> EmbeddingConfigRow: + return self._fetch_one( + "create_embedding_config", + INSERT_EMBEDDING_CONFIG_SQL, + (provider, model, version, dimensions, status, Jsonb(metadata)), + ) + + def get_embedding_config_optional(self, embedding_config_id: UUID) -> EmbeddingConfigRow | None: + return self._fetch_optional_one(GET_EMBEDDING_CONFIG_SQL, (embedding_config_id,)) + + def get_embedding_config_by_identity_optional( + self, + *, + provider: str, + model: str, + version: str, + ) -> EmbeddingConfigRow | None: + return self._fetch_optional_one( + GET_EMBEDDING_CONFIG_BY_IDENTITY_SQL, + (provider, model, version), + ) + + def list_embedding_configs(self) -> list[EmbeddingConfigRow]: + return self._fetch_all(LIST_EMBEDDING_CONFIGS_SQL) + + def create_memory_embedding( + self, + *, + memory_id: UUID, + embedding_config_id: UUID, + dimensions: int, + vector: list[float], + ) -> MemoryEmbeddingRow: + return self._fetch_one( + "create_memory_embedding", + INSERT_MEMORY_EMBEDDING_SQL, + (memory_id, embedding_config_id, dimensions, Jsonb(vector)), + ) + + def get_memory_embedding_optional(self, memory_embedding_id: UUID) -> MemoryEmbeddingRow | None: + return self._fetch_optional_one(GET_MEMORY_EMBEDDING_SQL, (memory_embedding_id,)) + + def get_memory_embedding_by_memory_and_config_optional( + self, + *, + memory_id: UUID, + embedding_config_id: UUID, + ) -> MemoryEmbeddingRow | None: + return self._fetch_optional_one( + GET_MEMORY_EMBEDDING_BY_MEMORY_AND_CONFIG_SQL, + (memory_id, embedding_config_id), + ) + + def list_memory_embeddings_for_memory(self, memory_id: UUID) -> list[MemoryEmbeddingRow]: + return self._fetch_all(LIST_MEMORY_EMBEDDINGS_FOR_MEMORY_SQL, (memory_id,)) + + def list_memory_embeddings_for_config( + self, + embedding_config_id: UUID, + ) -> list[MemoryEmbeddingRow]: + return self._fetch_all(LIST_MEMORY_EMBEDDINGS_FOR_CONFIG_SQL, (embedding_config_id,)) + + def update_memory_embedding( + self, + *, + memory_embedding_id: UUID, + dimensions: int, + vector: list[float], + ) -> MemoryEmbeddingRow: + return self._fetch_one( + "update_memory_embedding", + UPDATE_MEMORY_EMBEDDING_SQL, + (dimensions, Jsonb(vector), memory_embedding_id), + ) + + def retrieve_semantic_memory_matches( + self, + *, + embedding_config_id: UUID, + query_vector: list[float], + limit: int, + ) -> list[SemanticMemoryRetrievalRow]: + return self._fetch_all( + RETRIEVE_SEMANTIC_MEMORY_MATCHES_SQL, + ( + self._vector_literal(query_vector), + embedding_config_id, + len(query_vector), + limit, + ), + ) + + def retrieve_semantic_memory_matches_for_profile( + self, + *, + embedding_config_id: UUID, + query_vector: list[float], + limit: int, + agent_profile_id: str, + ) -> list[SemanticMemoryRetrievalRow]: + return self._fetch_all( + RETRIEVE_SEMANTIC_MEMORY_MATCHES_FOR_PROFILE_SQL, + ( + self._vector_literal(query_vector), + embedding_config_id, + len(query_vector), + agent_profile_id, + limit, + ), + ) + + def retrieve_task_scoped_semantic_artifact_chunk_matches( + self, + *, + task_id: UUID, + embedding_config_id: UUID, + query_vector: list[float], + limit: int, + ) -> list[TaskArtifactChunkSemanticRetrievalRow]: + return self._fetch_all( + RETRIEVE_TASK_SCOPED_SEMANTIC_ARTIFACT_CHUNK_MATCHES_SQL, + ( + self._vector_literal(query_vector), + embedding_config_id, + len(query_vector), + task_id, + limit, + ), + ) + + def retrieve_artifact_scoped_semantic_artifact_chunk_matches( + self, + *, + task_artifact_id: UUID, + embedding_config_id: UUID, + query_vector: list[float], + limit: int, + ) -> list[TaskArtifactChunkSemanticRetrievalRow]: + return self._fetch_all( + RETRIEVE_ARTIFACT_SCOPED_SEMANTIC_ARTIFACT_CHUNK_MATCHES_SQL, + ( + self._vector_literal(query_vector), + embedding_config_id, + len(query_vector), + task_artifact_id, + limit, + ), + ) + + def create_entity( + self, + *, + entity_type: str, + name: str, + source_memory_ids: list[str], + ) -> EntityRow: + return self._fetch_one( + "create_entity", + INSERT_ENTITY_SQL, + (entity_type, name, Jsonb(source_memory_ids)), + ) + + def get_entity_optional(self, entity_id: UUID) -> EntityRow | None: + return self._fetch_optional_one(GET_ENTITY_SQL, (entity_id,)) + + def list_entities(self) -> list[EntityRow]: + return self._fetch_all(LIST_ENTITIES_SQL) + + def create_entity_edge( + self, + *, + from_entity_id: UUID, + to_entity_id: UUID, + relationship_type: str, + valid_from: datetime | None, + valid_to: datetime | None, + source_memory_ids: list[str], + ) -> EntityEdgeRow: + return self._fetch_one( + "create_entity_edge", + INSERT_ENTITY_EDGE_SQL, + ( + from_entity_id, + to_entity_id, + relationship_type, + valid_from, + valid_to, + Jsonb(source_memory_ids), + ), + ) + + def list_entity_edges_for_entity(self, entity_id: UUID) -> list[EntityEdgeRow]: + return self._fetch_all(LIST_ENTITY_EDGES_FOR_ENTITY_SQL, (entity_id, entity_id)) + + def list_entity_edges_for_entities(self, entity_ids: list[UUID]) -> list[EntityEdgeRow]: + if not entity_ids: + return [] + return self._fetch_all(LIST_ENTITY_EDGES_FOR_ENTITIES_SQL, (entity_ids, entity_ids)) + + def create_consent( + self, + *, + consent_key: str, + status: str, + metadata: JsonObject, + ) -> ConsentRow: + return self._fetch_one( + "create_consent", + INSERT_CONSENT_SQL, + (consent_key, status, Jsonb(metadata)), + ) + + def get_consent_by_key_optional(self, consent_key: str) -> ConsentRow | None: + return self._fetch_optional_one(GET_CONSENT_BY_KEY_SQL, (consent_key,)) + + def list_consents(self) -> list[ConsentRow]: + return self._fetch_all(LIST_CONSENTS_SQL) + + def update_consent( + self, + *, + consent_id: UUID, + status: str, + metadata: JsonObject, + ) -> ConsentRow: + return self._fetch_one( + "update_consent", + UPDATE_CONSENT_SQL, + (status, Jsonb(metadata), consent_id), + ) + + def create_policy( + self, + *, + agent_profile_id: str | None = None, + name: str, + action: str, + scope: str, + effect: str, + priority: int, + active: bool, + conditions: JsonObject, + required_consents: list[str], + ) -> PolicyRow: + return self._fetch_one( + "create_policy", + INSERT_POLICY_SQL, + ( + agent_profile_id, + name, + action, + scope, + effect, + priority, + active, + Jsonb(conditions), + Jsonb(required_consents), + ), + ) + + def get_policy_optional(self, policy_id: UUID) -> PolicyRow | None: + return self._fetch_optional_one(GET_POLICY_SQL, (policy_id,)) + + def list_policies(self) -> list[PolicyRow]: + return self._fetch_all(LIST_POLICIES_SQL) + + def list_active_policies(self, *, agent_profile_id: str | None = None) -> list[PolicyRow]: + if agent_profile_id is None: + return self._fetch_all(LIST_ACTIVE_POLICIES_SQL) + return self._fetch_all(LIST_ACTIVE_POLICIES_FOR_PROFILE_SQL, (agent_profile_id,)) + + def create_tool( + self, + *, + tool_key: str, + name: str, + description: str, + version: str, + metadata_version: str, + active: bool, + tags: list[str], + action_hints: list[str], + scope_hints: list[str], + domain_hints: list[str], + risk_hints: list[str], + metadata: JsonObject, + ) -> ToolRow: + return self._fetch_one( + "create_tool", + INSERT_TOOL_SQL, + ( + tool_key, + name, + description, + version, + metadata_version, + active, + Jsonb(tags), + Jsonb(action_hints), + Jsonb(scope_hints), + Jsonb(domain_hints), + Jsonb(risk_hints), + Jsonb(metadata), + ), + ) + + def get_tool_optional(self, tool_id: UUID) -> ToolRow | None: + return self._fetch_optional_one(GET_TOOL_SQL, (tool_id,)) + + def list_tools(self) -> list[ToolRow]: + return self._fetch_all(LIST_TOOLS_SQL) + + def list_active_tools(self) -> list[ToolRow]: + return self._fetch_all(LIST_ACTIVE_TOOLS_SQL) + + def create_approval( + self, + *, + thread_id: UUID, + tool_id: UUID, + task_run_id: UUID | None = None, + task_step_id: UUID | None = None, + status: str, + request: JsonObject, + tool: JsonObject, + routing: JsonObject, + routing_trace_id: UUID, + ) -> ApprovalRow: + return self._fetch_one( + "create_approval", + INSERT_APPROVAL_SQL, + ( + thread_id, + tool_id, + task_run_id, + task_step_id, + status, + Jsonb(request), + Jsonb(tool), + Jsonb(routing), + routing_trace_id, + ), + ) + + def get_approval_optional(self, approval_id: UUID) -> ApprovalRow | None: + return self._fetch_optional_one(GET_APPROVAL_SQL, (approval_id,)) + + def list_approvals(self) -> list[ApprovalRow]: + return self._fetch_all(LIST_APPROVALS_SQL) + + def resolve_approval_optional( + self, + *, + approval_id: UUID, + status: str, + ) -> ApprovalRow | None: + return self._fetch_optional_one( + UPDATE_APPROVAL_RESOLUTION_SQL, + (status, approval_id), + ) + + def update_approval_task_step_optional( + self, + *, + approval_id: UUID, + task_step_id: UUID, + ) -> ApprovalRow | None: + return self._fetch_optional_one( + UPDATE_APPROVAL_TASK_STEP_SQL, + (task_step_id, approval_id), + ) + + def update_approval_task_run_optional( + self, + *, + approval_id: UUID, + task_run_id: UUID | None, + ) -> ApprovalRow | None: + return self._fetch_optional_one( + UPDATE_APPROVAL_TASK_RUN_SQL, + (task_run_id, approval_id), + ) + + def create_task( + self, + *, + thread_id: UUID, + tool_id: UUID, + status: str, + request: JsonObject, + tool: JsonObject, + latest_approval_id: UUID | None, + latest_execution_id: UUID | None, + ) -> TaskRow: + return self._fetch_one( + "create_task", + INSERT_TASK_SQL, + ( + thread_id, + tool_id, + status, + Jsonb(request), + Jsonb(tool), + latest_approval_id, + latest_execution_id, + ), + ) + + def get_task_optional(self, task_id: UUID) -> TaskRow | None: + return self._fetch_optional_one(GET_TASK_SQL, (task_id,)) + + def get_task_by_approval_optional(self, approval_id: UUID) -> TaskRow | None: + return self._fetch_optional_one(GET_TASK_BY_APPROVAL_SQL, (approval_id,)) + + def list_tasks(self) -> list[TaskRow]: + return self._fetch_all(LIST_TASKS_SQL) + + def update_task_status_by_approval_optional( + self, + *, + approval_id: UUID, + status: str, + ) -> TaskRow | None: + return self._fetch_optional_one( + UPDATE_TASK_STATUS_BY_APPROVAL_SQL, + (status, approval_id), + ) + + def update_task_execution_by_approval_optional( + self, + *, + approval_id: UUID, + latest_execution_id: UUID, + status: str, + ) -> TaskRow | None: + return self._fetch_optional_one( + UPDATE_TASK_EXECUTION_BY_APPROVAL_SQL, + (status, latest_execution_id, approval_id), + ) + + def update_task_status_optional( + self, + *, + task_id: UUID, + status: str, + latest_approval_id: UUID | None, + latest_execution_id: UUID | None, + ) -> TaskRow | None: + return self._fetch_optional_one( + UPDATE_TASK_STATUS_SQL, + (status, latest_approval_id, latest_execution_id, task_id), + ) + + def create_gmail_account( + self, + *, + provider_account_id: str, + email_address: str, + display_name: str | None, + scope: str, + ) -> GmailAccountRow: + return self._fetch_one( + "create_gmail_account", + INSERT_GMAIL_ACCOUNT_SQL, + (provider_account_id, email_address, display_name, scope), + ) + + def create_gmail_account_credential( + self, + *, + gmail_account_id: UUID, + auth_kind: str, + credential_kind: str, + secret_manager_kind: str, + secret_ref: str | None, + credential_blob: JsonObject | None, + ) -> ProtectedGmailCredentialRow: + return self._fetch_one( + "create_gmail_account_credential", + INSERT_GMAIL_ACCOUNT_CREDENTIAL_SQL, + ( + gmail_account_id, + auth_kind, + credential_kind, + secret_manager_kind, + secret_ref, + None if credential_blob is None else Jsonb(credential_blob), + ), + ) + + def get_gmail_account_optional(self, gmail_account_id: UUID) -> GmailAccountRow | None: + return self._fetch_optional_one(GET_GMAIL_ACCOUNT_SQL, (gmail_account_id,)) + + def get_gmail_account_credential_optional( + self, + gmail_account_id: UUID, + ) -> ProtectedGmailCredentialRow | None: + return self._fetch_optional_one(GET_GMAIL_ACCOUNT_CREDENTIAL_SQL, (gmail_account_id,)) + + def update_gmail_account_credential( + self, + *, + gmail_account_id: UUID, + auth_kind: str, + credential_kind: str, + secret_manager_kind: str, + secret_ref: str | None, + credential_blob: JsonObject | None, + ) -> ProtectedGmailCredentialRow: + return self._fetch_one( + "update_gmail_account_credential", + UPDATE_GMAIL_ACCOUNT_CREDENTIAL_SQL, + ( + auth_kind, + credential_kind, + secret_manager_kind, + secret_ref, + None if credential_blob is None else Jsonb(credential_blob), + gmail_account_id, + ), + ) + + def get_gmail_account_by_provider_account_id_optional( + self, + provider_account_id: str, + ) -> GmailAccountRow | None: + return self._fetch_optional_one( + GET_GMAIL_ACCOUNT_BY_PROVIDER_ACCOUNT_ID_SQL, + (provider_account_id,), + ) + + def list_gmail_accounts(self) -> list[GmailAccountRow]: + return self._fetch_all(LIST_GMAIL_ACCOUNTS_SQL) + + def create_calendar_account( + self, + *, + provider_account_id: str, + email_address: str, + display_name: str | None, + scope: str, + ) -> CalendarAccountRow: + return self._fetch_one( + "create_calendar_account", + INSERT_CALENDAR_ACCOUNT_SQL, + (provider_account_id, email_address, display_name, scope), + ) + + def create_calendar_account_credential( + self, + *, + calendar_account_id: UUID, + auth_kind: str, + credential_kind: str, + secret_manager_kind: str, + secret_ref: str | None, + credential_blob: JsonObject | None, + ) -> ProtectedCalendarCredentialRow: + return self._fetch_one( + "create_calendar_account_credential", + INSERT_CALENDAR_ACCOUNT_CREDENTIAL_SQL, + ( + calendar_account_id, + auth_kind, + credential_kind, + secret_manager_kind, + secret_ref, + None if credential_blob is None else Jsonb(credential_blob), + ), + ) + + def get_calendar_account_optional(self, calendar_account_id: UUID) -> CalendarAccountRow | None: + return self._fetch_optional_one(GET_CALENDAR_ACCOUNT_SQL, (calendar_account_id,)) + + def get_calendar_account_credential_optional( + self, + calendar_account_id: UUID, + ) -> ProtectedCalendarCredentialRow | None: + return self._fetch_optional_one( + GET_CALENDAR_ACCOUNT_CREDENTIAL_SQL, + (calendar_account_id,), + ) + + def get_calendar_account_by_provider_account_id_optional( + self, + provider_account_id: str, + ) -> CalendarAccountRow | None: + return self._fetch_optional_one( + GET_CALENDAR_ACCOUNT_BY_PROVIDER_ACCOUNT_ID_SQL, + (provider_account_id,), + ) + + def list_calendar_accounts(self) -> list[CalendarAccountRow]: + return self._fetch_all(LIST_CALENDAR_ACCOUNTS_SQL) + + def lock_task_workspaces(self, task_id: UUID) -> None: + with self.conn.cursor() as cur: + cur.execute(LOCK_TASK_WORKSPACES_SQL, (str(task_id),)) + + def create_task_workspace( + self, + *, + task_id: UUID, + status: str, + local_path: str, + ) -> TaskWorkspaceRow: + return self._fetch_one( + "create_task_workspace", + INSERT_TASK_WORKSPACE_SQL, + (task_id, status, local_path), + ) + + def get_task_workspace_optional(self, task_workspace_id: UUID) -> TaskWorkspaceRow | None: + return self._fetch_optional_one(GET_TASK_WORKSPACE_SQL, (task_workspace_id,)) + + def get_active_task_workspace_for_task_optional(self, task_id: UUID) -> TaskWorkspaceRow | None: + return self._fetch_optional_one(GET_ACTIVE_TASK_WORKSPACE_FOR_TASK_SQL, (task_id,)) + + def list_task_workspaces(self) -> list[TaskWorkspaceRow]: + return self._fetch_all(LIST_TASK_WORKSPACES_SQL) + + def lock_task_artifacts(self, task_workspace_id: UUID) -> None: + with self.conn.cursor() as cur: + cur.execute(LOCK_TASK_ARTIFACTS_SQL, (str(task_workspace_id),)) + + def create_task_artifact( + self, + *, + task_id: UUID, + task_workspace_id: UUID, + status: str, + ingestion_status: str, + relative_path: str, + media_type_hint: str | None, + ) -> TaskArtifactRow: + return self._fetch_one( + "create_task_artifact", + INSERT_TASK_ARTIFACT_SQL, + ( + task_id, + task_workspace_id, + status, + ingestion_status, + relative_path, + media_type_hint, + ), + ) + + def get_task_artifact_optional(self, task_artifact_id: UUID) -> TaskArtifactRow | None: + return self._fetch_optional_one(GET_TASK_ARTIFACT_SQL, (task_artifact_id,)) + + def get_task_artifact_by_workspace_relative_path_optional( + self, + *, + task_workspace_id: UUID, + relative_path: str, + ) -> TaskArtifactRow | None: + return self._fetch_optional_one( + GET_TASK_ARTIFACT_BY_WORKSPACE_RELATIVE_PATH_SQL, + (task_workspace_id, relative_path), + ) + + def list_task_artifacts(self) -> list[TaskArtifactRow]: + return self._fetch_all(LIST_TASK_ARTIFACTS_SQL) + + def list_task_artifacts_for_task(self, task_id: UUID) -> list[TaskArtifactRow]: + return self._fetch_all(LIST_TASK_ARTIFACTS_FOR_TASK_SQL, (task_id,)) + + def lock_task_artifact_ingestion(self, task_artifact_id: UUID) -> None: + with self.conn.cursor() as cur: + cur.execute(LOCK_TASK_ARTIFACT_INGESTION_SQL, (str(task_artifact_id),)) + + def create_task_artifact_chunk( + self, + *, + task_artifact_id: UUID, + sequence_no: int, + char_start: int, + char_end_exclusive: int, + text: str, + ) -> TaskArtifactChunkRow: + return self._fetch_one( + "create_task_artifact_chunk", + INSERT_TASK_ARTIFACT_CHUNK_SQL, + (task_artifact_id, sequence_no, char_start, char_end_exclusive, text), + ) + + def get_task_artifact_chunk_optional(self, task_artifact_chunk_id: UUID) -> TaskArtifactChunkRow | None: + return self._fetch_optional_one(GET_TASK_ARTIFACT_CHUNK_SQL, (task_artifact_chunk_id,)) + + def list_task_artifact_chunks(self, task_artifact_id: UUID) -> list[TaskArtifactChunkRow]: + return self._fetch_all(LIST_TASK_ARTIFACT_CHUNKS_SQL, (task_artifact_id,)) + + def create_task_artifact_chunk_embedding( + self, + *, + task_artifact_chunk_id: UUID, + embedding_config_id: UUID, + dimensions: int, + vector: list[float], + ) -> TaskArtifactChunkEmbeddingRow: + return self._fetch_one( + "create_task_artifact_chunk_embedding", + INSERT_TASK_ARTIFACT_CHUNK_EMBEDDING_SQL, + (task_artifact_chunk_id, embedding_config_id, dimensions, Jsonb(vector)), + ) + + def get_task_artifact_chunk_embedding_optional( + self, + task_artifact_chunk_embedding_id: UUID, + ) -> TaskArtifactChunkEmbeddingRow | None: + return self._fetch_optional_one( + GET_TASK_ARTIFACT_CHUNK_EMBEDDING_SQL, + (task_artifact_chunk_embedding_id,), + ) + + def get_task_artifact_chunk_embedding_by_chunk_and_config_optional( + self, + *, + task_artifact_chunk_id: UUID, + embedding_config_id: UUID, + ) -> TaskArtifactChunkEmbeddingRow | None: + return self._fetch_optional_one( + GET_TASK_ARTIFACT_CHUNK_EMBEDDING_BY_CHUNK_AND_CONFIG_SQL, + (task_artifact_chunk_id, embedding_config_id), + ) + + def list_task_artifact_chunk_embeddings_for_chunk( + self, + task_artifact_chunk_id: UUID, + ) -> list[TaskArtifactChunkEmbeddingRow]: + return self._fetch_all( + LIST_TASK_ARTIFACT_CHUNK_EMBEDDINGS_FOR_CHUNK_SQL, + (task_artifact_chunk_id,), + ) + + def list_task_artifact_chunk_embeddings_for_artifact( + self, + task_artifact_id: UUID, + ) -> list[TaskArtifactChunkEmbeddingRow]: + return self._fetch_all( + LIST_TASK_ARTIFACT_CHUNK_EMBEDDINGS_FOR_ARTIFACT_SQL, + (task_artifact_id,), + ) + + def update_task_artifact_chunk_embedding( + self, + *, + task_artifact_chunk_embedding_id: UUID, + dimensions: int, + vector: list[float], + ) -> TaskArtifactChunkEmbeddingRow: + return self._fetch_one( + "update_task_artifact_chunk_embedding", + UPDATE_TASK_ARTIFACT_CHUNK_EMBEDDING_SQL, + (dimensions, Jsonb(vector), task_artifact_chunk_embedding_id), + ) + + def update_task_artifact_ingestion_status( + self, + *, + task_artifact_id: UUID, + ingestion_status: str, + ) -> TaskArtifactRow: + return self._fetch_one( + "update_task_artifact_ingestion_status", + UPDATE_TASK_ARTIFACT_INGESTION_STATUS_SQL, + (ingestion_status, task_artifact_id), + ) + + def lock_task_steps(self, task_id: UUID) -> None: + self._acquire_advisory_lock(LOCK_TASK_STEPS_SQL, task_id) + + def create_task_step( + self, + *, + task_id: UUID, + sequence_no: int, + parent_step_id: UUID | None = None, + source_approval_id: UUID | None = None, + source_execution_id: UUID | None = None, + kind: str, + status: str, + request: JsonObject, + outcome: JsonObject, + trace_id: UUID, + trace_kind: str, + ) -> TaskStepRow: + return self._fetch_one_with_lock( + operation_name="create_task_step", + lock_query=LOCK_TASK_STEPS_SQL, + lock_key=task_id, + query=INSERT_TASK_STEP_SQL, + params=( + task_id, + sequence_no, + parent_step_id, + source_approval_id, + source_execution_id, + kind, + status, + Jsonb(request), + Jsonb(outcome), + trace_id, + trace_kind, + ), + ) + + def get_task_step_optional(self, task_step_id: UUID) -> TaskStepRow | None: + return self._fetch_optional_one(GET_TASK_STEP_SQL, (task_step_id,)) + + def get_task_step_for_task_sequence_optional( + self, + *, + task_id: UUID, + sequence_no: int, + ) -> TaskStepRow | None: + return self._fetch_optional_one( + GET_TASK_STEP_FOR_TASK_SEQUENCE_SQL, + (task_id, sequence_no), + ) + + def list_task_steps_for_task(self, task_id: UUID) -> list[TaskStepRow]: + return self._fetch_all(LIST_TASK_STEPS_FOR_TASK_SQL, (task_id,)) + + def update_task_step_for_task_sequence_optional( + self, + *, + task_id: UUID, + sequence_no: int, + status: str, + outcome: JsonObject, + trace_id: UUID, + trace_kind: str, + ) -> TaskStepRow | None: + return self._fetch_optional_one( + UPDATE_TASK_STEP_FOR_TASK_SEQUENCE_SQL, + ( + status, + Jsonb(outcome), + trace_id, + trace_kind, + task_id, + sequence_no, + ), + ) + + def update_task_step_optional( + self, + *, + task_step_id: UUID, + status: str, + outcome: JsonObject, + trace_id: UUID, + trace_kind: str, + ) -> TaskStepRow | None: + return self._fetch_optional_one( + UPDATE_TASK_STEP_SQL, + ( + status, + Jsonb(outcome), + trace_id, + trace_kind, + task_step_id, + ), + ) + + def lock_task_runs(self, task_id: UUID) -> None: + self._acquire_advisory_lock(LOCK_TASK_RUNS_SQL, task_id) + + def create_task_run( + self, + *, + task_id: UUID, + status: str, + checkpoint: JsonObject, + tick_count: int, + step_count: int, + max_ticks: int, + retry_count: int, + retry_cap: int, + retry_posture: str, + failure_class: str | None, + stop_reason: str | None, + ) -> TaskRunRow: + return self._fetch_one( + "create_task_run", + INSERT_TASK_RUN_SQL, + ( + task_id, + status, + Jsonb(checkpoint), + tick_count, + step_count, + max_ticks, + retry_count, + retry_cap, + retry_posture, + failure_class, + stop_reason, + ), + ) + + def get_task_run_optional(self, task_run_id: UUID) -> TaskRunRow | None: + return self._fetch_optional_one(GET_TASK_RUN_SQL, (task_run_id,)) + + def list_task_runs_for_task(self, task_id: UUID) -> list[TaskRunRow]: + return self._fetch_all(LIST_TASK_RUNS_FOR_TASK_SQL, (task_id,)) + + def update_task_run_optional( + self, + *, + task_run_id: UUID, + status: str, + checkpoint: JsonObject, + tick_count: int, + step_count: int, + retry_count: int, + retry_cap: int, + retry_posture: str, + failure_class: str | None, + stop_reason: str | None, + ) -> TaskRunRow | None: + return self._fetch_optional_one( + UPDATE_TASK_RUN_SQL, + ( + status, + Jsonb(checkpoint), + tick_count, + step_count, + retry_count, + retry_cap, + retry_posture, + failure_class, + stop_reason, + task_run_id, + ), + ) + + def acquire_next_task_run_optional(self) -> TaskRunRow | None: + return self._fetch_optional_one(ACQUIRE_NEXT_TASK_RUN_SQL) + + def create_tool_execution( + self, + *, + approval_id: UUID, + task_step_id: UUID, + thread_id: UUID, + tool_id: UUID, + trace_id: UUID, + request_event_id: UUID | None, + result_event_id: UUID | None, + status: str, + handler_key: str | None, + request: JsonObject, + tool: JsonObject, + result: JsonObject, + task_run_id: UUID | None = None, + idempotency_key: str | None = None, + ) -> ToolExecutionRow: + return self._fetch_one( + "create_tool_execution", + INSERT_TOOL_EXECUTION_SQL, + ( + approval_id, + task_run_id, + task_step_id, + thread_id, + tool_id, + trace_id, + request_event_id, + result_event_id, + status, + handler_key, + idempotency_key, + Jsonb(request), + Jsonb(tool), + Jsonb(result), + ), + ) + + def get_tool_execution_optional(self, execution_id: UUID) -> ToolExecutionRow | None: + return self._fetch_optional_one(GET_TOOL_EXECUTION_SQL, (execution_id,)) + + def list_tool_executions(self) -> list[ToolExecutionRow]: + return self._fetch_all(LIST_TOOL_EXECUTIONS_SQL) + + def get_tool_execution_by_idempotency_optional( + self, + *, + task_run_id: UUID, + approval_id: UUID, + idempotency_key: str, + ) -> ToolExecutionRow | None: + return self._fetch_optional_one( + GET_TOOL_EXECUTION_BY_IDEMPOTENCY_SQL, + (task_run_id, approval_id, idempotency_key), + ) + + def create_execution_budget( + self, + *, + budget_id: UUID | None = None, + agent_profile_id: str | None = None, + tool_key: str | None, + domain_hint: str | None, + max_completed_executions: int, + rolling_window_seconds: int | None = None, + supersedes_budget_id: UUID | None = None, + ) -> ExecutionBudgetRow: + return self._fetch_one( + "create_execution_budget", + INSERT_EXECUTION_BUDGET_SQL, + ( + budget_id, + agent_profile_id, + tool_key, + domain_hint, + max_completed_executions, + rolling_window_seconds, + supersedes_budget_id, + ), + ) + + def get_execution_budget_optional(self, execution_budget_id: UUID) -> ExecutionBudgetRow | None: + return self._fetch_optional_one(GET_EXECUTION_BUDGET_SQL, (execution_budget_id,)) + + def list_execution_budgets(self) -> list[ExecutionBudgetRow]: + return self._fetch_all(LIST_EXECUTION_BUDGETS_SQL) + + def deactivate_execution_budget_optional( + self, + execution_budget_id: UUID, + ) -> ExecutionBudgetRow | None: + return self._fetch_optional_one(DEACTIVATE_EXECUTION_BUDGET_SQL, (execution_budget_id,)) + + def supersede_execution_budget_optional( + self, + *, + execution_budget_id: UUID, + superseded_by_budget_id: UUID, + ) -> ExecutionBudgetRow | None: + return self._fetch_optional_one( + SUPERSEDE_EXECUTION_BUDGET_SQL, + ( + superseded_by_budget_id, + execution_budget_id, + ), + ) + + def update_event(self, *_args: Any, **_kwargs: Any) -> None: + raise AppendOnlyViolation(UPDATE_EVENT_ERROR) + + def delete_event(self, *_args: Any, **_kwargs: Any) -> None: + raise AppendOnlyViolation(DELETE_EVENT_ERROR) + + def update_trace_event(self, *_args: Any, **_kwargs: Any) -> None: + raise AppendOnlyViolation(UPDATE_TRACE_EVENT_ERROR) + + def delete_trace_event(self, *_args: Any, **_kwargs: Any) -> None: + raise AppendOnlyViolation(DELETE_TRACE_EVENT_ERROR) diff --git a/apps/api/src/alicebot_api/task_runs.py b/apps/api/src/alicebot_api/task_runs.py new file mode 100644 index 0000000..11fd0e8 --- /dev/null +++ b/apps/api/src/alicebot_api/task_runs.py @@ -0,0 +1,610 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from typing import cast +from uuid import UUID + +from alicebot_api.contracts import ( + TASK_RUN_LIST_ORDER, + TaskRunCancelInput, + TaskRunCreateInput, + TaskRunCreateResponse, + TaskRunDetailResponse, + TaskRunFailureClass, + TaskRunListResponse, + TaskRunListSummary, + TaskRunMutationResponse, + TaskRunPauseInput, + TaskRunRecord, + TaskRunResumeInput, + TaskRunRetryPosture, + TaskRunStatus, + TaskRunStopReason, + TaskRunTickInput, +) +from alicebot_api.store import ContinuityStore, JsonObject, TaskRunRow +from alicebot_api.tasks import TaskNotFoundError + + +RUNNABLE_TASK_RUN_STATUSES = frozenset({"queued", "running"}) +PAUSEABLE_TASK_RUN_STATUSES = frozenset({"queued", "running", "waiting_approval", "waiting_user"}) +RESUMABLE_TASK_RUN_STATUSES = frozenset({"paused", "waiting_approval", "waiting_user", "failed"}) +CANCELLABLE_TASK_RUN_STATUSES = frozenset({"queued", "running", "waiting_approval", "waiting_user", "paused"}) +TERMINAL_TASK_RUN_STATUSES = frozenset({"failed", "done", "cancelled"}) + + +class TaskRunValidationError(ValueError): + """Raised when a task-run request fails explicit validation.""" + + +class TaskRunNotFoundError(LookupError): + """Raised when a task-run record is not visible inside the current user scope.""" + + +class TaskRunTransitionError(RuntimeError): + """Raised when a task-run lifecycle transition is invalid.""" + + +def serialize_task_run_row(row: TaskRunRow) -> TaskRunRecord: + return { + "id": str(row["id"]), + "task_id": str(row["task_id"]), + "status": cast(TaskRunStatus, row["status"]), + "checkpoint": cast(JsonObject, row["checkpoint"]), + "tick_count": row["tick_count"], + "step_count": row["step_count"], + "max_ticks": row["max_ticks"], + "retry_count": row["retry_count"], + "retry_cap": row["retry_cap"], + "retry_posture": cast(TaskRunRetryPosture, row["retry_posture"]), + "failure_class": cast(TaskRunFailureClass | None, row["failure_class"]), + "stop_reason": cast(TaskRunStopReason | None, row["stop_reason"]), + "last_transitioned_at": row["last_transitioned_at"].isoformat(), + "created_at": row["created_at"].isoformat(), + "updated_at": row["updated_at"].isoformat(), + } + + +def _coerce_non_negative_int(value: object, *, key: str) -> int: + if isinstance(value, bool) or not isinstance(value, int): + raise TaskRunValidationError(f"checkpoint.{key} must be an integer") + if value < 0: + raise TaskRunValidationError(f"checkpoint.{key} must be greater than or equal to 0") + return value + + +def _coerce_positive_int(value: object, *, key: str) -> int: + if isinstance(value, bool) or not isinstance(value, int): + raise TaskRunValidationError(f"checkpoint.{key} must be an integer") + if value <= 0: + raise TaskRunValidationError(f"checkpoint.{key} must be greater than 0") + return value + + +def normalize_checkpoint(checkpoint: JsonObject) -> JsonObject: + if not isinstance(checkpoint, dict): + raise TaskRunValidationError("checkpoint must be a JSON object") + + cursor = _coerce_non_negative_int(checkpoint.get("cursor", 0), key="cursor") + target_steps = _coerce_positive_int(checkpoint.get("target_steps", 1), key="target_steps") + wait_for_signal = checkpoint.get("wait_for_signal", False) + if not isinstance(wait_for_signal, bool): + raise TaskRunValidationError("checkpoint.wait_for_signal must be a boolean") + if cursor > target_steps: + raise TaskRunValidationError("checkpoint.cursor must be less than or equal to checkpoint.target_steps") + + normalized = dict(checkpoint) + normalized["cursor"] = cursor + normalized["target_steps"] = target_steps + normalized["wait_for_signal"] = wait_for_signal + return normalized + + +def _require_task_exists(store: ContinuityStore, *, task_id: UUID) -> None: + if store.get_task_optional(task_id) is None: + raise TaskNotFoundError(f"task {task_id} was not found") + + +def _require_task_run(store: ContinuityStore, *, task_run_id: UUID) -> TaskRunRow: + row = store.get_task_run_optional(task_run_id) + if row is None: + raise TaskRunNotFoundError(f"task run {task_run_id} was not found") + return row + + +def _transition_conflict(*, task_run_id: UUID, status: str, action: str) -> TaskRunTransitionError: + return TaskRunTransitionError(f"task run {task_run_id} is {status} and cannot be {action}") + + +def _append_transition_checkpoint_entry( + checkpoint: JsonObject, + *, + source: str, + previous_status: TaskRunStatus | None, + status: TaskRunStatus, + previous_stop_reason: TaskRunStopReason | None, + stop_reason: TaskRunStopReason | None, + failure_class: TaskRunFailureClass | None, + retry_count: int, + retry_cap: int, + retry_posture: TaskRunRetryPosture, +) -> JsonObject: + normalized = normalize_checkpoint(checkpoint) + transitions = normalized.get("transitions") + if isinstance(transitions, list): + history = [entry for entry in transitions if isinstance(entry, dict)] + else: + history = [] + + transition_entry = { + "sequence_no": len(history) + 1, + "source": source, + "at": datetime.now(UTC).isoformat(), + "previous_status": previous_status, + "status": status, + "previous_stop_reason": previous_stop_reason, + "stop_reason": stop_reason, + "failure_class": failure_class, + "retry_count": retry_count, + "retry_cap": retry_cap, + "retry_posture": retry_posture, + } + history.append(transition_entry) + normalized["transitions"] = history + normalized["last_transition"] = transition_entry + return normalized + + +def _update_task_run( + store: ContinuityStore, + *, + row: TaskRunRow, + status: TaskRunStatus, + checkpoint: JsonObject, + tick_count: int, + step_count: int, + retry_count: int, + retry_cap: int, + retry_posture: TaskRunRetryPosture, + failure_class: TaskRunFailureClass | None, + stop_reason: TaskRunStopReason | None, + source: str, +) -> TaskRunRow: + checkpoint_with_transition = _append_transition_checkpoint_entry( + checkpoint, + source=source, + previous_status=cast(TaskRunStatus, row["status"]), + status=status, + previous_stop_reason=cast(TaskRunStopReason | None, row["stop_reason"]), + stop_reason=stop_reason, + failure_class=failure_class, + retry_count=retry_count, + retry_cap=retry_cap, + retry_posture=retry_posture, + ) + updated = store.update_task_run_optional( + task_run_id=cast(UUID, row["id"]), + status=status, + checkpoint=checkpoint_with_transition, + tick_count=tick_count, + step_count=step_count, + retry_count=retry_count, + retry_cap=retry_cap, + retry_posture=retry_posture, + failure_class=failure_class, + stop_reason=stop_reason, + ) + if updated is None: + raise TaskRunNotFoundError(f"task run {row['id']} was not found") + return updated + + +def create_task_run_record( + store: ContinuityStore, + *, + user_id: UUID, + request: TaskRunCreateInput, +) -> TaskRunCreateResponse: + del user_id + + _require_task_exists(store, task_id=request.task_id) + if request.max_ticks <= 0: + raise TaskRunValidationError("max_ticks must be greater than 0") + + retry_cap = request.retry_cap if request.retry_cap is not None else max(1, request.max_ticks) + if retry_cap <= 0: + raise TaskRunValidationError("retry_cap must be greater than 0") + + checkpoint = _append_transition_checkpoint_entry( + request.checkpoint, + source="create", + previous_status=None, + status="queued", + previous_stop_reason=None, + stop_reason=None, + failure_class=None, + retry_count=0, + retry_cap=retry_cap, + retry_posture="none", + ) + row = store.create_task_run( + task_id=request.task_id, + status="queued", + checkpoint=checkpoint, + tick_count=0, + step_count=0, + max_ticks=request.max_ticks, + retry_count=0, + retry_cap=retry_cap, + retry_posture="none", + failure_class=None, + stop_reason=None, + ) + return {"task_run": serialize_task_run_row(row)} + + +def list_task_run_records( + store: ContinuityStore, + *, + user_id: UUID, + task_id: UUID, +) -> TaskRunListResponse: + del user_id + + _require_task_exists(store, task_id=task_id) + items = [serialize_task_run_row(row) for row in store.list_task_runs_for_task(task_id)] + summary: TaskRunListSummary = { + "task_id": str(task_id), + "total_count": len(items), + "order": list(TASK_RUN_LIST_ORDER), + } + return { + "items": items, + "summary": summary, + } + + +def get_task_run_record( + store: ContinuityStore, + *, + user_id: UUID, + task_run_id: UUID, +) -> TaskRunDetailResponse: + del user_id + + row = _require_task_run(store, task_run_id=task_run_id) + return {"task_run": serialize_task_run_row(row)} + + +def tick_task_run_record( + store: ContinuityStore, + *, + user_id: UUID, + request: TaskRunTickInput, +) -> TaskRunMutationResponse: + del user_id + + row = _require_task_run(store, task_run_id=request.task_run_id) + previous_status = cast(TaskRunStatus, row["status"]) + if previous_status not in RUNNABLE_TASK_RUN_STATUSES: + raise _transition_conflict(task_run_id=request.task_run_id, status=previous_status, action="ticked") + + checkpoint = normalize_checkpoint(cast(JsonObject, row["checkpoint"])) + cursor = _coerce_non_negative_int(checkpoint.get("cursor", 0), key="cursor") + target_steps = _coerce_positive_int(checkpoint.get("target_steps", 1), key="target_steps") + wait_for_signal = bool(checkpoint.get("wait_for_signal", False)) + tick_count = int(row["tick_count"]) + step_count = int(row["step_count"]) + max_ticks = int(row["max_ticks"]) + retry_count = int(row["retry_count"]) + retry_cap = int(row["retry_cap"]) + task = _require_task_exists_and_load(store, task_id=cast(UUID, row["task_id"])) + + if task["status"] == "pending_approval" and task["latest_approval_id"] is not None: + approval_id = cast(UUID, task["latest_approval_id"]) + approval = store.get_approval_optional(approval_id) + if approval is not None and approval["status"] == "pending": + checkpoint["wait_for_signal"] = True + checkpoint["waiting_approval_id"] = str(approval_id) + store.update_approval_task_run_optional( + approval_id=approval_id, + task_run_id=cast(UUID, row["id"]), + ) + updated = _update_task_run( + store, + row=row, + status="waiting_approval", + checkpoint=checkpoint, + tick_count=tick_count + 1, + step_count=step_count, + retry_count=retry_count, + retry_cap=retry_cap, + retry_posture="awaiting_approval", + failure_class=None, + stop_reason="waiting_approval", + source="tick_waiting_approval", + ) + return { + "task_run": serialize_task_run_row(updated), + "previous_status": previous_status, + } + + if cursor >= target_steps: + updated = _update_task_run( + store, + row=row, + status="done", + checkpoint=checkpoint, + tick_count=tick_count, + step_count=step_count, + retry_count=retry_count, + retry_cap=retry_cap, + retry_posture="terminal", + failure_class=None, + stop_reason="done", + source="tick_done_existing_cursor", + ) + elif tick_count >= max_ticks: + updated = _update_task_run( + store, + row=row, + status="failed", + checkpoint=checkpoint, + tick_count=tick_count, + step_count=step_count, + retry_count=retry_count, + retry_cap=retry_cap, + retry_posture="terminal", + failure_class="budget", + stop_reason="budget_exhausted", + source="tick_budget_exhausted", + ) + elif wait_for_signal: + checkpoint["wait_for_signal"] = True + updated = _update_task_run( + store, + row=row, + status="waiting_user", + checkpoint=checkpoint, + tick_count=tick_count + 1, + step_count=step_count, + retry_count=retry_count, + retry_cap=retry_cap, + retry_posture="awaiting_user", + failure_class=None, + stop_reason="waiting_user", + source="tick_waiting_user", + ) + else: + checkpoint["cursor"] = cursor + 1 + next_tick_count = tick_count + 1 + next_step_count = step_count + 1 + if checkpoint["cursor"] >= target_steps: + status: TaskRunStatus = "done" + stop_reason: TaskRunStopReason | None = "done" + retry_posture: TaskRunRetryPosture = "terminal" + else: + status = "running" + stop_reason = None + retry_posture = "none" + updated = _update_task_run( + store, + row=row, + status=status, + checkpoint=checkpoint, + tick_count=next_tick_count, + step_count=next_step_count, + retry_count=retry_count, + retry_cap=retry_cap, + retry_posture=retry_posture, + failure_class=None, + stop_reason=stop_reason, + source="tick_progress", + ) + + return { + "task_run": serialize_task_run_row(updated), + "previous_status": previous_status, + } + + +def pause_task_run_record( + store: ContinuityStore, + *, + user_id: UUID, + request: TaskRunPauseInput, +) -> TaskRunMutationResponse: + del user_id + + row = _require_task_run(store, task_run_id=request.task_run_id) + previous_status = cast(TaskRunStatus, row["status"]) + if previous_status not in PAUSEABLE_TASK_RUN_STATUSES: + raise _transition_conflict(task_run_id=request.task_run_id, status=previous_status, action="paused") + + checkpoint = normalize_checkpoint(cast(JsonObject, row["checkpoint"])) + updated = _update_task_run( + store, + row=row, + status="paused", + checkpoint=checkpoint, + tick_count=int(row["tick_count"]), + step_count=int(row["step_count"]), + retry_count=int(row["retry_count"]), + retry_cap=int(row["retry_cap"]), + retry_posture="paused", + failure_class=None, + stop_reason="paused", + source="pause", + ) + return { + "task_run": serialize_task_run_row(updated), + "previous_status": previous_status, + } + + +def resume_task_run_record( + store: ContinuityStore, + *, + user_id: UUID, + request: TaskRunResumeInput, +) -> TaskRunMutationResponse: + del user_id + + row = _require_task_run(store, task_run_id=request.task_run_id) + previous_status = cast(TaskRunStatus, row["status"]) + if previous_status not in RESUMABLE_TASK_RUN_STATUSES: + raise _transition_conflict(task_run_id=request.task_run_id, status=previous_status, action="resumed") + + checkpoint = normalize_checkpoint(cast(JsonObject, row["checkpoint"])) + if previous_status == "waiting_approval": + waiting_approval_id = checkpoint.get("waiting_approval_id") + if isinstance(waiting_approval_id, str) and waiting_approval_id: + try: + waiting_approval_uuid = UUID(waiting_approval_id) + except ValueError: + waiting_approval_uuid = None + approval = None if waiting_approval_uuid is None else store.get_approval_optional(waiting_approval_uuid) + if approval is not None and approval["status"] == "pending": + raise _transition_conflict( + task_run_id=request.task_run_id, + status=previous_status, + action="resumed while approval is still pending", + ) + + checkpoint["wait_for_signal"] = False + checkpoint["waiting_approval_id"] = None + checkpoint["resumed_by_user"] = True + + retry_count = int(row["retry_count"]) + retry_cap = int(row["retry_cap"]) + if previous_status == "failed": + failure_class = cast(TaskRunFailureClass | None, row["failure_class"]) + if failure_class != "transient": + raise _transition_conflict( + task_run_id=request.task_run_id, + status=previous_status, + action="resumed because failure class is terminal", + ) + if retry_count >= retry_cap: + raise _transition_conflict( + task_run_id=request.task_run_id, + status=previous_status, + action="resumed because retry cap is exhausted", + ) + next_retry_count = retry_count + 1 + next_status: TaskRunStatus = "queued" + else: + next_retry_count = retry_count + next_status = "queued" if previous_status == "waiting_approval" else "running" + + updated = _update_task_run( + store, + row=row, + status=next_status, + checkpoint=checkpoint, + tick_count=int(row["tick_count"]), + step_count=int(row["step_count"]), + retry_count=next_retry_count, + retry_cap=retry_cap, + retry_posture="none", + failure_class=None, + stop_reason=None, + source="resume", + ) + return { + "task_run": serialize_task_run_row(updated), + "previous_status": previous_status, + } + + +def cancel_task_run_record( + store: ContinuityStore, + *, + user_id: UUID, + request: TaskRunCancelInput, +) -> TaskRunMutationResponse: + del user_id + + row = _require_task_run(store, task_run_id=request.task_run_id) + previous_status = cast(TaskRunStatus, row["status"]) + if previous_status not in CANCELLABLE_TASK_RUN_STATUSES: + raise _transition_conflict(task_run_id=request.task_run_id, status=previous_status, action="cancelled") + + checkpoint = normalize_checkpoint(cast(JsonObject, row["checkpoint"])) + updated = _update_task_run( + store, + row=row, + status="cancelled", + checkpoint=checkpoint, + tick_count=int(row["tick_count"]), + step_count=int(row["step_count"]), + retry_count=int(row["retry_count"]), + retry_cap=int(row["retry_cap"]), + retry_posture="terminal", + failure_class=None, + stop_reason="cancelled", + source="cancel", + ) + return { + "task_run": serialize_task_run_row(updated), + "previous_status": previous_status, + } + + +def mark_task_run_failed( + store: ContinuityStore, + *, + user_id: UUID, + task_run_id: UUID, + stop_reason: TaskRunStopReason, + failure_class: TaskRunFailureClass, + source: str, +) -> TaskRunMutationResponse | None: + del user_id + + row = store.get_task_run_optional(task_run_id) + if row is None: + return None + + previous_status = cast(TaskRunStatus, row["status"]) + if previous_status in {"done", "cancelled"}: + return None + + retry_count = int(row["retry_count"]) + retry_cap = int(row["retry_cap"]) + if failure_class == "transient": + if retry_count < retry_cap: + retry_posture: TaskRunRetryPosture = "retryable" + next_stop_reason = stop_reason + else: + retry_posture = "exhausted" + next_stop_reason = "retry_exhausted" + else: + retry_posture = "terminal" + next_stop_reason = stop_reason + + updated = _update_task_run( + store, + row=row, + status="failed", + checkpoint=cast(JsonObject, row["checkpoint"]), + tick_count=int(row["tick_count"]), + step_count=int(row["step_count"]), + retry_count=retry_count, + retry_cap=retry_cap, + retry_posture=retry_posture, + failure_class=failure_class, + stop_reason=next_stop_reason, + source=source, + ) + return { + "task_run": serialize_task_run_row(updated), + "previous_status": previous_status, + } + + +def _require_task_exists_and_load(store: ContinuityStore, *, task_id: UUID) -> dict[str, object]: + task = store.get_task_optional(task_id) + if task is None: + raise TaskNotFoundError(f"task {task_id} was not found") + return cast(dict[str, object], task) diff --git a/apps/api/src/alicebot_api/tasks.py b/apps/api/src/alicebot_api/tasks.py new file mode 100644 index 0000000..da88e5f --- /dev/null +++ b/apps/api/src/alicebot_api/tasks.py @@ -0,0 +1,1170 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import cast +from uuid import UUID + +import psycopg + +from alicebot_api.contracts import ( + TASK_LIST_ORDER, + TASK_STEP_CONTINUATION_VERSION_V0, + TASK_STEP_LIST_ORDER, + TASK_STEP_TRANSITION_VERSION_V0, + TRACE_KIND_TASK_STEP_CONTINUATION, + TRACE_KIND_TASK_STEP_TRANSITION, + TaskCreateInput, + TaskCreateResponse, + TaskDetailResponse, + TaskLifecycleSource, + TaskLifecycleStateTracePayload, + TaskLifecycleSummaryTracePayload, + TaskListResponse, + TaskListSummary, + TaskRecord, + TaskStatus, + TaskStepCreateInput, + TaskStepCreateResponse, + TaskStepDetailResponse, + TaskStepContinuationLineageTracePayload, + TaskStepContinuationRequestTracePayload, + TaskStepContinuationSummaryTracePayload, + TaskStepLifecycleStateTracePayload, + TaskStepLifecycleSummaryTracePayload, + TaskStepLineageRecord, + TaskStepListSummary, + TaskStepListResponse, + TaskStepMutationTraceSummary, + TaskStepNextCreateInput, + TaskStepNextCreateResponse, + TaskStepOutcomeSnapshot, + TaskStepRecord, + TaskStepStatus, + TaskStepTransitionInput, + TaskStepTransitionRequestTracePayload, + TaskStepTransitionResponse, + TaskStepTransitionStateTracePayload, + TaskStepTransitionSummaryTracePayload, +) +from alicebot_api.store import ( + ContinuityStore, + ContinuityStoreInvariantError, + TaskRow, + TaskStepRow, + ToolExecutionRow, +) + +TASK_LIFECYCLE_STATE_EVENT_KIND = "task.lifecycle.state" +TASK_LIFECYCLE_SUMMARY_EVENT_KIND = "task.lifecycle.summary" +TASK_STEP_LIFECYCLE_STATE_EVENT_KIND = "task.step.lifecycle.state" +TASK_STEP_LIFECYCLE_SUMMARY_EVENT_KIND = "task.step.lifecycle.summary" +TASK_STEP_CONTINUATION_REQUEST_EVENT_KIND = "task.step.continuation.request" +TASK_STEP_CONTINUATION_LINEAGE_EVENT_KIND = "task.step.continuation.lineage" +TASK_STEP_CONTINUATION_SUMMARY_EVENT_KIND = "task.step.continuation.summary" +TASK_STEP_TRANSITION_REQUEST_EVENT_KIND = "task.step.transition.request" +TASK_STEP_TRANSITION_STATE_EVENT_KIND = "task.step.transition.state" +TASK_STEP_TRANSITION_SUMMARY_EVENT_KIND = "task.step.transition.summary" +DEFAULT_TASK_STEP_SEQUENCE_NO = 1 +DEFAULT_TASK_STEP_KIND = "governed_request" +TASK_STEP_APPENDABLE_STATUSES = frozenset({"executed", "blocked", "denied"}) +TASK_STEP_INITIAL_STATUSES = frozenset({"created", "approved", "denied"}) +TASK_STEP_STATUS_GRAPH: dict[TaskStepStatus, tuple[TaskStepStatus, ...]] = { + "created": ("approved", "denied"), + "approved": ("executed", "blocked"), + "executed": (), + "blocked": (), + "denied": (), +} + + +class TaskNotFoundError(LookupError): + """Raised when a task record is not visible inside the current user scope.""" + + +class TaskStepNotFoundError(LookupError): + """Raised when a task-step record is not visible inside the current user scope.""" + + +class TaskStepSequenceError(RuntimeError): + """Raised when a task-step append request violates deterministic sequencing rules.""" + + +class TaskStepTransitionError(RuntimeError): + """Raised when a task-step transition request violates the explicit status graph.""" + + +class TaskStepLifecycleBoundaryError(RuntimeError): + """Raised when first-step-only lifecycle helpers are routed a later-step context.""" + + +class TaskStepApprovalLinkageError(RuntimeError): + """Raised when approval resolution cannot validate its linked task step.""" + + +class TaskStepExecutionLinkageError(RuntimeError): + """Raised when execution synchronization cannot validate its linked task step.""" + + +@dataclass(frozen=True, slots=True) +class TaskTransitionResult: + task: TaskRecord + previous_status: TaskStatus | None + + +@dataclass(frozen=True, slots=True) +class TaskStepTransitionResult: + task_step: TaskStepRecord + previous_status: TaskStepStatus | None + + +def _append_trace_events( + store: ContinuityStore, + *, + trace_id: UUID, + trace_events: list[tuple[str, dict[str, object]]], +) -> None: + for sequence_no, (kind, payload) in enumerate(trace_events, start=1): + store.append_trace_event( + trace_id=trace_id, + sequence_no=sequence_no, + kind=kind, + payload=payload, + ) + + +def _trace_summary( + trace_id: UUID, + trace_events: list[tuple[str, dict[str, object]]], +) -> TaskStepMutationTraceSummary: + return { + "trace_id": str(trace_id), + "trace_event_count": len(trace_events), + } + + +def validate_linked_task_step_for_approval( + store: ContinuityStore, + *, + approval_id: UUID, + task_step_id: UUID | None, +) -> tuple[TaskRow, TaskStepRow]: + if task_step_id is None: + raise TaskStepApprovalLinkageError(f"approval {approval_id} is missing linked task_step_id") + + unlocked_task = store.get_task_by_approval_optional(approval_id) + if unlocked_task is None: + raise TaskStepApprovalLinkageError(f"approval {approval_id} is not linked to a visible task") + store.lock_task_steps(cast(UUID, unlocked_task["id"])) + + task = store.get_task_optional(cast(UUID, unlocked_task["id"])) + if task is None: + raise ContinuityStoreInvariantError( + f"task for approval {approval_id} disappeared during approval linkage validation" + ) + + task_step = store.get_task_step_optional(task_step_id) + if task_step is None: + raise TaskStepApprovalLinkageError( + f"approval {approval_id} references linked task step {task_step_id} that was not found" + ) + if task_step["task_id"] != task["id"]: + raise TaskStepApprovalLinkageError( + f"approval {approval_id} links task step {task_step_id} outside task {task['id']}" + ) + + outcome = cast(TaskStepOutcomeSnapshot, task_step["outcome"]) + if outcome["approval_id"] != str(approval_id): + raise TaskStepApprovalLinkageError( + f"approval {approval_id} is inconsistent with linked task step {task_step_id}" + ) + + return task, task_step + + +def validate_linked_task_step_for_execution( + store: ContinuityStore, + *, + task_id: UUID, + execution: ToolExecutionRow, +) -> TaskStepRow: + store.lock_task_steps(task_id) + + execution_id = cast(UUID, execution["id"]) + task_step_id = cast(UUID | None, execution["task_step_id"]) + if task_step_id is None: + raise TaskStepExecutionLinkageError( + f"tool execution {execution_id} is missing linked task_step_id" + ) + + task_step = store.get_task_step_optional(task_step_id) + if task_step is None: + raise TaskStepExecutionLinkageError( + f"tool execution {execution_id} references linked task step {task_step_id} that was not found" + ) + if task_step["task_id"] != task_id: + raise TaskStepExecutionLinkageError( + f"tool execution {execution_id} links task step {task_step_id} outside task {task_id}" + ) + + outcome = cast(TaskStepOutcomeSnapshot, task_step["outcome"]) + if outcome["approval_id"] != str(execution["approval_id"]): + raise TaskStepExecutionLinkageError( + f"tool execution {execution_id} is inconsistent with linked task step {task_step_id}" + ) + + return task_step + + +def serialize_task_row(row: TaskRow) -> TaskRecord: + return { + "id": str(row["id"]), + "thread_id": str(row["thread_id"]), + "tool_id": str(row["tool_id"]), + "status": cast(TaskStatus, row["status"]), + "request": cast(dict[str, object], row["request"]), + "tool": cast(dict[str, object], row["tool"]), + "latest_approval_id": None if row["latest_approval_id"] is None else str(row["latest_approval_id"]), + "latest_execution_id": None if row["latest_execution_id"] is None else str(row["latest_execution_id"]), + "created_at": row["created_at"].isoformat(), + "updated_at": row["updated_at"].isoformat(), + } + + +def serialize_task_step_row(row: TaskStepRow) -> TaskStepRecord: + return { + "id": str(row["id"]), + "task_id": str(row["task_id"]), + "sequence_no": row["sequence_no"], + "lineage": { + "parent_step_id": None if row["parent_step_id"] is None else str(row["parent_step_id"]), + "source_approval_id": ( + None if row["source_approval_id"] is None else str(row["source_approval_id"]) + ), + "source_execution_id": ( + None if row["source_execution_id"] is None else str(row["source_execution_id"]) + ), + }, + "kind": cast(str, row["kind"]), + "status": cast(TaskStepStatus, row["status"]), + "request": cast(dict[str, object], row["request"]), + "outcome": cast(TaskStepOutcomeSnapshot, row["outcome"]), + "trace": { + "trace_id": str(row["trace_id"]), + "trace_kind": row["trace_kind"], + }, + "created_at": row["created_at"].isoformat(), + "updated_at": row["updated_at"].isoformat(), + } + + +def task_status_for_routing_decision(decision: str) -> TaskStatus: + return { + "approval_required": "pending_approval", + "ready": "approved", + "denied": "denied", + }[decision] + + +def task_status_for_approval_status(approval_status: str) -> TaskStatus: + return { + "pending": "pending_approval", + "approved": "approved", + "rejected": "denied", + }[approval_status] + + +def next_task_status_for_approval( + *, + current_status: TaskStatus, + approval_status: str, +) -> TaskStatus: + if current_status in {"executed", "blocked"}: + return current_status + return task_status_for_approval_status(approval_status) + + +def task_status_for_execution_status(execution_status: str) -> TaskStatus: + return { + "completed": "executed", + "blocked": "blocked", + }[execution_status] + + +def task_status_for_step_status(step_status: TaskStepStatus) -> TaskStatus: + return { + "created": "pending_approval", + "approved": "approved", + "executed": "executed", + "blocked": "blocked", + "denied": "denied", + }[step_status] + + +def task_step_status_for_routing_decision(decision: str) -> TaskStepStatus: + return { + "approval_required": "created", + "ready": "approved", + "denied": "denied", + }[decision] + + +def task_step_status_for_approval_status(approval_status: str) -> TaskStepStatus: + return { + "pending": "created", + "approved": "approved", + "rejected": "denied", + }[approval_status] + + +def next_task_step_status_for_approval( + *, + current_status: TaskStepStatus, + approval_status: str, +) -> TaskStepStatus: + if current_status in {"executed", "blocked"}: + return current_status + return task_step_status_for_approval_status(approval_status) + + +def task_step_status_for_execution_status(execution_status: str) -> TaskStepStatus: + return { + "completed": "executed", + "blocked": "blocked", + }[execution_status] + + +def allowed_task_step_transitions(current_status: TaskStepStatus) -> list[TaskStepStatus]: + return list(TASK_STEP_STATUS_GRAPH[current_status]) + + +def task_step_outcome_snapshot( + *, + routing_decision: str, + approval_id: str | None, + approval_status: str | None, + execution_id: str | None, + execution_status: str | None, + blocked_reason: str | None, +) -> TaskStepOutcomeSnapshot: + return { + "routing_decision": cast(str, routing_decision), + "approval_id": approval_id, + "approval_status": cast(str | None, approval_status), + "execution_id": execution_id, + "execution_status": cast(str | None, execution_status), + "blocked_reason": blocked_reason, + } + + +def create_task_for_governed_request( + store: ContinuityStore, + *, + request: TaskCreateInput, +) -> TaskCreateResponse: + task = store.create_task( + thread_id=request.thread_id, + tool_id=request.tool_id, + status=request.status, + request=cast(dict[str, object], request.request), + tool=cast(dict[str, object], request.tool), + latest_approval_id=request.latest_approval_id, + latest_execution_id=request.latest_execution_id, + ) + return {"task": serialize_task_row(task)} + + +def create_task_step_for_governed_request( + store: ContinuityStore, + *, + request: TaskStepCreateInput, +) -> TaskStepCreateResponse: + task_step = store.create_task_step( + task_id=request.task_id, + sequence_no=request.sequence_no, + kind=request.kind, + status=request.status, + request=cast(dict[str, object], request.request), + outcome=cast(dict[str, object], request.outcome), + trace_id=request.trace_id, + trace_kind=request.trace_kind, + ) + return {"task_step": serialize_task_step_row(task_step)} + + +def _task_step_sequencing_summary( + *, + task_id: str, + items: list[TaskStepRecord], +) -> TaskStepListSummary: + latest = items[-1] if items else None + latest_status = None if latest is None else latest["status"] + latest_sequence_no = None if latest is None else latest["sequence_no"] + return { + "task_id": task_id, + "total_count": len(items), + "latest_sequence_no": latest_sequence_no, + "latest_status": latest_status, + "next_sequence_no": 1 if latest_sequence_no is None else latest_sequence_no + 1, + "append_allowed": latest_status in TASK_STEP_APPENDABLE_STATUSES if latest_status is not None else False, + "order": list(TASK_STEP_LIST_ORDER), + } + + +def _validated_optional_approval_id( + store: ContinuityStore, + *, + approval_id: str | None, + current_approval_id: UUID | None, + task: TaskRow, + require_existing: bool, + missing_error: str, + error_cls: type[TaskStepSequenceError] | type[TaskStepTransitionError], +) -> UUID | None: + def _approval_belongs_to_task(approval_uuid: UUID) -> bool: + if current_approval_id == approval_uuid: + return True + for task_step in store.list_task_steps_for_task(task["id"]): + outcome = cast(dict[str, object], task_step["outcome"]) + linked_approval_id = outcome.get("approval_id") + if linked_approval_id is not None and str(linked_approval_id) == str(approval_uuid): + return True + return False + + if approval_id is None: + if require_existing and current_approval_id is None: + raise error_cls(missing_error) + approval_uuid = current_approval_id + else: + approval_uuid = UUID(approval_id) + if not _approval_belongs_to_task(approval_uuid): + raise error_cls(f"approval {approval_uuid} does not belong to task {task['id']}") + + if approval_uuid is None: + return None + + approval_row = store.get_approval_optional(approval_uuid) + if approval_row is None: + raise error_cls(f"approval {approval_uuid} was not found") + return approval_uuid + + +def _validated_optional_execution_id( + store: ContinuityStore, + *, + execution_id: str | None, + current_execution_id: UUID | None, + task: TaskRow, + require_existing: bool, + missing_error: str, + error_cls: type[TaskStepSequenceError] | type[TaskStepTransitionError], +) -> UUID | None: + def _execution_belongs_to_task(execution_uuid: UUID) -> bool: + if current_execution_id == execution_uuid: + return True + for task_step in store.list_task_steps_for_task(task["id"]): + outcome = cast(dict[str, object], task_step["outcome"]) + linked_execution_id = outcome.get("execution_id") + if linked_execution_id is not None and str(linked_execution_id) == str(execution_uuid): + return True + return False + + if execution_id is None: + if require_existing and current_execution_id is None: + raise error_cls(missing_error) + execution_uuid = current_execution_id + else: + execution_uuid = UUID(execution_id) + if not _execution_belongs_to_task(execution_uuid): + raise error_cls(f"tool execution {execution_uuid} does not belong to task {task['id']}") + + if execution_uuid is None: + return None + + execution_row = store.get_tool_execution_optional(execution_uuid) + if execution_row is None: + raise error_cls(f"tool execution {execution_uuid} was not found") + return execution_uuid + + +def _validated_continuation_parent_step( + *, + task_id: UUID, + latest: TaskStepRecord, + existing_items: list[TaskStepRecord], + parent_step_id: UUID, +) -> TaskStepRecord: + parent_step = next( + ( + item + for item in existing_items + if item["id"] == str(parent_step_id) + ), + None, + ) + if parent_step is None: + raise TaskStepSequenceError(f"task step {parent_step_id} does not belong to task {task_id}") + if parent_step["id"] != latest["id"]: + raise TaskStepSequenceError( + f"task {task_id} continuation must reference latest step {latest['id']}; received {parent_step_id}" + ) + return parent_step + + +def _validated_continuation_lineage( + *, + parent_step: TaskStepRecord, + source_approval_id: UUID | None, + source_execution_id: UUID | None, +) -> TaskStepLineageRecord: + parent_outcome = parent_step["outcome"] + if source_approval_id is not None and parent_outcome["approval_id"] != str(source_approval_id): + raise TaskStepSequenceError( + f"approval {source_approval_id} is not linked from parent step {parent_step['id']}" + ) + if source_execution_id is not None and parent_outcome["execution_id"] != str(source_execution_id): + raise TaskStepSequenceError( + f"tool execution {source_execution_id} is not linked from parent step {parent_step['id']}" + ) + + return { + "parent_step_id": parent_step["id"], + "source_approval_id": None if source_approval_id is None else str(source_approval_id), + "source_execution_id": None if source_execution_id is None else str(source_execution_id), + } + + +def sync_task_with_task_step_status( + store: ContinuityStore, + *, + task_id: UUID, + task_step_status: TaskStepStatus, + linked_approval_id: UUID | None, + linked_execution_id: UUID | None, +) -> TaskTransitionResult: + current = store.get_task_optional(task_id) + if current is None: + raise ContinuityStoreInvariantError( + f"task {task_id} disappeared before task-step lifecycle synchronization" + ) + previous_status = cast(TaskStatus, current["status"]) + target_status = task_status_for_step_status(task_step_status) + latest_execution_id = ( + current["latest_execution_id"] if linked_execution_id is None else linked_execution_id + ) if target_status in {"executed", "blocked"} else None + updated = store.update_task_status_optional( + task_id=task_id, + status=target_status, + latest_approval_id=linked_approval_id, + latest_execution_id=latest_execution_id, + ) + if updated is None: + raise ContinuityStoreInvariantError( + f"task {task_id} disappeared during task-step lifecycle synchronization" + ) + return TaskTransitionResult( + task=serialize_task_row(updated), + previous_status=previous_status, + ) + + +def sync_task_with_approval( + store: ContinuityStore, + *, + approval_id: UUID, + approval_status: str, +) -> TaskTransitionResult: + current = store.get_task_by_approval_optional(approval_id) + if current is None: + raise ContinuityStoreInvariantError( + f"task for approval {approval_id} disappeared before lifecycle synchronization" + ) + previous_status = cast(TaskStatus, current["status"]) + + updated = store.update_task_status_by_approval_optional( + approval_id=approval_id, + status=next_task_status_for_approval( + current_status=previous_status, + approval_status=approval_status, + ), + ) + if updated is None: + raise ContinuityStoreInvariantError( + f"task for approval {approval_id} disappeared during lifecycle synchronization" + ) + + return TaskTransitionResult( + task=serialize_task_row(updated), + previous_status=previous_status, + ) + + +def sync_task_step_with_approval( + store: ContinuityStore, + *, + approval_id: UUID, + task_step_id: UUID | None, + approval_status: str, + trace_id: UUID, + trace_kind: str, +) -> TaskStepTransitionResult: + _, current = validate_linked_task_step_for_approval( + store, + approval_id=approval_id, + task_step_id=task_step_id, + ) + previous_status = cast(TaskStepStatus, current["status"]) + current_outcome = cast(TaskStepOutcomeSnapshot, current["outcome"]) + updated_outcome = task_step_outcome_snapshot( + routing_decision=current_outcome["routing_decision"], + approval_id=str(approval_id), + approval_status=approval_status, + execution_id=current_outcome["execution_id"], + execution_status=current_outcome["execution_status"], + blocked_reason=current_outcome["blocked_reason"], + ) + + updated = store.update_task_step_optional( + task_step_id=cast(UUID, current["id"]), + status=next_task_step_status_for_approval( + current_status=previous_status, + approval_status=approval_status, + ), + outcome=cast(dict[str, object], updated_outcome), + trace_id=trace_id, + trace_kind=trace_kind, + ) + if updated is None: + raise ContinuityStoreInvariantError( + f"linked task step {current['id']} disappeared during approval lifecycle synchronization" + ) + + return TaskStepTransitionResult( + task_step=serialize_task_step_row(updated), + previous_status=previous_status, + ) + + +def sync_task_with_execution( + store: ContinuityStore, + *, + approval_id: UUID, + execution_id: UUID, + execution_status: str, +) -> TaskTransitionResult: + current = store.get_task_by_approval_optional(approval_id) + if current is None: + raise ContinuityStoreInvariantError( + f"task for approval {approval_id} disappeared before execution synchronization" + ) + previous_status = cast(TaskStatus, current["status"]) + + updated = store.update_task_execution_by_approval_optional( + approval_id=approval_id, + latest_execution_id=execution_id, + status=task_status_for_execution_status(execution_status), + ) + if updated is None: + raise ContinuityStoreInvariantError( + f"task for approval {approval_id} disappeared during execution synchronization" + ) + + return TaskTransitionResult( + task=serialize_task_row(updated), + previous_status=previous_status, + ) + + +def sync_task_step_with_execution( + store: ContinuityStore, + *, + task_id: UUID, + execution: ToolExecutionRow, + trace_id: UUID, + trace_kind: str, +) -> TaskStepTransitionResult: + current = validate_linked_task_step_for_execution( + store, + task_id=task_id, + execution=execution, + ) + previous_status = cast(TaskStepStatus, current["status"]) + current_outcome = cast(TaskStepOutcomeSnapshot, current["outcome"]) + execution_result = cast(dict[str, object], execution["result"]) + updated_outcome = task_step_outcome_snapshot( + routing_decision=current_outcome["routing_decision"], + approval_id=current_outcome["approval_id"], + approval_status=current_outcome["approval_status"], + execution_id=str(execution["id"]), + execution_status=cast(str, execution["status"]), + blocked_reason=cast(str | None, execution_result.get("reason")), + ) + + updated = store.update_task_step_optional( + task_step_id=cast(UUID, current["id"]), + status=task_step_status_for_execution_status(cast(str, execution["status"])), + outcome=cast(dict[str, object], updated_outcome), + trace_id=trace_id, + trace_kind=trace_kind, + ) + if updated is None: + raise ContinuityStoreInvariantError( + f"linked task step {current['id']} disappeared during execution lifecycle synchronization" + ) + + return TaskStepTransitionResult( + task_step=serialize_task_step_row(updated), + previous_status=previous_status, + ) + + +def create_next_task_step_record( + store: ContinuityStore, + *, + user_id: UUID, + request: TaskStepNextCreateInput, +) -> TaskStepNextCreateResponse: + del user_id + + task_row = store.get_task_optional(request.task_id) + if task_row is None: + raise TaskNotFoundError(f"task {request.task_id} was not found") + + store.lock_task_steps(request.task_id) + existing_items = [serialize_task_step_row(row) for row in store.list_task_steps_for_task(request.task_id)] + if not existing_items: + raise TaskStepSequenceError(f"task {request.task_id} has no existing steps and cannot append a next step") + + latest = existing_items[-1] + if latest["status"] not in TASK_STEP_APPENDABLE_STATUSES: + raise TaskStepSequenceError( + f"task {request.task_id} latest step {latest['id']} is {latest['status']} and cannot append a next step" + ) + if request.status not in TASK_STEP_INITIAL_STATUSES: + allowed = ", ".join(sorted(TASK_STEP_INITIAL_STATUSES)) + raise TaskStepSequenceError( + f"new task step for task {request.task_id} must start in one of {allowed}; received {request.status}" + ) + parent_step = _validated_continuation_parent_step( + task_id=request.task_id, + latest=latest, + existing_items=existing_items, + parent_step_id=request.lineage.parent_step_id, + ) + source_approval_id = _validated_optional_approval_id( + store, + approval_id=( + None if request.lineage.source_approval_id is None else str(request.lineage.source_approval_id) + ), + current_approval_id=None, + task=task_row, + require_existing=False, + missing_error="", + error_cls=TaskStepSequenceError, + ) + source_execution_id = _validated_optional_execution_id( + store, + execution_id=( + None if request.lineage.source_execution_id is None else str(request.lineage.source_execution_id) + ), + current_execution_id=None, + task=task_row, + require_existing=False, + missing_error="", + error_cls=TaskStepSequenceError, + ) + lineage = _validated_continuation_lineage( + parent_step=parent_step, + source_approval_id=source_approval_id, + source_execution_id=source_execution_id, + ) + linked_approval_id = _validated_optional_approval_id( + store, + approval_id=request.outcome["approval_id"], + current_approval_id=None, + task=task_row, + require_existing=False, + missing_error="", + error_cls=TaskStepSequenceError, + ) + linked_execution_id = _validated_optional_execution_id( + store, + execution_id=request.outcome["execution_id"], + current_execution_id=None, + task=task_row, + require_existing=False, + missing_error="", + error_cls=TaskStepSequenceError, + ) + + trace = store.create_trace( + user_id=task_row["user_id"], + thread_id=task_row["thread_id"], + kind=TRACE_KIND_TASK_STEP_CONTINUATION, + compiler_version=TASK_STEP_CONTINUATION_VERSION_V0, + status="completed", + limits={ + "order": list(TASK_STEP_LIST_ORDER), + "appendable_statuses": sorted(TASK_STEP_APPENDABLE_STATUSES), + "initial_statuses": sorted(TASK_STEP_INITIAL_STATUSES), + "parent_step_id": parent_step["id"], + "parent_sequence_no": parent_step["sequence_no"], + }, + ) + try: + created = store.create_task_step( + task_id=request.task_id, + sequence_no=latest["sequence_no"] + 1, + parent_step_id=request.lineage.parent_step_id, + source_approval_id=source_approval_id, + source_execution_id=source_execution_id, + kind=request.kind, + status=request.status, + request=cast(dict[str, object], request.request), + outcome=cast(dict[str, object], request.outcome), + trace_id=trace["id"], + trace_kind=TRACE_KIND_TASK_STEP_CONTINUATION, + ) + except psycopg.IntegrityError as exc: + raise TaskStepSequenceError( + f"task {request.task_id} next-step creation conflicted with a concurrent append" + ) from exc + task_step = serialize_task_step_row(created) + task_transition = sync_task_with_task_step_status( + store, + task_id=request.task_id, + task_step_status=request.status, + linked_approval_id=( + source_approval_id if request.status == "created" and linked_approval_id is None else linked_approval_id + ), + linked_execution_id=linked_execution_id, + ) + updated_items = [*existing_items, task_step] + sequencing = _task_step_sequencing_summary( + task_id=str(task_row["id"]), + items=updated_items, + ) + + request_payload: TaskStepContinuationRequestTracePayload = { + "task_id": str(task_row["id"]), + "parent_task_step_id": parent_step["id"], + "parent_sequence_no": parent_step["sequence_no"], + "parent_status": parent_step["status"], + "requested_kind": request.kind, + "requested_status": request.status, + "requested_source_approval_id": lineage["source_approval_id"], + "requested_source_execution_id": lineage["source_execution_id"], + } + lineage_payload: TaskStepContinuationLineageTracePayload = { + "task_id": str(task_row["id"]), + "parent_task_step_id": parent_step["id"], + "parent_sequence_no": parent_step["sequence_no"], + "parent_status": parent_step["status"], + "source_approval_id": lineage["source_approval_id"], + "source_execution_id": lineage["source_execution_id"], + } + summary_payload: TaskStepContinuationSummaryTracePayload = { + "task_id": str(task_row["id"]), + "task_step_id": task_step["id"], + "latest_sequence_no": task_step["sequence_no"], + "next_sequence_no": sequencing["next_sequence_no"], + "append_allowed": sequencing["append_allowed"], + "lineage": task_step["lineage"], + } + trace_events: list[tuple[str, dict[str, object]]] = [ + (TASK_STEP_CONTINUATION_REQUEST_EVENT_KIND, cast(dict[str, object], request_payload)), + (TASK_STEP_CONTINUATION_LINEAGE_EVENT_KIND, cast(dict[str, object], lineage_payload)), + (TASK_STEP_CONTINUATION_SUMMARY_EVENT_KIND, cast(dict[str, object], summary_payload)), + ] + trace_events.extend( + task_lifecycle_trace_events( + task=task_transition.task, + previous_status=task_transition.previous_status, + source="task_step_continuation", + ) + ) + trace_events.extend( + task_step_lifecycle_trace_events( + task_step=task_step, + previous_status=None, + source="task_step_continuation", + ) + ) + _append_trace_events(store, trace_id=trace["id"], trace_events=trace_events) + + return { + "task": task_transition.task, + "task_step": task_step, + "sequencing": sequencing, + "trace": _trace_summary(trace["id"], trace_events), + } + + +def transition_task_step_record( + store: ContinuityStore, + *, + user_id: UUID, + request: TaskStepTransitionInput, +) -> TaskStepTransitionResponse: + del user_id + + step_row = store.get_task_step_optional(request.task_step_id) + if step_row is None: + raise TaskStepNotFoundError(f"task step {request.task_step_id} was not found") + + task_row = store.get_task_optional(step_row["task_id"]) + if task_row is None: + raise ContinuityStoreInvariantError( + f"task {step_row['task_id']} disappeared before task-step transition" + ) + + existing_items = [serialize_task_step_row(row) for row in store.list_task_steps_for_task(step_row["task_id"])] + latest = existing_items[-1] if existing_items else None + if latest is None: + raise ContinuityStoreInvariantError( + f"task {step_row['task_id']} has no visible steps during transition" + ) + if latest["id"] != str(step_row["id"]): + raise TaskStepTransitionError( + f"task step {request.task_step_id} is not the latest step on task {step_row['task_id']}" + ) + + previous_status = cast(TaskStepStatus, step_row["status"]) + allowed_next_statuses = allowed_task_step_transitions(previous_status) + if request.status not in allowed_next_statuses: + allowed = ", ".join(allowed_next_statuses) or "no further statuses" + raise TaskStepTransitionError( + f"task step {request.task_step_id} is {previous_status} and cannot transition to {request.status}; allowed: {allowed}" + ) + linked_approval_id = _validated_optional_approval_id( + store, + approval_id=request.outcome["approval_id"], + current_approval_id=task_row["latest_approval_id"], + task=task_row, + require_existing=request.status == "created", + missing_error=f"task {task_row['id']} cannot reflect created without an approval link", + error_cls=TaskStepTransitionError, + ) + linked_execution_id = _validated_optional_execution_id( + store, + execution_id=request.outcome["execution_id"], + current_execution_id=task_row["latest_execution_id"], + task=task_row, + require_existing=request.status in {"executed", "blocked"}, + missing_error=f"task {task_row['id']} cannot reflect {request.status} without an existing execution link", + error_cls=TaskStepTransitionError, + ) + + trace = store.create_trace( + user_id=task_row["user_id"], + thread_id=task_row["thread_id"], + kind=TRACE_KIND_TASK_STEP_TRANSITION, + compiler_version=TASK_STEP_TRANSITION_VERSION_V0, + status="completed", + limits={ + "order": list(TASK_STEP_LIST_ORDER), + "status_graph": {status: list(next_statuses) for status, next_statuses in TASK_STEP_STATUS_GRAPH.items()}, + "requested_status": request.status, + }, + ) + updated_row = store.update_task_step_for_task_sequence_optional( + task_id=step_row["task_id"], + sequence_no=step_row["sequence_no"], + status=request.status, + outcome=cast(dict[str, object], request.outcome), + trace_id=trace["id"], + trace_kind=TRACE_KIND_TASK_STEP_TRANSITION, + ) + if updated_row is None: + raise ContinuityStoreInvariantError( + f"task step {request.task_step_id} disappeared during transition" + ) + + updated_step = serialize_task_step_row(updated_row) + task_transition = sync_task_with_task_step_status( + store, + task_id=step_row["task_id"], + task_step_status=request.status, + linked_approval_id=linked_approval_id, + linked_execution_id=linked_execution_id, + ) + updated_items = [*existing_items[:-1], updated_step] + sequencing = _task_step_sequencing_summary( + task_id=str(task_row["id"]), + items=updated_items, + ) + + request_payload: TaskStepTransitionRequestTracePayload = { + "task_id": str(task_row["id"]), + "task_step_id": updated_step["id"], + "sequence_no": updated_step["sequence_no"], + "previous_status": previous_status, + "requested_status": request.status, + } + state_payload: TaskStepTransitionStateTracePayload = { + "task_id": str(task_row["id"]), + "task_step_id": updated_step["id"], + "sequence_no": updated_step["sequence_no"], + "previous_status": previous_status, + "current_status": updated_step["status"], + "allowed_next_statuses": allowed_next_statuses, + "trace": updated_step["trace"], + } + summary_payload: TaskStepTransitionSummaryTracePayload = { + "task_id": str(task_row["id"]), + "task_step_id": updated_step["id"], + "sequence_no": updated_step["sequence_no"], + "final_status": updated_step["status"], + "parent_task_status": task_transition.task["status"], + "trace": updated_step["trace"], + } + trace_events: list[tuple[str, dict[str, object]]] = [ + (TASK_STEP_TRANSITION_REQUEST_EVENT_KIND, cast(dict[str, object], request_payload)), + (TASK_STEP_TRANSITION_STATE_EVENT_KIND, cast(dict[str, object], state_payload)), + (TASK_STEP_TRANSITION_SUMMARY_EVENT_KIND, cast(dict[str, object], summary_payload)), + ] + trace_events.extend( + task_lifecycle_trace_events( + task=task_transition.task, + previous_status=task_transition.previous_status, + source="task_step_transition", + ) + ) + trace_events.extend( + task_step_lifecycle_trace_events( + task_step=updated_step, + previous_status=previous_status, + source="task_step_transition", + ) + ) + _append_trace_events(store, trace_id=trace["id"], trace_events=trace_events) + + return { + "task": task_transition.task, + "task_step": updated_step, + "sequencing": sequencing, + "trace": _trace_summary(trace["id"], trace_events), + } + + +def task_lifecycle_trace_events( + *, + task: TaskRecord, + previous_status: TaskStatus | None, + source: TaskLifecycleSource, +) -> list[tuple[str, dict[str, object]]]: + state_payload: TaskLifecycleStateTracePayload = { + "task_id": task["id"], + "source": source, + "previous_status": previous_status, + "current_status": task["status"], + "latest_approval_id": task["latest_approval_id"], + "latest_execution_id": task["latest_execution_id"], + } + summary_payload: TaskLifecycleSummaryTracePayload = { + "task_id": task["id"], + "source": source, + "final_status": task["status"], + "latest_approval_id": task["latest_approval_id"], + "latest_execution_id": task["latest_execution_id"], + } + return [ + (TASK_LIFECYCLE_STATE_EVENT_KIND, cast(dict[str, object], state_payload)), + (TASK_LIFECYCLE_SUMMARY_EVENT_KIND, cast(dict[str, object], summary_payload)), + ] + + +def task_step_lifecycle_trace_events( + *, + task_step: TaskStepRecord, + previous_status: TaskStepStatus | None, + source: TaskLifecycleSource, +) -> list[tuple[str, dict[str, object]]]: + state_payload: TaskStepLifecycleStateTracePayload = { + "task_id": task_step["task_id"], + "task_step_id": task_step["id"], + "source": source, + "sequence_no": task_step["sequence_no"], + "kind": task_step["kind"], + "previous_status": previous_status, + "current_status": task_step["status"], + "trace": task_step["trace"], + } + summary_payload: TaskStepLifecycleSummaryTracePayload = { + "task_id": task_step["task_id"], + "task_step_id": task_step["id"], + "source": source, + "sequence_no": task_step["sequence_no"], + "kind": task_step["kind"], + "final_status": task_step["status"], + "trace": task_step["trace"], + } + return [ + (TASK_STEP_LIFECYCLE_STATE_EVENT_KIND, cast(dict[str, object], state_payload)), + (TASK_STEP_LIFECYCLE_SUMMARY_EVENT_KIND, cast(dict[str, object], summary_payload)), + ] + + +def list_task_records( + store: ContinuityStore, + *, + user_id: UUID, +) -> TaskListResponse: + del user_id + + items = [serialize_task_row(row) for row in store.list_tasks()] + summary: TaskListSummary = { + "total_count": len(items), + "order": list(TASK_LIST_ORDER), + } + return { + "items": items, + "summary": summary, + } + + +def get_task_record( + store: ContinuityStore, + *, + user_id: UUID, + task_id: UUID, +) -> TaskDetailResponse: + del user_id + + task = store.get_task_optional(task_id) + if task is None: + raise TaskNotFoundError(f"task {task_id} was not found") + return {"task": serialize_task_row(task)} + + +def list_task_step_records( + store: ContinuityStore, + *, + user_id: UUID, + task_id: UUID, +) -> TaskStepListResponse: + del user_id + + task = store.get_task_optional(task_id) + if task is None: + raise TaskNotFoundError(f"task {task_id} was not found") + + items = [serialize_task_step_row(row) for row in store.list_task_steps_for_task(task_id)] + summary = _task_step_sequencing_summary(task_id=str(task["id"]), items=items) + return { + "items": items, + "summary": summary, + } + + +def get_task_step_record( + store: ContinuityStore, + *, + user_id: UUID, + task_step_id: UUID, +) -> TaskStepDetailResponse: + del user_id + + task_step = store.get_task_step_optional(task_step_id) + if task_step is None: + raise TaskStepNotFoundError(f"task step {task_step_id} was not found") + return {"task_step": serialize_task_step_row(task_step)} diff --git a/apps/api/src/alicebot_api/telegram_channels.py b/apps/api/src/alicebot_api/telegram_channels.py new file mode 100644 index 0000000..7cb1c2a --- /dev/null +++ b/apps/api/src/alicebot_api/telegram_channels.py @@ -0,0 +1,1714 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +import hashlib +import re +import secrets +import string +from typing import Any, TypedDict +from uuid import UUID + +from psycopg.types.json import Jsonb + +from alicebot_api.hosted_auth import generate_token, hash_token, utc_now + + +TELEGRAM_CHANNEL_TYPE = "telegram" + +_LINK_PATTERN = re.compile(r"^/link(?:@(?P[A-Za-z0-9_]+))?\s+(?P[A-Za-z0-9]{6,32})$") +_START_PATTERN = re.compile(r"^/start\s+(?P[A-Za-z0-9]{6,32})$") + + +class TelegramLinkTokenInvalidError(ValueError): + """Raised when a Telegram link token is invalid or already consumed.""" + + +class TelegramLinkTokenExpiredError(ValueError): + """Raised when a Telegram link token has expired.""" + + +class TelegramLinkPendingError(RuntimeError): + """Raised when link confirmation has not yet been observed via webhook.""" + + +class TelegramIdentityConflictError(RuntimeError): + """Raised when a Telegram chat is already linked to a different workspace.""" + + +class TelegramIdentityNotFoundError(LookupError): + """Raised when a linked Telegram identity is not visible for the workspace.""" + + +class TelegramMessageNotFoundError(LookupError): + """Raised when a Telegram message is not visible for dispatch.""" + + +class TelegramRoutingError(RuntimeError): + """Raised when Telegram routing cannot be resolved deterministically.""" + + +class TelegramWebhookValidationError(ValueError): + """Raised when incoming webhook payload is missing required Telegram fields.""" + + +class TelegramChannelIdentityRow(TypedDict): + id: UUID + user_account_id: UUID + workspace_id: UUID + channel_type: str + external_user_id: str + external_chat_id: str + external_username: str | None + status: str + linked_at: datetime + unlinked_at: datetime | None + created_at: datetime + updated_at: datetime + + +class TelegramChannelLinkChallengeRow(TypedDict): + id: UUID + user_account_id: UUID + workspace_id: UUID + channel_type: str + challenge_token_hash: str + link_code: str + status: str + expires_at: datetime + confirmed_at: datetime | None + channel_identity_id: UUID | None + created_at: datetime + + +class IssuedTelegramChannelLinkChallengeRow(TelegramChannelLinkChallengeRow): + challenge_token: str + + +class TelegramChannelThreadRow(TypedDict): + id: UUID + workspace_id: UUID + channel_type: str + external_thread_key: str + channel_identity_id: UUID | None + last_message_at: datetime | None + created_at: datetime + updated_at: datetime + + +class TelegramChannelMessageRow(TypedDict): + id: UUID + workspace_id: UUID | None + channel_thread_id: UUID | None + channel_identity_id: UUID | None + channel_type: str + direction: str + provider_update_id: str | None + provider_message_id: str | None + external_chat_id: str | None + external_user_id: str | None + message_text: str | None + normalized_payload: dict[str, Any] + route_status: str + idempotency_key: str + created_at: datetime + received_at: datetime + + +class TelegramDeliveryReceiptRow(TypedDict): + id: UUID + workspace_id: UUID + channel_message_id: UUID + channel_type: str + status: str + provider_receipt_id: str | None + failure_code: str | None + failure_detail: str | None + scheduled_job_id: UUID | None + scheduler_job_kind: str | None + scheduled_for: datetime | None + schedule_slot: str | None + notification_policy: dict[str, Any] + rollout_flag_state: str + support_evidence: dict[str, Any] + rate_limit_evidence: dict[str, Any] + incident_evidence: dict[str, Any] + recorded_at: datetime + created_at: datetime + + +class NormalizedTelegramInboundMessage(TypedDict): + provider_update_id: str + provider_message_id: str + external_chat_id: str + external_user_id: str + external_username: str | None + message_text: str + sent_at: datetime + link_code: str | None + idempotency_key: str + normalized_payload: dict[str, Any] + + +class TelegramWebhookIngestResult(TypedDict): + duplicate: bool + route_status: str + link_status: str | None + unknown_chat_routing: bool + message: TelegramChannelMessageRow + thread: TelegramChannelThreadRow | None + + +def build_inbound_idempotency_key(*, update_id: int) -> str: + payload = f"telegram:update:{update_id}" + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +def resolve_telegram_thread_key(*, external_chat_id: str) -> str: + return f"telegram-chat:{external_chat_id}" + + +def extract_telegram_link_code( + text: str, + *, + bot_username: str | None, +) -> str | None: + normalized_text = text.strip() + if normalized_text == "": + return None + + for pattern in (_LINK_PATTERN, _START_PATTERN): + match = pattern.match(normalized_text) + if match is None: + continue + mention = match.groupdict().get("mention") + if mention is not None and bot_username and mention.lower() != bot_username.lower(): + return None + code = match.group("code").strip().upper() + if code == "": + return None + return code + + return None + + +def normalize_telegram_update( + payload: dict[str, Any], + *, + bot_username: str | None, +) -> NormalizedTelegramInboundMessage: + raw_update_id = payload.get("update_id") + if not isinstance(raw_update_id, int): + raise TelegramWebhookValidationError("telegram webhook payload requires integer update_id") + + raw_message = payload.get("message") + if not isinstance(raw_message, dict): + raise TelegramWebhookValidationError("telegram webhook payload requires message object") + + raw_chat = raw_message.get("chat") + if not isinstance(raw_chat, dict) or "id" not in raw_chat: + raise TelegramWebhookValidationError("telegram webhook message requires chat.id") + + raw_from = raw_message.get("from") + if not isinstance(raw_from, dict) or "id" not in raw_from: + raise TelegramWebhookValidationError("telegram webhook message requires from.id") + + raw_message_id = raw_message.get("message_id") + if not isinstance(raw_message_id, int): + raise TelegramWebhookValidationError("telegram webhook message requires integer message_id") + + text = raw_message.get("text") + normalized_text = text.strip() if isinstance(text, str) else "" + + sent_at: datetime + raw_date = raw_message.get("date") + if isinstance(raw_date, int): + sent_at = datetime.fromtimestamp(raw_date, tz=timezone.utc) + else: + sent_at = utc_now() + + external_chat_id = str(raw_chat["id"]) + external_user_id = str(raw_from["id"]) + username = raw_from.get("username") + external_username = username.strip() if isinstance(username, str) and username.strip() else None + + link_code = extract_telegram_link_code(normalized_text, bot_username=bot_username) + + normalized_payload = { + "update_id": raw_update_id, + "message_id": raw_message_id, + "chat": { + "id": external_chat_id, + "type": raw_chat.get("type"), + }, + "from": { + "id": external_user_id, + "username": external_username, + }, + "text": normalized_text, + "received_kind": "telegram_webhook", + "link_code": link_code, + } + + return { + "provider_update_id": str(raw_update_id), + "provider_message_id": str(raw_message_id), + "external_chat_id": external_chat_id, + "external_user_id": external_user_id, + "external_username": external_username, + "message_text": normalized_text, + "sent_at": sent_at, + "link_code": link_code, + "idempotency_key": build_inbound_idempotency_key(update_id=raw_update_id), + "normalized_payload": normalized_payload, + } + + +def _generate_link_code(length: int = 8) -> str: + alphabet = string.ascii_uppercase + string.digits + return "".join(secrets.choice(alphabet) for _ in range(length)) + + +def start_telegram_link_challenge( + conn, + *, + user_account_id: UUID, + workspace_id: UUID, + ttl_seconds: int, +) -> IssuedTelegramChannelLinkChallengeRow: + now = utc_now() + + with conn.cursor() as cur: + cur.execute( + """ + UPDATE channel_link_challenges + SET status = 'expired' + WHERE user_account_id = %s + AND workspace_id = %s + AND channel_type = %s + AND status = 'pending' + AND expires_at > %s + """, + (user_account_id, workspace_id, TELEGRAM_CHANNEL_TYPE, now), + ) + + challenge_token = generate_token() + challenge_token_hash = hash_token(challenge_token) + link_code = _generate_link_code() + expires_at = now + timedelta(seconds=ttl_seconds) + + cur.execute( + """ + INSERT INTO channel_link_challenges ( + user_account_id, + workspace_id, + channel_type, + challenge_token_hash, + link_code, + status, + expires_at + ) + VALUES (%s, %s, %s, %s, %s, 'pending', %s) + RETURNING id, user_account_id, workspace_id, channel_type, challenge_token_hash, + link_code, status, expires_at, confirmed_at, channel_identity_id, created_at + """, + ( + user_account_id, + workspace_id, + TELEGRAM_CHANNEL_TYPE, + challenge_token_hash, + link_code, + expires_at, + ), + ) + challenge = cur.fetchone() + + if challenge is None: + raise RuntimeError("failed to create telegram link challenge") + + challenge["challenge_token"] = challenge_token + return challenge + + +def _lookup_link_challenge_for_update( + conn, + *, + user_account_id: UUID, + challenge_token: str, +) -> TelegramChannelLinkChallengeRow | None: + token = challenge_token.strip() + if token == "": + return None + + with conn.cursor() as cur: + cur.execute( + """ + SELECT id, user_account_id, workspace_id, channel_type, challenge_token_hash, link_code, + status, expires_at, confirmed_at, channel_identity_id, created_at + FROM channel_link_challenges + WHERE user_account_id = %s + AND channel_type = %s + AND challenge_token_hash = %s + FOR UPDATE + """, + (user_account_id, TELEGRAM_CHANNEL_TYPE, hash_token(token)), + ) + return cur.fetchone() + + +def _fetch_channel_identity_by_id(conn, *, channel_identity_id: UUID) -> TelegramChannelIdentityRow | None: + with conn.cursor() as cur: + cur.execute( + """ + SELECT id, user_account_id, workspace_id, channel_type, external_user_id, external_chat_id, + external_username, status, linked_at, unlinked_at, created_at, updated_at + FROM channel_identities + WHERE id = %s + """, + (channel_identity_id,), + ) + return cur.fetchone() + + +def confirm_telegram_link_challenge( + conn, + *, + user_account_id: UUID, + challenge_token: str, +) -> tuple[TelegramChannelLinkChallengeRow, TelegramChannelIdentityRow]: + now = utc_now() + challenge = _lookup_link_challenge_for_update( + conn, + user_account_id=user_account_id, + challenge_token=challenge_token, + ) + if challenge is None: + raise TelegramLinkTokenInvalidError("telegram link token is invalid") + + if challenge["status"] == "confirmed" and challenge["channel_identity_id"] is not None: + identity = _fetch_channel_identity_by_id(conn, channel_identity_id=challenge["channel_identity_id"]) + if identity is not None: + return challenge, identity + + if challenge["status"] != "pending": + raise TelegramLinkTokenInvalidError("telegram link token is no longer valid") + + if challenge["expires_at"] <= now: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE channel_link_challenges + SET status = 'expired' + WHERE id = %s + """, + (challenge["id"],), + ) + raise TelegramLinkTokenExpiredError("telegram link token has expired") + + if challenge["channel_identity_id"] is None: + raise TelegramLinkPendingError("telegram link is pending webhook confirmation") + + with conn.cursor() as cur: + cur.execute( + """ + UPDATE channel_link_challenges + SET status = 'confirmed', + confirmed_at = COALESCE(confirmed_at, %s) + WHERE id = %s + RETURNING id, user_account_id, workspace_id, channel_type, challenge_token_hash, + link_code, status, expires_at, confirmed_at, channel_identity_id, created_at + """, + (now, challenge["id"]), + ) + updated = cur.fetchone() + + if updated is None or updated["channel_identity_id"] is None: + raise TelegramLinkPendingError("telegram link is pending webhook confirmation") + + identity = _fetch_channel_identity_by_id(conn, channel_identity_id=updated["channel_identity_id"]) + if identity is None: + raise TelegramLinkPendingError("telegram link is pending webhook confirmation") + + return updated, identity + + +def _upsert_linked_identity( + conn, + *, + user_account_id: UUID, + workspace_id: UUID, + external_chat_id: str, + external_user_id: str, + external_username: str | None, +) -> TelegramChannelIdentityRow: + now = utc_now() + + with conn.cursor() as cur: + cur.execute( + """ + SELECT id, user_account_id, workspace_id, channel_type, external_user_id, external_chat_id, + external_username, status, linked_at, unlinked_at, created_at, updated_at + FROM channel_identities + WHERE channel_type = %s + AND external_chat_id = %s + AND status = 'linked' + ORDER BY updated_at DESC, created_at DESC, id DESC + LIMIT 1 + FOR UPDATE + """, + (TELEGRAM_CHANNEL_TYPE, external_chat_id), + ) + linked = cur.fetchone() + + if linked is not None: + if linked["workspace_id"] != workspace_id: + raise TelegramIdentityConflictError( + "telegram chat is already linked to another workspace" + ) + + cur.execute( + """ + UPDATE channel_identities + SET user_account_id = %s, + external_user_id = %s, + external_username = %s, + updated_at = %s + WHERE id = %s + RETURNING id, user_account_id, workspace_id, channel_type, external_user_id, + external_chat_id, external_username, status, linked_at, unlinked_at, + created_at, updated_at + """, + ( + user_account_id, + external_user_id, + external_username, + now, + linked["id"], + ), + ) + refreshed = cur.fetchone() + if refreshed is None: + raise RuntimeError("failed to refresh linked telegram identity") + return refreshed + + cur.execute( + """ + SELECT id, user_account_id, workspace_id, channel_type, external_user_id, external_chat_id, + external_username, status, linked_at, unlinked_at, created_at, updated_at + FROM channel_identities + WHERE workspace_id = %s + AND channel_type = %s + AND external_chat_id = %s + ORDER BY created_at DESC, id DESC + LIMIT 1 + FOR UPDATE + """, + (workspace_id, TELEGRAM_CHANNEL_TYPE, external_chat_id), + ) + prior = cur.fetchone() + + if prior is not None: + cur.execute( + """ + UPDATE channel_identities + SET user_account_id = %s, + external_user_id = %s, + external_username = %s, + status = 'linked', + linked_at = %s, + unlinked_at = NULL, + updated_at = %s + WHERE id = %s + RETURNING id, user_account_id, workspace_id, channel_type, external_user_id, + external_chat_id, external_username, status, linked_at, unlinked_at, + created_at, updated_at + """, + ( + user_account_id, + external_user_id, + external_username, + now, + now, + prior["id"], + ), + ) + relinked = cur.fetchone() + if relinked is None: + raise RuntimeError("failed to relink telegram identity") + return relinked + + cur.execute( + """ + INSERT INTO channel_identities ( + user_account_id, + workspace_id, + channel_type, + external_user_id, + external_chat_id, + external_username, + status, + linked_at, + updated_at + ) + VALUES (%s, %s, %s, %s, %s, %s, 'linked', %s, %s) + RETURNING id, user_account_id, workspace_id, channel_type, external_user_id, + external_chat_id, external_username, status, linked_at, unlinked_at, + created_at, updated_at + """, + ( + user_account_id, + workspace_id, + TELEGRAM_CHANNEL_TYPE, + external_user_id, + external_chat_id, + external_username, + now, + now, + ), + ) + inserted = cur.fetchone() + + if inserted is None: + raise RuntimeError("failed to insert linked telegram identity") + return inserted + + +def _apply_link_code_if_present( + conn, + *, + normalized: NormalizedTelegramInboundMessage, +) -> tuple[str | None, TelegramChannelIdentityRow | None]: + link_code = normalized["link_code"] + if link_code is None: + return None, None + + now = utc_now() + with conn.cursor() as cur: + cur.execute( + """ + SELECT id, user_account_id, workspace_id, channel_type, challenge_token_hash, link_code, + status, expires_at, confirmed_at, channel_identity_id, created_at + FROM channel_link_challenges + WHERE channel_type = %s + AND link_code = %s + ORDER BY created_at DESC, id DESC + LIMIT 1 + FOR UPDATE + """, + (TELEGRAM_CHANNEL_TYPE, link_code), + ) + challenge = cur.fetchone() + + if challenge is None: + return "invalid_link_code", None + + if challenge["status"] != "pending": + if challenge["status"] == "confirmed" and challenge["channel_identity_id"] is not None: + identity = _fetch_channel_identity_by_id( + conn, + channel_identity_id=challenge["channel_identity_id"], + ) + if ( + identity is not None + and identity["status"] == "linked" + and identity["external_chat_id"] == normalized["external_chat_id"] + ): + return "already_confirmed", identity + return "invalid_link_code", None + return "invalid_link_code", None + + if challenge["expires_at"] <= now: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE channel_link_challenges + SET status = 'expired' + WHERE id = %s + """, + (challenge["id"],), + ) + return "expired_link_code", None + + try: + identity = _upsert_linked_identity( + conn, + user_account_id=challenge["user_account_id"], + workspace_id=challenge["workspace_id"], + external_chat_id=normalized["external_chat_id"], + external_user_id=normalized["external_user_id"], + external_username=normalized["external_username"], + ) + except TelegramIdentityConflictError: + return "identity_conflict", None + + with conn.cursor() as cur: + cur.execute( + """ + UPDATE channel_link_challenges + SET status = 'confirmed', + confirmed_at = %s, + channel_identity_id = %s + WHERE id = %s + """, + (now, identity["id"], challenge["id"]), + ) + + return "confirmed", identity + + +def _resolve_workspace_and_identity_for_inbound( + conn, + *, + normalized: NormalizedTelegramInboundMessage, + linked_identity: TelegramChannelIdentityRow | None, +) -> tuple[UUID | None, UUID | None]: + if linked_identity is not None: + return linked_identity["workspace_id"], linked_identity["id"] + + with conn.cursor() as cur: + cur.execute( + """ + SELECT id, workspace_id + FROM channel_identities + WHERE channel_type = %s + AND external_chat_id = %s + AND status = 'linked' + ORDER BY updated_at DESC, created_at DESC, id DESC + LIMIT 1 + """, + (TELEGRAM_CHANNEL_TYPE, normalized["external_chat_id"]), + ) + row = cur.fetchone() + + if row is None: + return None, None + return row["workspace_id"], row["id"] + + +def _ensure_channel_thread( + conn, + *, + workspace_id: UUID, + channel_identity_id: UUID | None, + external_chat_id: str, + sent_at: datetime, +) -> TelegramChannelThreadRow: + external_thread_key = resolve_telegram_thread_key(external_chat_id=external_chat_id) + + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO channel_threads ( + workspace_id, + channel_type, + external_thread_key, + channel_identity_id, + last_message_at, + updated_at + ) + VALUES (%s, %s, %s, %s, %s, %s) + ON CONFLICT (workspace_id, channel_type, external_thread_key) DO UPDATE + SET channel_identity_id = COALESCE(EXCLUDED.channel_identity_id, channel_threads.channel_identity_id), + last_message_at = EXCLUDED.last_message_at, + updated_at = EXCLUDED.updated_at + RETURNING id, workspace_id, channel_type, external_thread_key, + channel_identity_id, last_message_at, created_at, updated_at + """, + ( + workspace_id, + TELEGRAM_CHANNEL_TYPE, + external_thread_key, + channel_identity_id, + sent_at, + utc_now(), + ), + ) + row = cur.fetchone() + + if row is None: + raise RuntimeError("failed to resolve telegram thread") + return row + + +def _insert_or_get_channel_message( + conn, + *, + workspace_id: UUID | None, + channel_thread_id: UUID | None, + channel_identity_id: UUID | None, + normalized: NormalizedTelegramInboundMessage, + route_status: str, +) -> tuple[TelegramChannelMessageRow, bool]: + now = utc_now() + + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO channel_messages ( + workspace_id, + channel_thread_id, + channel_identity_id, + channel_type, + direction, + provider_update_id, + provider_message_id, + external_chat_id, + external_user_id, + message_text, + normalized_payload, + route_status, + idempotency_key, + received_at + ) + VALUES (%s, %s, %s, %s, 'inbound', %s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (channel_type, direction, idempotency_key) DO NOTHING + RETURNING id, workspace_id, channel_thread_id, channel_identity_id, + channel_type, direction, provider_update_id, provider_message_id, + external_chat_id, external_user_id, message_text, + normalized_payload, route_status, idempotency_key, created_at, received_at + """, + ( + workspace_id, + channel_thread_id, + channel_identity_id, + TELEGRAM_CHANNEL_TYPE, + normalized["provider_update_id"], + normalized["provider_message_id"], + normalized["external_chat_id"], + normalized["external_user_id"], + normalized["message_text"], + Jsonb(normalized["normalized_payload"]), + route_status, + normalized["idempotency_key"], + now, + ), + ) + inserted = cur.fetchone() + + if inserted is not None: + return inserted, False + + cur.execute( + """ + SELECT id, workspace_id, channel_thread_id, channel_identity_id, channel_type, direction, + provider_update_id, provider_message_id, external_chat_id, external_user_id, + message_text, normalized_payload, route_status, idempotency_key, + created_at, received_at + FROM channel_messages + WHERE channel_type = %s + AND direction = 'inbound' + AND idempotency_key = %s + LIMIT 1 + """, + (TELEGRAM_CHANNEL_TYPE, normalized["idempotency_key"]), + ) + existing = cur.fetchone() + + if existing is None: + raise RuntimeError("failed to resolve idempotent telegram channel message") + return existing, True + + +def ingest_telegram_webhook( + conn, + *, + payload: dict[str, Any], + bot_username: str | None, +) -> TelegramWebhookIngestResult: + normalized = normalize_telegram_update(payload, bot_username=bot_username) + + link_status, linked_identity = _apply_link_code_if_present(conn, normalized=normalized) + workspace_id, channel_identity_id = _resolve_workspace_and_identity_for_inbound( + conn, + normalized=normalized, + linked_identity=linked_identity, + ) + + route_status = "resolved" if workspace_id is not None else "unresolved" + thread: TelegramChannelThreadRow | None = None + + if workspace_id is not None: + thread = _ensure_channel_thread( + conn, + workspace_id=workspace_id, + channel_identity_id=channel_identity_id, + external_chat_id=normalized["external_chat_id"], + sent_at=normalized["sent_at"], + ) + + message, duplicate = _insert_or_get_channel_message( + conn, + workspace_id=workspace_id, + channel_thread_id=None if thread is None else thread["id"], + channel_identity_id=channel_identity_id, + normalized=normalized, + route_status=route_status, + ) + + if not duplicate and workspace_id is not None: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO chat_intents ( + workspace_id, + channel_message_id, + channel_thread_id, + intent_kind, + status + ) + VALUES (%s, %s, %s, 'inbound_message', 'recorded') + ON CONFLICT (channel_message_id, intent_kind) DO NOTHING + """, + ( + workspace_id, + message["id"], + None if thread is None else thread["id"], + ), + ) + + return { + "duplicate": duplicate, + "route_status": route_status, + "link_status": link_status, + "unknown_chat_routing": workspace_id is None, + "message": message, + "thread": thread, + } + + +def list_workspace_telegram_messages( + conn, + *, + user_account_id: UUID, + workspace_id: UUID, + limit: int, +) -> list[TelegramChannelMessageRow]: + with conn.cursor() as cur: + cur.execute( + """ + SELECT m.id, + m.workspace_id, + m.channel_thread_id, + m.channel_identity_id, + m.channel_type, + m.direction, + m.provider_update_id, + m.provider_message_id, + m.external_chat_id, + m.external_user_id, + m.message_text, + m.normalized_payload, + m.route_status, + m.idempotency_key, + m.created_at, + m.received_at + FROM channel_messages AS m + JOIN workspace_members AS wm + ON wm.workspace_id = m.workspace_id + WHERE m.channel_type = %s + AND m.workspace_id = %s + AND wm.user_account_id = %s + ORDER BY m.created_at DESC, m.id DESC + LIMIT %s + """, + (TELEGRAM_CHANNEL_TYPE, workspace_id, user_account_id, limit), + ) + rows = cur.fetchall() + + return rows + + +def list_workspace_telegram_threads( + conn, + *, + user_account_id: UUID, + workspace_id: UUID, + limit: int, +) -> list[TelegramChannelThreadRow]: + with conn.cursor() as cur: + cur.execute( + """ + SELECT t.id, + t.workspace_id, + t.channel_type, + t.external_thread_key, + t.channel_identity_id, + t.last_message_at, + t.created_at, + t.updated_at + FROM channel_threads AS t + JOIN workspace_members AS wm + ON wm.workspace_id = t.workspace_id + WHERE t.channel_type = %s + AND t.workspace_id = %s + AND wm.user_account_id = %s + ORDER BY COALESCE(t.last_message_at, t.created_at) DESC, t.id DESC + LIMIT %s + """, + (TELEGRAM_CHANNEL_TYPE, workspace_id, user_account_id, limit), + ) + rows = cur.fetchall() + + return rows + + +def _get_latest_linked_identity( + conn, + *, + user_account_id: UUID, + workspace_id: UUID, +) -> TelegramChannelIdentityRow | None: + with conn.cursor() as cur: + cur.execute( + """ + SELECT id, user_account_id, workspace_id, channel_type, external_user_id, external_chat_id, + external_username, status, linked_at, unlinked_at, created_at, updated_at + FROM channel_identities + WHERE user_account_id = %s + AND workspace_id = %s + AND channel_type = %s + AND status = 'linked' + ORDER BY updated_at DESC, created_at DESC, id DESC + LIMIT 1 + """, + (user_account_id, workspace_id, TELEGRAM_CHANNEL_TYPE), + ) + return cur.fetchone() + + +def get_latest_linked_telegram_identity( + conn, + *, + user_account_id: UUID, + workspace_id: UUID, +) -> TelegramChannelIdentityRow | None: + return _get_latest_linked_identity( + conn, + user_account_id=user_account_id, + workspace_id=workspace_id, + ) + + +def get_telegram_link_status( + conn, + *, + user_account_id: UUID, + workspace_id: UUID, +) -> dict[str, Any]: + identity = _get_latest_linked_identity( + conn, + user_account_id=user_account_id, + workspace_id=workspace_id, + ) + + with conn.cursor() as cur: + cur.execute( + """ + SELECT id, user_account_id, workspace_id, channel_type, challenge_token_hash, link_code, + status, expires_at, confirmed_at, channel_identity_id, created_at + FROM channel_link_challenges + WHERE user_account_id = %s + AND workspace_id = %s + AND channel_type = %s + ORDER BY created_at DESC, id DESC + LIMIT 1 + """, + (user_account_id, workspace_id, TELEGRAM_CHANNEL_TYPE), + ) + latest_challenge = cur.fetchone() + + cur.execute( + """ + SELECT id, route_status, direction, created_at + FROM channel_messages + WHERE workspace_id = %s + AND channel_type = %s + ORDER BY created_at DESC, id DESC + LIMIT 1 + """, + (workspace_id, TELEGRAM_CHANNEL_TYPE), + ) + recent_message = cur.fetchone() + + return { + "workspace_id": str(workspace_id), + "channel_type": TELEGRAM_CHANNEL_TYPE, + "linked": identity is not None, + "identity": None if identity is None else serialize_channel_identity(identity), + "latest_challenge": None + if latest_challenge is None + else serialize_channel_link_challenge(latest_challenge, include_token=False), + "recent_transport": None + if recent_message is None + else { + "message_id": str(recent_message["id"]), + "direction": recent_message["direction"], + "route_status": recent_message["route_status"], + "observed_at": recent_message["created_at"].isoformat(), + }, + } + + +def unlink_telegram_identity( + conn, + *, + user_account_id: UUID, + workspace_id: UUID, +) -> TelegramChannelIdentityRow: + now = utc_now() + + with conn.cursor() as cur: + cur.execute( + """ + UPDATE channel_identities + SET status = 'unlinked', + unlinked_at = %s, + updated_at = %s + WHERE id = ( + SELECT id + FROM channel_identities + WHERE user_account_id = %s + AND workspace_id = %s + AND channel_type = %s + AND status = 'linked' + ORDER BY updated_at DESC, created_at DESC, id DESC + LIMIT 1 + ) + RETURNING id, user_account_id, workspace_id, channel_type, external_user_id, + external_chat_id, external_username, status, linked_at, unlinked_at, + created_at, updated_at + """, + (now, now, user_account_id, workspace_id, TELEGRAM_CHANNEL_TYPE), + ) + identity = cur.fetchone() + + if identity is None: + raise TelegramIdentityNotFoundError("telegram channel is not linked for this workspace") + + cur.execute( + """ + UPDATE channel_link_challenges + SET status = 'cancelled' + WHERE user_account_id = %s + AND workspace_id = %s + AND channel_type = %s + AND status = 'pending' + """, + (user_account_id, workspace_id, TELEGRAM_CHANNEL_TYPE), + ) + + return identity + + +def dispatch_telegram_message( + conn, + *, + user_account_id: UUID, + workspace_id: UUID, + source_message_id: UUID, + text: str, + dispatch_idempotency_key: str | None, + bot_token: str, + rollout_flag_state: str = "enabled", + support_evidence: dict[str, Any] | None = None, + rate_limit_evidence: dict[str, Any] | None = None, + incident_evidence: dict[str, Any] | None = None, +) -> tuple[TelegramChannelMessageRow, TelegramDeliveryReceiptRow]: + normalized_text = text.strip() + if normalized_text == "": + raise ValueError("dispatch text is required") + + with conn.cursor() as cur: + cur.execute( + """ + SELECT m.id, + m.workspace_id, + m.channel_thread_id, + m.channel_identity_id, + m.channel_type, + m.direction, + m.provider_update_id, + m.provider_message_id, + m.external_chat_id, + m.external_user_id, + m.message_text, + m.normalized_payload, + m.route_status, + m.idempotency_key, + m.created_at, + m.received_at + FROM channel_messages AS m + JOIN workspace_members AS wm + ON wm.workspace_id = m.workspace_id + WHERE m.id = %s + AND wm.user_account_id = %s + AND m.workspace_id = %s + AND m.channel_type = %s + LIMIT 1 + """, + (source_message_id, user_account_id, workspace_id, TELEGRAM_CHANNEL_TYPE), + ) + source = cur.fetchone() + + if source is None: + raise TelegramMessageNotFoundError("telegram source message was not found") + + if source["route_status"] != "resolved": + raise TelegramRoutingError("telegram source message does not have resolved routing") + + external_chat_id = source["external_chat_id"] + if external_chat_id is None: + raise TelegramRoutingError("telegram source message is missing external chat id") + + resolved_idempotency_key = dispatch_idempotency_key + if resolved_idempotency_key is None: + resolved_idempotency_key = hashlib.sha256( + f"telegram:dispatch:{source_message_id}:{normalized_text}".encode("utf-8") + ).hexdigest() + + provider_message_id = f"simulated:{secrets.token_hex(10)}" + now = utc_now() + + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO channel_messages ( + workspace_id, + channel_thread_id, + channel_identity_id, + channel_type, + direction, + provider_update_id, + provider_message_id, + external_chat_id, + external_user_id, + message_text, + normalized_payload, + route_status, + idempotency_key, + received_at + ) + VALUES (%s, %s, %s, %s, 'outbound', NULL, %s, %s, %s, %s, %s, 'resolved', %s, %s) + ON CONFLICT (channel_type, direction, idempotency_key) DO NOTHING + RETURNING id, workspace_id, channel_thread_id, channel_identity_id, + channel_type, direction, provider_update_id, provider_message_id, + external_chat_id, external_user_id, message_text, + normalized_payload, route_status, idempotency_key, created_at, received_at + """, + ( + workspace_id, + source["channel_thread_id"], + source["channel_identity_id"], + TELEGRAM_CHANNEL_TYPE, + provider_message_id, + external_chat_id, + source["external_user_id"], + normalized_text, + Jsonb( + { + "dispatch": { + "source_message_id": str(source_message_id), + "mode": "simulated" if bot_token.strip() == "" else "deterministic_failure", + } + } + ), + resolved_idempotency_key, + now, + ), + ) + outbound = cur.fetchone() + + if outbound is None: + cur.execute( + """ + SELECT id, workspace_id, channel_thread_id, channel_identity_id, channel_type, + direction, provider_update_id, provider_message_id, external_chat_id, + external_user_id, message_text, normalized_payload, route_status, + idempotency_key, created_at, received_at + FROM channel_messages + WHERE workspace_id = %s + AND channel_type = %s + AND direction = 'outbound' + AND idempotency_key = %s + LIMIT 1 + """, + (workspace_id, TELEGRAM_CHANNEL_TYPE, resolved_idempotency_key), + ) + outbound = cur.fetchone() + + if outbound is None: + raise RuntimeError("failed to create outbound telegram message") + + receipt_status = "simulated" + failure_code: str | None = None + failure_detail: str | None = None + provider_receipt_id: str | None = outbound["provider_message_id"] + + if bot_token.strip() != "": + receipt_status = "failed" + failure_code = "telegram_transport_not_enabled" + failure_detail = "live telegram dispatch is not enabled in this environment" + provider_receipt_id = None + + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO channel_delivery_receipts ( + workspace_id, + channel_message_id, + channel_type, + status, + provider_receipt_id, + failure_code, + failure_detail, + rollout_flag_state, + support_evidence, + rate_limit_evidence, + incident_evidence, + recorded_at + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (channel_message_id) DO UPDATE + SET status = EXCLUDED.status, + provider_receipt_id = EXCLUDED.provider_receipt_id, + failure_code = EXCLUDED.failure_code, + failure_detail = EXCLUDED.failure_detail, + rollout_flag_state = EXCLUDED.rollout_flag_state, + support_evidence = EXCLUDED.support_evidence, + rate_limit_evidence = EXCLUDED.rate_limit_evidence, + incident_evidence = EXCLUDED.incident_evidence, + recorded_at = EXCLUDED.recorded_at + RETURNING id, workspace_id, channel_message_id, channel_type, + status, provider_receipt_id, failure_code, failure_detail, + scheduled_job_id, scheduler_job_kind, scheduled_for, schedule_slot, + notification_policy, rollout_flag_state, support_evidence, + rate_limit_evidence, incident_evidence, recorded_at, created_at + """, + ( + workspace_id, + outbound["id"], + TELEGRAM_CHANNEL_TYPE, + receipt_status, + provider_receipt_id, + failure_code, + failure_detail, + rollout_flag_state, + Jsonb(support_evidence or {}), + Jsonb(rate_limit_evidence or {}), + Jsonb(incident_evidence or {}), + utc_now(), + ), + ) + receipt = cur.fetchone() + + if receipt is None: + raise RuntimeError("failed to persist telegram delivery receipt") + + return outbound, receipt + + +def dispatch_telegram_workspace_message( + conn, + *, + user_account_id: UUID, + workspace_id: UUID, + text: str, + dispatch_idempotency_key: str, + bot_token: str, + dispatch_payload: dict[str, Any] | None = None, + receipt_status_override: str | None = None, + failure_code_override: str | None = None, + failure_detail_override: str | None = None, + scheduled_job_id: UUID | None = None, + scheduler_job_kind: str | None = None, + scheduled_for: datetime | None = None, + schedule_slot: str | None = None, + notification_policy: dict[str, Any] | None = None, + rollout_flag_state: str = "enabled", + support_evidence: dict[str, Any] | None = None, + rate_limit_evidence: dict[str, Any] | None = None, + incident_evidence: dict[str, Any] | None = None, +) -> tuple[TelegramChannelMessageRow, TelegramDeliveryReceiptRow]: + normalized_text = text.strip() + if normalized_text == "": + raise ValueError("dispatch text is required") + + resolved_idempotency_key = dispatch_idempotency_key.strip() + if resolved_idempotency_key == "": + raise ValueError("dispatch idempotency key is required") + + identity = _get_latest_linked_identity( + conn, + user_account_id=user_account_id, + workspace_id=workspace_id, + ) + if identity is None: + raise TelegramIdentityNotFoundError("telegram channel is not linked for this workspace") + + now = utc_now() + external_thread_key = resolve_telegram_thread_key(external_chat_id=identity["external_chat_id"]) + + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO channel_threads ( + workspace_id, + channel_type, + external_thread_key, + channel_identity_id, + last_message_at, + updated_at + ) + VALUES (%s, %s, %s, %s, %s, %s) + ON CONFLICT (workspace_id, channel_type, external_thread_key) DO UPDATE + SET channel_identity_id = EXCLUDED.channel_identity_id, + last_message_at = EXCLUDED.last_message_at, + updated_at = EXCLUDED.updated_at + RETURNING id + """, + ( + workspace_id, + TELEGRAM_CHANNEL_TYPE, + external_thread_key, + identity["id"], + now, + now, + ), + ) + thread = cur.fetchone() + + if thread is None: + raise RuntimeError("failed to resolve telegram channel thread for workspace dispatch") + + provider_message_id = f"simulated:{hashlib.sha256(resolved_idempotency_key.encode('utf-8')).hexdigest()[:20]}" + normalized_dispatch_payload = dispatch_payload or {} + dispatch_mode = "suppressed" if receipt_status_override == "suppressed" else ( + "simulated" if bot_token.strip() == "" else "deterministic_failure" + ) + + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO channel_messages ( + workspace_id, + channel_thread_id, + channel_identity_id, + channel_type, + direction, + provider_update_id, + provider_message_id, + external_chat_id, + external_user_id, + message_text, + normalized_payload, + route_status, + idempotency_key, + received_at + ) + VALUES (%s, %s, %s, %s, 'outbound', NULL, %s, %s, %s, %s, %s, 'resolved', %s, %s) + ON CONFLICT (channel_type, direction, idempotency_key) DO NOTHING + RETURNING id, workspace_id, channel_thread_id, channel_identity_id, + channel_type, direction, provider_update_id, provider_message_id, + external_chat_id, external_user_id, message_text, + normalized_payload, route_status, idempotency_key, created_at, received_at + """, + ( + workspace_id, + thread["id"], + identity["id"], + TELEGRAM_CHANNEL_TYPE, + provider_message_id, + identity["external_chat_id"], + identity["external_user_id"], + normalized_text, + Jsonb( + { + "dispatch": { + "source": "workspace_notification", + "mode": dispatch_mode, + }, + "scheduler": normalized_dispatch_payload, + } + ), + resolved_idempotency_key, + now, + ), + ) + outbound = cur.fetchone() + + if outbound is None: + cur.execute( + """ + SELECT id, workspace_id, channel_thread_id, channel_identity_id, channel_type, + direction, provider_update_id, provider_message_id, external_chat_id, + external_user_id, message_text, normalized_payload, route_status, + idempotency_key, created_at, received_at + FROM channel_messages + WHERE workspace_id = %s + AND channel_type = %s + AND direction = 'outbound' + AND idempotency_key = %s + LIMIT 1 + """, + (workspace_id, TELEGRAM_CHANNEL_TYPE, resolved_idempotency_key), + ) + outbound = cur.fetchone() + + if outbound is None: + raise RuntimeError("failed to create outbound telegram workspace notification message") + + if receipt_status_override is not None: + receipt_status = receipt_status_override + provider_receipt_id: str | None = None + failure_code = failure_code_override + failure_detail = failure_detail_override + else: + receipt_status = "simulated" + failure_code = None + failure_detail = None + provider_receipt_id = outbound["provider_message_id"] + if bot_token.strip() != "": + receipt_status = "failed" + provider_receipt_id = None + failure_code = "telegram_transport_not_enabled" + failure_detail = "live telegram dispatch is not enabled in this environment" + + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO channel_delivery_receipts ( + workspace_id, + channel_message_id, + channel_type, + status, + provider_receipt_id, + failure_code, + failure_detail, + scheduled_job_id, + scheduler_job_kind, + scheduled_for, + schedule_slot, + notification_policy, + rollout_flag_state, + support_evidence, + rate_limit_evidence, + incident_evidence, + recorded_at + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (channel_message_id) DO UPDATE + SET status = EXCLUDED.status, + provider_receipt_id = EXCLUDED.provider_receipt_id, + failure_code = EXCLUDED.failure_code, + failure_detail = EXCLUDED.failure_detail, + scheduled_job_id = EXCLUDED.scheduled_job_id, + scheduler_job_kind = EXCLUDED.scheduler_job_kind, + scheduled_for = EXCLUDED.scheduled_for, + schedule_slot = EXCLUDED.schedule_slot, + notification_policy = EXCLUDED.notification_policy, + rollout_flag_state = EXCLUDED.rollout_flag_state, + support_evidence = EXCLUDED.support_evidence, + rate_limit_evidence = EXCLUDED.rate_limit_evidence, + incident_evidence = EXCLUDED.incident_evidence, + recorded_at = EXCLUDED.recorded_at + RETURNING id, workspace_id, channel_message_id, channel_type, + status, provider_receipt_id, failure_code, failure_detail, + scheduled_job_id, scheduler_job_kind, scheduled_for, schedule_slot, + notification_policy, rollout_flag_state, support_evidence, + rate_limit_evidence, incident_evidence, recorded_at, created_at + """, + ( + workspace_id, + outbound["id"], + TELEGRAM_CHANNEL_TYPE, + receipt_status, + provider_receipt_id, + failure_code, + failure_detail, + scheduled_job_id, + scheduler_job_kind, + scheduled_for, + schedule_slot, + Jsonb(notification_policy or {}), + rollout_flag_state, + Jsonb(support_evidence or {}), + Jsonb(rate_limit_evidence or {}), + Jsonb(incident_evidence or {}), + utc_now(), + ), + ) + receipt = cur.fetchone() + + if receipt is None: + raise RuntimeError("failed to persist telegram workspace notification delivery receipt") + + return outbound, receipt + + +def list_workspace_telegram_delivery_receipts( + conn, + *, + user_account_id: UUID, + workspace_id: UUID, + limit: int, +) -> list[TelegramDeliveryReceiptRow]: + with conn.cursor() as cur: + cur.execute( + """ + SELECT r.id, + r.workspace_id, + r.channel_message_id, + r.channel_type, + r.status, + r.provider_receipt_id, + r.failure_code, + r.failure_detail, + r.scheduled_job_id, + r.scheduler_job_kind, + r.scheduled_for, + r.schedule_slot, + r.notification_policy, + r.rollout_flag_state, + r.support_evidence, + r.rate_limit_evidence, + r.incident_evidence, + r.recorded_at, + r.created_at + FROM channel_delivery_receipts AS r + JOIN workspace_members AS wm + ON wm.workspace_id = r.workspace_id + WHERE r.channel_type = %s + AND r.workspace_id = %s + AND wm.user_account_id = %s + ORDER BY r.recorded_at DESC, r.id DESC + LIMIT %s + """, + (TELEGRAM_CHANNEL_TYPE, workspace_id, user_account_id, limit), + ) + rows = cur.fetchall() + + return rows + + +def serialize_channel_identity(identity: TelegramChannelIdentityRow) -> dict[str, object]: + return { + "id": str(identity["id"]), + "user_account_id": str(identity["user_account_id"]), + "workspace_id": str(identity["workspace_id"]), + "channel_type": identity["channel_type"], + "external_user_id": identity["external_user_id"], + "external_chat_id": identity["external_chat_id"], + "external_username": identity["external_username"], + "status": identity["status"], + "linked_at": identity["linked_at"].isoformat(), + "unlinked_at": None if identity["unlinked_at"] is None else identity["unlinked_at"].isoformat(), + "created_at": identity["created_at"].isoformat(), + "updated_at": identity["updated_at"].isoformat(), + } + + +def serialize_channel_link_challenge( + challenge: TelegramChannelLinkChallengeRow | IssuedTelegramChannelLinkChallengeRow, + *, + include_token: bool, +) -> dict[str, object]: + payload: dict[str, object] = { + "id": str(challenge["id"]), + "user_account_id": str(challenge["user_account_id"]), + "workspace_id": str(challenge["workspace_id"]), + "channel_type": challenge["channel_type"], + "link_code": challenge["link_code"], + "status": challenge["status"], + "expires_at": challenge["expires_at"].isoformat(), + "confirmed_at": None + if challenge["confirmed_at"] is None + else challenge["confirmed_at"].isoformat(), + "channel_identity_id": None + if challenge["channel_identity_id"] is None + else str(challenge["channel_identity_id"]), + "created_at": challenge["created_at"].isoformat(), + } + if include_token: + token = challenge.get("challenge_token") + if not isinstance(token, str): + raise ValueError("challenge token is required for issued link challenge serialization") + payload["challenge_token"] = token + return payload + + +def serialize_channel_thread(thread: TelegramChannelThreadRow) -> dict[str, object]: + return { + "id": str(thread["id"]), + "workspace_id": str(thread["workspace_id"]), + "channel_type": thread["channel_type"], + "external_thread_key": thread["external_thread_key"], + "channel_identity_id": None + if thread["channel_identity_id"] is None + else str(thread["channel_identity_id"]), + "last_message_at": None + if thread["last_message_at"] is None + else thread["last_message_at"].isoformat(), + "created_at": thread["created_at"].isoformat(), + "updated_at": thread["updated_at"].isoformat(), + } + + +def serialize_channel_message(message: TelegramChannelMessageRow) -> dict[str, object]: + return { + "id": str(message["id"]), + "workspace_id": None if message["workspace_id"] is None else str(message["workspace_id"]), + "channel_thread_id": None + if message["channel_thread_id"] is None + else str(message["channel_thread_id"]), + "channel_identity_id": None + if message["channel_identity_id"] is None + else str(message["channel_identity_id"]), + "channel_type": message["channel_type"], + "direction": message["direction"], + "provider_update_id": message["provider_update_id"], + "provider_message_id": message["provider_message_id"], + "external_chat_id": message["external_chat_id"], + "external_user_id": message["external_user_id"], + "message_text": message["message_text"], + "normalized_payload": message["normalized_payload"], + "route_status": message["route_status"], + "idempotency_key": message["idempotency_key"], + "created_at": message["created_at"].isoformat(), + "received_at": message["received_at"].isoformat(), + } + + +def serialize_delivery_receipt(receipt: TelegramDeliveryReceiptRow) -> dict[str, object]: + return { + "id": str(receipt["id"]), + "workspace_id": str(receipt["workspace_id"]), + "channel_message_id": str(receipt["channel_message_id"]), + "channel_type": receipt["channel_type"], + "status": receipt["status"], + "provider_receipt_id": receipt["provider_receipt_id"], + "failure_code": receipt["failure_code"], + "failure_detail": receipt["failure_detail"], + "scheduled_job_id": None + if receipt["scheduled_job_id"] is None + else str(receipt["scheduled_job_id"]), + "scheduler_job_kind": receipt["scheduler_job_kind"], + "scheduled_for": None if receipt["scheduled_for"] is None else receipt["scheduled_for"].isoformat(), + "schedule_slot": receipt["schedule_slot"], + "notification_policy": receipt["notification_policy"], + "rollout_flag_state": receipt["rollout_flag_state"], + "support_evidence": receipt["support_evidence"], + "rate_limit_evidence": receipt["rate_limit_evidence"], + "incident_evidence": receipt["incident_evidence"], + "recorded_at": receipt["recorded_at"].isoformat(), + "created_at": receipt["created_at"].isoformat(), + } + + +def serialize_webhook_ingest_result(result: TelegramWebhookIngestResult) -> dict[str, object]: + return { + "duplicate": result["duplicate"], + "route_status": result["route_status"], + "link_status": result["link_status"], + "unknown_chat_routing": result["unknown_chat_routing"], + "message": serialize_channel_message(result["message"]), + "thread": None if result["thread"] is None else serialize_channel_thread(result["thread"]), + } diff --git a/apps/api/src/alicebot_api/telegram_continuity.py b/apps/api/src/alicebot_api/telegram_continuity.py new file mode 100644 index 0000000..22f732b --- /dev/null +++ b/apps/api/src/alicebot_api/telegram_continuity.py @@ -0,0 +1,1217 @@ +from __future__ import annotations + +from datetime import UTC, datetime +import hashlib +import re +from typing import Any, Literal, TypedDict +from uuid import UUID + +from psycopg.types.json import Jsonb + +from alicebot_api.approvals import ( + ApprovalNotFoundError, + ApprovalResolutionConflictError, + approve_approval_record, + list_approval_records, + reject_approval_record, +) +from alicebot_api.continuity_capture import capture_continuity_input +from alicebot_api.continuity_objects import ContinuityObjectValidationError +from alicebot_api.continuity_open_loops import ( + ContinuityOpenLoopNotFoundError, + ContinuityOpenLoopValidationError, + apply_continuity_open_loop_review_action, + compile_continuity_open_loop_dashboard, +) +from alicebot_api.continuity_recall import ContinuityRecallValidationError, query_continuity_recall +from alicebot_api.continuity_resumption import ( + ContinuityResumptionValidationError, + compile_continuity_resumption_brief, +) +from alicebot_api.continuity_review import ( + ContinuityReviewNotFoundError, + ContinuityReviewValidationError, + apply_continuity_correction, +) +from alicebot_api.contracts import ( + ApprovalApproveInput, + ApprovalRejectInput, + ContinuityCaptureCreateInput, + ContinuityCorrectionInput, + ContinuityOpenLoopDashboardQueryInput, + ContinuityOpenLoopReviewActionInput, + ContinuityRecallQueryInput, + ContinuityResumptionBriefRequestInput, +) +from alicebot_api.db import set_current_user +from alicebot_api.store import ContinuityStore, JsonObject +from alicebot_api.tasks import TaskStepApprovalLinkageError, TaskStepLifecycleBoundaryError +from alicebot_api.telegram_channels import ( + TELEGRAM_CHANNEL_TYPE, + TelegramMessageNotFoundError, + TelegramRoutingError, + dispatch_telegram_message, + serialize_channel_message, + serialize_delivery_receipt, +) + + +TelegramChatIntentKind = Literal[ + "capture", + "recall", + "resume", + "correction", + "open_loops", + "open_loop_review", + "approvals", + "approval_approve", + "approval_reject", + "unknown", +] +TelegramChatIntentStatus = Literal["pending", "recorded", "handled", "failed"] + +_SUPPORTED_INTENT_HINTS: set[str] = { + "capture", + "recall", + "resume", + "correction", + "open_loops", + "open_loop_review", + "approvals", + "approval_approve", + "approval_reject", + "unknown", +} +_RECALL_PATTERN = re.compile(r"^\s*/?recall\b(?:\s+(?P.+))?$", flags=re.IGNORECASE) +_RESUME_PATTERN = re.compile(r"^\s*/?resume\b(?:\s+(?P.+))?$", flags=re.IGNORECASE) +_OPEN_LOOPS_PATTERN = re.compile(r"^\s*(?:/?open-loops\b|open loops\b)(?:\s+.*)?$", flags=re.IGNORECASE) +_APPROVALS_PATTERN = re.compile(r"^\s*(?:/?approvals\b|pending approvals\b)(?:\s+.*)?$", flags=re.IGNORECASE) +_CORRECTION_PATTERN = re.compile( + ( + r"^\s*/?(?:correct|correction)\b\s+" + r"(?P[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})" + r"\s+(?P.+)$" + ), + flags=re.IGNORECASE, +) +_OPEN_LOOP_REVIEW_PATTERN = re.compile( + ( + r"^\s*/?open-loop\b\s+" + r"(?P<object_id>[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})" + r"\s+(?P<action>done|deferred|still_blocked)" + r"(?:\s+(?P<note>.+))?$" + ), + flags=re.IGNORECASE, +) +_APPROVE_PATTERN = re.compile(r"^\s*/?approve\b(?:\s+(?P<approval_id>\S+))?.*$", flags=re.IGNORECASE) +_REJECT_PATTERN = re.compile(r"^\s*/?reject\b(?:\s+(?P<approval_id>\S+))?(?:\s+(?P<note>.+))?$", flags=re.IGNORECASE) + + +class HostedUserAccountNotFoundError(LookupError): + """Raised when a hosted account is not available for continuity projection.""" + + +class TelegramMessageResultNotFoundError(LookupError): + """Raised when no Telegram handle result is available for the message.""" + + +class _TelegramInboundMessageRow(TypedDict): + id: UUID + workspace_id: UUID + channel_thread_id: UUID | None + channel_identity_id: UUID | None + route_status: str + message_text: str | None + external_chat_id: str | None + + +class TelegramIntentClassification(TypedDict): + intent_kind: TelegramChatIntentKind + confidence: float + intent_payload: JsonObject + + +class _ChatIntentRow(TypedDict): + id: UUID + workspace_id: UUID + channel_message_id: UUID + channel_thread_id: UUID | None + intent_kind: str + status: str + intent_payload: JsonObject + result_payload: JsonObject + handled_at: datetime | None + created_at: datetime + + +class _ApprovalChallengeRow(TypedDict): + id: UUID + workspace_id: UUID + approval_id: UUID + channel_message_id: UUID | None + status: str + challenge_prompt: str + challenge_payload: JsonObject + resolved_at: datetime | None + created_at: datetime + updated_at: datetime + + +def _utcnow() -> datetime: + return datetime.now(UTC) + + +def _normalize_optional_text(value: str | None) -> str | None: + if value is None: + return None + normalized = " ".join(value.split()).strip() + if normalized == "": + return None + return normalized + + +def _normalize_optional_payload_text(payload: JsonObject, *, field_name: str) -> str | None: + raw_value = payload.get(field_name) + if raw_value is None: + return None + if not isinstance(raw_value, str): + raise ValueError(f"{field_name} must be a string") + return _normalize_optional_text(raw_value) + + +def _parse_uuid(value: str, *, field_name: str) -> UUID: + try: + return UUID(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{field_name} must be a valid uuid") from exc + + +def _resolve_intent_hint(intent_hint: str | None) -> TelegramChatIntentKind | None: + normalized = _normalize_optional_text(intent_hint) + if normalized is None: + return None + lowered = normalized.casefold() + if lowered not in _SUPPORTED_INTENT_HINTS: + allowed = ", ".join(sorted(_SUPPORTED_INTENT_HINTS)) + raise ValueError(f"intent_hint must be one of: {allowed}") + return lowered # type: ignore[return-value] + + +def _serialize_chat_intent(row: _ChatIntentRow) -> dict[str, object]: + return { + "id": str(row["id"]), + "workspace_id": str(row["workspace_id"]), + "channel_message_id": str(row["channel_message_id"]), + "channel_thread_id": None if row["channel_thread_id"] is None else str(row["channel_thread_id"]), + "intent_kind": row["intent_kind"], + "status": row["status"], + "intent_payload": row["intent_payload"], + "result_payload": row["result_payload"], + "handled_at": None if row["handled_at"] is None else row["handled_at"].isoformat(), + "created_at": row["created_at"].isoformat(), + } + + +def _serialize_approval_challenge(row: _ApprovalChallengeRow) -> dict[str, object]: + return { + "id": str(row["id"]), + "workspace_id": str(row["workspace_id"]), + "approval_id": str(row["approval_id"]), + "channel_message_id": None if row["channel_message_id"] is None else str(row["channel_message_id"]), + "status": row["status"], + "challenge_prompt": row["challenge_prompt"], + "challenge_payload": row["challenge_payload"], + "resolved_at": None if row["resolved_at"] is None else row["resolved_at"].isoformat(), + "created_at": row["created_at"].isoformat(), + "updated_at": row["updated_at"].isoformat(), + } + + +def prepare_telegram_continuity_context( + conn, + *, + user_account_id: UUID, +) -> None: + set_current_user(conn, user_account_id) + + with conn.cursor() as cur: + cur.execute( + """ + SELECT id, email, display_name + FROM user_accounts + WHERE id = %s + LIMIT 1 + """, + (user_account_id,), + ) + account = cur.fetchone() + + if account is None: + raise HostedUserAccountNotFoundError(f"hosted user account {user_account_id} was not found") + + cur.execute( + """ + INSERT INTO users (id, email, display_name) + VALUES (%s, %s, %s) + ON CONFLICT (id) DO NOTHING + """, + (account["id"], account["email"], account["display_name"]), + ) + + +def _load_workspace_inbound_message( + conn, + *, + user_account_id: UUID, + workspace_id: UUID, + message_id: UUID, +) -> _TelegramInboundMessageRow: + with conn.cursor() as cur: + cur.execute( + """ + SELECT m.id, + m.workspace_id, + m.channel_thread_id, + m.channel_identity_id, + m.route_status, + m.message_text, + m.external_chat_id + FROM channel_messages AS m + JOIN workspace_members AS wm + ON wm.workspace_id = m.workspace_id + WHERE m.id = %s + AND m.workspace_id = %s + AND wm.user_account_id = %s + AND m.channel_type = %s + AND m.direction = 'inbound' + LIMIT 1 + """, + (message_id, workspace_id, user_account_id, TELEGRAM_CHANNEL_TYPE), + ) + row = cur.fetchone() + + if row is None: + raise TelegramMessageNotFoundError(f"telegram source message {message_id} was not found") + if row["route_status"] != "resolved": + raise TelegramRoutingError("telegram source message does not have resolved routing") + return row + + +def classify_telegram_message_intent(message_text: str) -> TelegramIntentClassification: + normalized_text = _normalize_optional_text(message_text) + if normalized_text is None: + return { + "intent_kind": "unknown", + "confidence": 1.0, + "intent_payload": {"reason": "empty_message"}, + } + + review_match = _OPEN_LOOP_REVIEW_PATTERN.match(normalized_text) + if review_match is not None: + note = _normalize_optional_text(review_match.group("note")) + payload: JsonObject = { + "continuity_object_id": review_match.group("object_id"), + "action": review_match.group("action").casefold(), + } + payload["note"] = note + return { + "intent_kind": "open_loop_review", + "confidence": 0.99, + "intent_payload": payload, + } + + correction_match = _CORRECTION_PATTERN.match(normalized_text) + if correction_match is not None: + return { + "intent_kind": "correction", + "confidence": 0.99, + "intent_payload": { + "continuity_object_id": correction_match.group("object_id"), + "replacement_title": correction_match.group("title").strip(), + }, + } + + approve_match = _APPROVE_PATTERN.match(normalized_text) + if approve_match is not None: + return { + "intent_kind": "approval_approve", + "confidence": 0.98, + "intent_payload": { + "approval_id": approve_match.group("approval_id"), + }, + } + + reject_match = _REJECT_PATTERN.match(normalized_text) + if reject_match is not None: + return { + "intent_kind": "approval_reject", + "confidence": 0.98, + "intent_payload": { + "approval_id": reject_match.group("approval_id"), + "note": _normalize_optional_text(reject_match.group("note")), + }, + } + + recall_match = _RECALL_PATTERN.match(normalized_text) + if recall_match is not None: + return { + "intent_kind": "recall", + "confidence": 0.97, + "intent_payload": {"query": _normalize_optional_text(recall_match.group("query"))}, + } + + if normalized_text.casefold().startswith("what do you remember"): + suffix = _normalize_optional_text(normalized_text[len("what do you remember") :]) + return { + "intent_kind": "recall", + "confidence": 0.85, + "intent_payload": {"query": suffix}, + } + + resume_match = _RESUME_PATTERN.match(normalized_text) + if resume_match is not None: + return { + "intent_kind": "resume", + "confidence": 0.97, + "intent_payload": {"query": _normalize_optional_text(resume_match.group("query"))}, + } + + if normalized_text.casefold().startswith("where was i"): + return { + "intent_kind": "resume", + "confidence": 0.85, + "intent_payload": {"query": None}, + } + + if _OPEN_LOOPS_PATTERN.match(normalized_text) is not None: + return { + "intent_kind": "open_loops", + "confidence": 0.98, + "intent_payload": {"query": None}, + } + + if _APPROVALS_PATTERN.match(normalized_text) is not None: + return { + "intent_kind": "approvals", + "confidence": 0.98, + "intent_payload": {"status": "pending"}, + } + + return { + "intent_kind": "capture", + "confidence": 0.65, + "intent_payload": {"raw_content": normalized_text}, + } + + +def _upsert_chat_intent_result( + conn, + *, + workspace_id: UUID, + channel_message_id: UUID, + channel_thread_id: UUID | None, + intent_kind: TelegramChatIntentKind, + status: TelegramChatIntentStatus, + intent_payload: JsonObject, + result_payload: JsonObject, + handled_at: datetime | None, +) -> _ChatIntentRow: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO chat_intents ( + workspace_id, + channel_message_id, + channel_thread_id, + intent_kind, + status, + intent_payload, + result_payload, + handled_at + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (channel_message_id, intent_kind) DO UPDATE + SET status = EXCLUDED.status, + intent_payload = EXCLUDED.intent_payload, + result_payload = EXCLUDED.result_payload, + handled_at = EXCLUDED.handled_at + RETURNING id, + workspace_id, + channel_message_id, + channel_thread_id, + intent_kind, + status, + intent_payload, + result_payload, + handled_at, + created_at + """, + ( + workspace_id, + channel_message_id, + channel_thread_id, + intent_kind, + status, + Jsonb(intent_payload), + Jsonb(result_payload), + handled_at, + ), + ) + row = cur.fetchone() + + if row is None: + raise RuntimeError("failed to persist telegram chat intent result") + return row + + +def _fetch_latest_chat_intent_result( + conn, + *, + user_account_id: UUID, + workspace_id: UUID, + message_id: UUID, +) -> _ChatIntentRow: + with conn.cursor() as cur: + cur.execute( + """ + SELECT ci.id, + ci.workspace_id, + ci.channel_message_id, + ci.channel_thread_id, + ci.intent_kind, + ci.status, + ci.intent_payload, + ci.result_payload, + ci.handled_at, + ci.created_at + FROM chat_intents AS ci + JOIN workspace_members AS wm + ON wm.workspace_id = ci.workspace_id + WHERE ci.workspace_id = %s + AND ci.channel_message_id = %s + AND wm.user_account_id = %s + AND ci.intent_kind <> 'inbound_message' + ORDER BY COALESCE(ci.handled_at, ci.created_at) DESC, ci.created_at DESC, ci.id DESC + LIMIT 1 + """, + (workspace_id, message_id, user_account_id), + ) + row = cur.fetchone() + + if row is None: + raise TelegramMessageResultNotFoundError( + f"telegram message {message_id} does not have a continuity handle result yet" + ) + return row + + +def _format_provenance_reference_list(references: list[dict[str, object]], *, limit: int = 3) -> str: + compact: list[str] = [] + for item in references[:limit]: + source_kind = str(item.get("source_kind", "source")) + source_id = str(item.get("source_id", "unknown")) + compact.append(f"{source_kind}:{source_id}") + return ", ".join(compact) + + +def _record_pending_approval_challenges( + conn, + *, + workspace_id: UUID, + approvals: list[dict[str, object]], + channel_message_id: UUID | None, +) -> list[dict[str, object]]: + recorded: list[dict[str, object]] = [] + if not approvals: + return recorded + + with conn.cursor() as cur: + for approval in approvals: + approval_id = _parse_uuid(str(approval["id"]), field_name="approval_id") + request_payload = approval.get("request") + if isinstance(request_payload, dict): + action_hint = _normalize_optional_text(str(request_payload.get("action"))) + else: + action_hint = None + + challenge_prompt = ( + f"Approval {approval_id} is pending." + if action_hint is None + else f"Approval {approval_id} is pending for action '{action_hint}'." + ) + challenge_payload: JsonObject = { + "approval": approval, + "source": "telegram", + } + cur.execute( + """ + INSERT INTO approval_challenges ( + workspace_id, + approval_id, + channel_message_id, + status, + challenge_prompt, + challenge_payload, + updated_at + ) + VALUES (%s, %s, %s, 'pending', %s, %s, %s) + ON CONFLICT (workspace_id, approval_id) WHERE status = 'pending' + DO UPDATE + SET channel_message_id = COALESCE(EXCLUDED.channel_message_id, approval_challenges.channel_message_id), + challenge_prompt = EXCLUDED.challenge_prompt, + challenge_payload = EXCLUDED.challenge_payload, + updated_at = EXCLUDED.updated_at + RETURNING id, + workspace_id, + approval_id, + channel_message_id, + status, + challenge_prompt, + challenge_payload, + resolved_at, + created_at, + updated_at + """, + ( + workspace_id, + approval_id, + channel_message_id, + challenge_prompt, + Jsonb(challenge_payload), + _utcnow(), + ), + ) + row = cur.fetchone() + if row is not None: + recorded.append(_serialize_approval_challenge(row)) + + return recorded + + +def _resolve_pending_approval_challenges( + conn, + *, + workspace_id: UUID, + approval_id: UUID, + resolution_status: Literal["approved", "rejected"], +) -> list[dict[str, object]]: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE approval_challenges + SET status = %s, + resolved_at = %s, + updated_at = %s + WHERE workspace_id = %s + AND approval_id = %s + AND status = 'pending' + RETURNING id, + workspace_id, + approval_id, + channel_message_id, + status, + challenge_prompt, + challenge_payload, + resolved_at, + created_at, + updated_at + """, + ( + resolution_status, + _utcnow(), + _utcnow(), + workspace_id, + approval_id, + ), + ) + rows = cur.fetchall() + + return [_serialize_approval_challenge(row) for row in rows] + + +def list_telegram_approvals( + conn, + *, + user_account_id: UUID, + workspace_id: UUID, + status_filter: Literal["pending", "all"] = "pending", + channel_message_id: UUID | None = None, +) -> dict[str, object]: + payload = list_approval_records( + ContinuityStore(conn), + user_id=user_account_id, + ) + raw_items = payload["items"] + if status_filter == "pending": + items = [item for item in raw_items if item["status"] == "pending"] + else: + items = raw_items + + pending_items = [item for item in raw_items if item["status"] == "pending"] + challenges = _record_pending_approval_challenges( + conn, + workspace_id=workspace_id, + approvals=pending_items, + channel_message_id=channel_message_id, + ) + + return { + "items": items, + "summary": { + "status": status_filter, + "returned_count": len(items), + "pending_count": len(pending_items), + "order": payload["summary"]["order"], + }, + "challenges": challenges, + } + + +def approve_telegram_approval( + conn, + *, + user_account_id: UUID, + workspace_id: UUID, + approval_id: UUID, +) -> dict[str, object]: + payload = approve_approval_record( + ContinuityStore(conn), + user_id=user_account_id, + request=ApprovalApproveInput(approval_id=approval_id), + ) + challenge_updates = _resolve_pending_approval_challenges( + conn, + workspace_id=workspace_id, + approval_id=approval_id, + resolution_status="approved", + ) + return { + **payload, + "challenge_updates": challenge_updates, + } + + +def reject_telegram_approval( + conn, + *, + user_account_id: UUID, + workspace_id: UUID, + approval_id: UUID, +) -> dict[str, object]: + payload = reject_approval_record( + ContinuityStore(conn), + user_id=user_account_id, + request=ApprovalRejectInput(approval_id=approval_id), + ) + challenge_updates = _resolve_pending_approval_challenges( + conn, + workspace_id=workspace_id, + approval_id=approval_id, + resolution_status="rejected", + ) + return { + **payload, + "challenge_updates": challenge_updates, + } + + +def apply_telegram_open_loop_review_with_log( + conn, + *, + user_account_id: UUID, + workspace_id: UUID, + continuity_object_id: UUID, + action: str, + note: str | None, + channel_message_id: UUID | None = None, +) -> dict[str, object]: + response = apply_continuity_open_loop_review_action( + ContinuityStore(conn), + user_id=user_account_id, + continuity_object_id=continuity_object_id, + request=ContinuityOpenLoopReviewActionInput( + action=action, # type: ignore[arg-type] + note=note, + ), + ) + + correction_event_id = _parse_uuid(response["correction_event"]["id"], field_name="correction_event_id") + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO open_loop_reviews ( + workspace_id, + continuity_object_id, + channel_message_id, + correction_event_id, + review_action, + note + ) + VALUES (%s, %s, %s, %s, %s, %s) + RETURNING id, + workspace_id, + continuity_object_id, + channel_message_id, + correction_event_id, + review_action, + note, + created_at + """, + ( + workspace_id, + continuity_object_id, + channel_message_id, + correction_event_id, + action, + note, + ), + ) + review_row = cur.fetchone() + + if review_row is None: + raise RuntimeError("failed to persist open-loop review action log") + + return { + **response, + "review_log": { + "id": str(review_row["id"]), + "workspace_id": str(review_row["workspace_id"]), + "continuity_object_id": str(review_row["continuity_object_id"]), + "channel_message_id": None + if review_row["channel_message_id"] is None + else str(review_row["channel_message_id"]), + "correction_event_id": None + if review_row["correction_event_id"] is None + else str(review_row["correction_event_id"]), + "review_action": review_row["review_action"], + "note": review_row["note"], + "created_at": review_row["created_at"].isoformat(), + }, + } + + +def _execute_intent( + conn, + *, + store: ContinuityStore, + user_account_id: UUID, + workspace_id: UUID, + source_message_id: UUID, + classification: TelegramIntentClassification, + source_message_text: str, +) -> tuple[JsonObject, str]: + intent_kind = classification["intent_kind"] + intent_payload = classification["intent_payload"] + + if intent_kind == "capture": + capture_payload = capture_continuity_input( + store, + user_id=user_account_id, + request=ContinuityCaptureCreateInput( + raw_content=source_message_text, + explicit_signal=None, + ), + ) + capture = capture_payload["capture"] + derived_object = capture.get("derived_object") + if isinstance(derived_object, dict): + object_type = str(derived_object.get("object_type", "Note")) + title = str(derived_object.get("title", "captured object")) + reply_text = f"Captured {object_type}: {title}" + else: + reply_text = "Captured and queued for continuity triage." + return ( + { + "mode": "capture", + "capture": capture_payload["capture"], + "provenance_references": [ + { + "source_kind": "continuity_capture_event", + "source_id": capture["capture_event"]["id"], + } + ], + }, + reply_text, + ) + + if intent_kind == "recall": + query = _normalize_optional_payload_text(intent_payload, field_name="query") + if query is None: + raise ValueError("recall intent requires a query") + recall_payload = query_continuity_recall( + store, + user_id=user_account_id, + request=ContinuityRecallQueryInput(query=query, limit=5), + ) + if len(recall_payload["items"]) == 0: + reply_text = f"No recall results for '{query}'." + else: + first = recall_payload["items"][0] + provenance = _format_provenance_reference_list(first["provenance_references"]) + if provenance == "": + reply_text = f"Recall: {first['title']} ({first['status']})." + else: + reply_text = f"Recall: {first['title']} ({first['status']}). Provenance {provenance}." + return ( + { + "mode": "recall", + "query": query, + "recall": recall_payload, + }, + reply_text, + ) + + if intent_kind == "resume": + query = _normalize_optional_payload_text(intent_payload, field_name="query") + resume_payload = compile_continuity_resumption_brief( + store, + user_id=user_account_id, + request=ContinuityResumptionBriefRequestInput( + query=query, + ), + ) + brief = resume_payload["brief"] + decision = brief["last_decision"]["item"] + next_action = brief["next_action"]["item"] + decision_title = "none" if decision is None else decision["title"] + next_action_title = "none" if next_action is None else next_action["title"] + open_loop_count = brief["open_loops"]["summary"]["returned_count"] + reply_text = ( + f"Resume: decision={decision_title}; next_action={next_action_title}; " + f"open_loops={open_loop_count}." + ) + return ( + { + "mode": "resume", + "brief": brief, + }, + reply_text, + ) + + if intent_kind == "correction": + continuity_object_id_raw = _normalize_optional_payload_text(intent_payload, field_name="continuity_object_id") + if continuity_object_id_raw is None: + raise ValueError("correction intent requires continuity object id") + continuity_object_id = _parse_uuid(continuity_object_id_raw, field_name="continuity_object_id") + replacement_title = _normalize_optional_payload_text(intent_payload, field_name="replacement_title") + if replacement_title is None: + raise ValueError("correction intent requires replacement title text") + correction_payload = apply_continuity_correction( + store, + user_id=user_account_id, + continuity_object_id=continuity_object_id, + request=ContinuityCorrectionInput( + action="edit", + reason="telegram_correction", + title=replacement_title, + ), + ) + updated_object = correction_payload["continuity_object"] + reply_text = f"Correction applied: {updated_object['id']} now titled '{updated_object['title']}'." + return ( + { + "mode": "correction", + "correction": correction_payload, + "provenance_references": [ + { + "source_kind": "continuity_correction_event", + "source_id": correction_payload["correction_event"]["id"], + }, + { + "source_kind": "continuity_object", + "source_id": updated_object["id"], + }, + ], + }, + reply_text, + ) + + if intent_kind == "open_loops": + dashboard_payload = compile_continuity_open_loop_dashboard( + store, + user_id=user_account_id, + request=ContinuityOpenLoopDashboardQueryInput(limit=5), + ) + dashboard = dashboard_payload["dashboard"] + reply_text = ( + "Open loops: " + f"waiting_for={dashboard['waiting_for']['summary']['total_count']}, " + f"blocker={dashboard['blocker']['summary']['total_count']}, " + f"stale={dashboard['stale']['summary']['total_count']}, " + f"next_action={dashboard['next_action']['summary']['total_count']}." + ) + return ( + { + "mode": "open_loops", + "dashboard": dashboard, + }, + reply_text, + ) + + if intent_kind == "open_loop_review": + continuity_object_id_raw = _normalize_optional_payload_text(intent_payload, field_name="continuity_object_id") + if continuity_object_id_raw is None: + raise ValueError("open-loop review intent requires continuity object id") + continuity_object_id = _parse_uuid(continuity_object_id_raw, field_name="continuity_object_id") + action = _normalize_optional_payload_text(intent_payload, field_name="action") + if action is None: + raise ValueError("open-loop review intent requires action") + note = _normalize_optional_payload_text(intent_payload, field_name="note") + review_payload = apply_telegram_open_loop_review_with_log( + conn, + user_account_id=user_account_id, + workspace_id=workspace_id, + continuity_object_id=continuity_object_id, + action=action, + note=note, + channel_message_id=source_message_id, + ) + reply_text = ( + f"Open-loop review applied: action={review_payload['review_action']}, " + f"outcome={review_payload['lifecycle_outcome']}." + ) + return ( + { + "mode": "open_loop_review", + "review": review_payload, + }, + reply_text, + ) + + if intent_kind == "approvals": + approvals_payload = list_telegram_approvals( + conn, + user_account_id=user_account_id, + workspace_id=workspace_id, + status_filter="pending", + channel_message_id=source_message_id, + ) + items = approvals_payload["items"] + if len(items) == 0: + reply_text = "No pending approvals." + else: + pending_ids = ", ".join(str(item["id"]) for item in items[:3]) + suffix = "" if len(items) <= 3 else f" (+{len(items) - 3} more)" + reply_text = f"Pending approvals: {pending_ids}{suffix}." + return ( + { + "mode": "approvals", + "approvals": approvals_payload, + }, + reply_text, + ) + + if intent_kind == "approval_approve": + approval_id_raw = _normalize_optional_payload_text(intent_payload, field_name="approval_id") + if approval_id_raw is None: + raise ValueError("approve intent requires approval id") + approval_id = _parse_uuid(approval_id_raw, field_name="approval_id") + approval_payload = approve_telegram_approval( + conn, + user_account_id=user_account_id, + workspace_id=workspace_id, + approval_id=approval_id, + ) + final_status = approval_payload["approval"]["status"] + reply_text = f"Approval {approval_id} resolved as {final_status}." + return ( + { + "mode": "approval_approve", + "resolution": approval_payload, + }, + reply_text, + ) + + if intent_kind == "approval_reject": + approval_id_raw = _normalize_optional_payload_text(intent_payload, field_name="approval_id") + if approval_id_raw is None: + raise ValueError("reject intent requires approval id") + approval_id = _parse_uuid(approval_id_raw, field_name="approval_id") + approval_payload = reject_telegram_approval( + conn, + user_account_id=user_account_id, + workspace_id=workspace_id, + approval_id=approval_id, + ) + final_status = approval_payload["approval"]["status"] + reply_text = f"Approval {approval_id} resolved as {final_status}." + return ( + { + "mode": "approval_reject", + "resolution": approval_payload, + }, + reply_text, + ) + + if intent_kind == "unknown": + return ( + { + "mode": "unknown", + "reason": intent_payload.get("reason", "unknown_intent"), + }, + "I could not determine the requested action. Use /recall, /resume, /open-loops, /approvals, or send capture text.", + ) + + raise ValueError(f"unsupported telegram intent kind: {intent_kind}") + + +def handle_telegram_message( + conn, + *, + user_account_id: UUID, + workspace_id: UUID, + message_id: UUID, + bot_token: str, + intent_hint: str | None = None, +) -> dict[str, object]: + source_message = _load_workspace_inbound_message( + conn, + user_account_id=user_account_id, + workspace_id=workspace_id, + message_id=message_id, + ) + + source_text = _normalize_optional_text(source_message["message_text"]) or "" + classification = classify_telegram_message_intent(source_text) + hinted_intent = _resolve_intent_hint(intent_hint) + + intent_kind: TelegramChatIntentKind = classification["intent_kind"] + status: TelegramChatIntentStatus + result_payload: JsonObject + reply_text: str + + store = ContinuityStore(conn) + execution_error_kinds = ( + ApprovalNotFoundError, + ApprovalResolutionConflictError, + ContinuityOpenLoopNotFoundError, + ContinuityOpenLoopValidationError, + ContinuityRecallValidationError, + ContinuityResumptionValidationError, + ContinuityReviewNotFoundError, + ContinuityReviewValidationError, + ContinuityObjectValidationError, + TaskStepApprovalLinkageError, + TaskStepLifecycleBoundaryError, + ValueError, + ) + + if hinted_intent is not None and hinted_intent != classification["intent_kind"]: + intent_kind = hinted_intent + status = "failed" + result_payload = { + "ok": False, + "error": { + "code": "intent_hint_mismatch", + "detail": ( + f"intent_hint '{hinted_intent}' does not match detected intent " + f"'{classification['intent_kind']}'" + ), + }, + "detected_intent_kind": classification["intent_kind"], + } + reply_text = ( + f"Intent hint '{hinted_intent}' did not match detected intent " + f"'{classification['intent_kind']}'." + ) + else: + try: + intent_result, reply_text = _execute_intent( + conn, + store=store, + user_account_id=user_account_id, + workspace_id=workspace_id, + source_message_id=message_id, + classification=classification, + source_message_text=source_text, + ) + status = "handled" + result_payload = { + "ok": True, + "intent_result": intent_result, + } + except execution_error_kinds as exc: + status = "failed" + result_payload = { + "ok": False, + "error": { + "code": "intent_execution_failed", + "type": exc.__class__.__name__, + "detail": str(exc), + }, + } + reply_text = f"Unable to process {classification['intent_kind']}: {exc}" + + dispatch_idempotency_key = hashlib.sha256( + f"telegram:handle:{message_id}:{intent_kind}".encode("utf-8") + ).hexdigest() + outbound_message, receipt = dispatch_telegram_message( + conn, + user_account_id=user_account_id, + workspace_id=workspace_id, + source_message_id=message_id, + text=reply_text, + dispatch_idempotency_key=dispatch_idempotency_key, + bot_token=bot_token, + ) + + result_payload["reply"] = { + "text": reply_text, + "outbound_message_id": str(outbound_message["id"]), + "delivery_receipt_id": str(receipt["id"]), + } + + persisted_intent = _upsert_chat_intent_result( + conn, + workspace_id=workspace_id, + channel_message_id=message_id, + channel_thread_id=source_message["channel_thread_id"], + intent_kind=intent_kind, + status=status, + intent_payload={ + **classification["intent_payload"], + "detected_intent_kind": classification["intent_kind"], + "intent_confidence": classification["confidence"], + "intent_hint": hinted_intent, + }, + result_payload=result_payload, + handled_at=_utcnow(), + ) + + return { + "message": { + "id": str(source_message["id"]), + "workspace_id": str(source_message["workspace_id"]), + "channel_thread_id": None + if source_message["channel_thread_id"] is None + else str(source_message["channel_thread_id"]), + "channel_identity_id": None + if source_message["channel_identity_id"] is None + else str(source_message["channel_identity_id"]), + "route_status": source_message["route_status"], + "message_text": source_message["message_text"], + "external_chat_id": source_message["external_chat_id"], + }, + "intent": _serialize_chat_intent(persisted_intent), + "outbound_message": serialize_channel_message(outbound_message), + "delivery_receipt": serialize_delivery_receipt(receipt), + } + + +def get_telegram_message_result( + conn, + *, + user_account_id: UUID, + workspace_id: UUID, + message_id: UUID, +) -> dict[str, object]: + intent = _fetch_latest_chat_intent_result( + conn, + user_account_id=user_account_id, + workspace_id=workspace_id, + message_id=message_id, + ) + return { + "message_id": str(message_id), + "intent": _serialize_chat_intent(intent), + } diff --git a/apps/api/src/alicebot_api/telegram_notifications.py b/apps/api/src/alicebot_api/telegram_notifications.py new file mode 100644 index 0000000..e172403 --- /dev/null +++ b/apps/api/src/alicebot_api/telegram_notifications.py @@ -0,0 +1,1783 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, date, datetime, time +import hashlib +import re +from typing import Any, Literal, TypedDict +from uuid import UUID +from zoneinfo import ZoneInfo + +from psycopg.types.json import Jsonb + +from alicebot_api.chief_of_staff import compile_chief_of_staff_priority_brief +from alicebot_api.continuity_open_loops import ( + compile_continuity_daily_brief, + compile_continuity_open_loop_dashboard, +) +from alicebot_api.contracts import ( + DEFAULT_CHIEF_OF_STAFF_PRIORITY_LIMIT, + DEFAULT_CONTINUITY_DAILY_BRIEF_LIMIT, + MAX_CHIEF_OF_STAFF_PRIORITY_LIMIT, + MAX_CONTINUITY_DAILY_BRIEF_LIMIT, + MAX_CONTINUITY_OPEN_LOOP_LIMIT, + ChiefOfStaffPriorityBriefRequestInput, + ContinuityDailyBriefRequestInput, + ContinuityOpenLoopDashboardQueryInput, +) +from alicebot_api.db import set_current_user +from alicebot_api.hosted_preferences import ( + DEFAULT_BRIEF_PREFERENCES, + DEFAULT_QUIET_HOURS, + DEFAULT_TIMEZONE, + ensure_user_preferences, + validate_timezone, +) +from alicebot_api.store import ContinuityStore +from alicebot_api.telegram_channels import ( + TELEGRAM_CHANNEL_TYPE, + TelegramDeliveryReceiptRow, + TelegramIdentityNotFoundError, + dispatch_telegram_workspace_message, + get_latest_linked_telegram_identity, + serialize_delivery_receipt, +) + + +_HHMM_PATTERN = re.compile(r"^(?:[01]\d|2[0-3]):[0-5]\d$") + +_TERMINAL_JOB_STATUSES = { + "delivered", + "simulated", + "suppressed_quiet_hours", + "suppressed_disabled", + "suppressed_outside_window", + "failed", +} + + +class TelegramNotificationPreferenceValidationError(ValueError): + """Raised when Telegram notification preferences are invalid.""" + + +class TelegramOpenLoopPromptNotFoundError(LookupError): + """Raised when a Telegram open-loop prompt id does not map to a scoped item.""" + + +class NotificationSubscriptionRow(TypedDict): + id: UUID + workspace_id: UUID + channel_type: str + channel_identity_id: UUID + notifications_enabled: bool + daily_brief_enabled: bool + daily_brief_window_start: str + open_loop_prompts_enabled: bool + waiting_for_prompts_enabled: bool + stale_prompts_enabled: bool + timezone: str + quiet_hours_enabled: bool + quiet_hours_start: str + quiet_hours_end: str + created_at: datetime + updated_at: datetime + + +class ContinuityBriefRow(TypedDict): + id: UUID + workspace_id: UUID + channel_type: str + channel_identity_id: UUID + brief_kind: str + assembly_version: str + summary: dict[str, Any] + brief_payload: dict[str, Any] + message_text: str + compiled_at: datetime + created_at: datetime + + +class DailyBriefJobRow(TypedDict): + id: UUID + workspace_id: UUID + channel_type: str + channel_identity_id: UUID + job_kind: str + prompt_kind: str | None + prompt_id: str | None + continuity_object_id: UUID | None + continuity_brief_id: UUID | None + schedule_slot: str + idempotency_key: str + due_at: datetime + status: str + suppression_reason: str | None + attempt_count: int + delivery_receipt_id: UUID | None + payload: dict[str, Any] + result_payload: dict[str, Any] + rollout_flag_state: str + support_evidence: dict[str, Any] + rate_limit_evidence: dict[str, Any] + incident_evidence: dict[str, Any] + attempted_at: datetime | None + completed_at: datetime | None + created_at: datetime + updated_at: datetime + + +class OpenLoopPromptCandidate(TypedDict): + prompt_id: str + prompt_kind: Literal["waiting_for", "stale"] + continuity_object_id: str + title: str + continuity_status: str + review_action_hint: Literal["still_blocked", "deferred"] + due_at: str + message_text: str + + +@dataclass(frozen=True) +class DeliveryPolicyEvaluation: + allowed: bool + suppression_status: str | None + reason: str + window_open: bool + quiet_hours_active: bool + timezone: str + local_time: str + + def as_dict(self) -> dict[str, object]: + return { + "allowed": self.allowed, + "suppression_status": self.suppression_status, + "reason": self.reason, + "window_open": self.window_open, + "quiet_hours_active": self.quiet_hours_active, + "timezone": self.timezone, + "local_time": self.local_time, + } + + +def _utcnow() -> datetime: + return datetime.now(UTC) + + +def _normalize_hhmm(value: object, *, field_name: str) -> str: + if not isinstance(value, str): + raise TelegramNotificationPreferenceValidationError(f"{field_name} must be a string in HH:MM format") + normalized = value.strip() + if _HHMM_PATTERN.fullmatch(normalized) is None: + raise TelegramNotificationPreferenceValidationError(f"{field_name} must use HH:MM 24-hour format") + return normalized + + +def _hhmm_to_minutes(hhmm: str) -> int: + hours, minutes = hhmm.split(":", maxsplit=1) + return int(hours) * 60 + int(minutes) + + +def _local_now(now: datetime, timezone_name: str) -> datetime: + return now.astimezone(ZoneInfo(timezone_name)) + + +def _quiet_hours_active(*, local_now: datetime, start: str, end: str) -> bool: + start_minutes = _hhmm_to_minutes(start) + end_minutes = _hhmm_to_minutes(end) + current_minutes = local_now.hour * 60 + local_now.minute + + if start_minutes == end_minutes: + return False + if start_minutes < end_minutes: + return start_minutes <= current_minutes < end_minutes + return current_minutes >= start_minutes or current_minutes < end_minutes + + +def _window_open(*, local_now: datetime, start: str) -> bool: + return (local_now.hour * 60 + local_now.minute) >= _hhmm_to_minutes(start) + + +def _daily_slot_for_now(*, now: datetime, timezone_name: str) -> str: + return _local_now(now, timezone_name).date().isoformat() + + +def _daily_due_at(*, slot: str, timezone_name: str, window_start: str) -> datetime: + local_date = date.fromisoformat(slot) + hour, minute = window_start.split(":", maxsplit=1) + local_due = datetime.combine( + local_date, + time(int(hour), int(minute), tzinfo=ZoneInfo(timezone_name)), + ) + return local_due.astimezone(UTC) + + +def _resolve_internal_idempotency_key( + *, + workspace_id: UUID, + job_kind: Literal["daily_brief", "open_loop_prompt"], + schedule_slot: str, + prompt_id: str | None, + client_idempotency_key: str | None, +) -> str: + if client_idempotency_key is None: + if job_kind == "daily_brief": + return f"telegram:daily-brief:{workspace_id}:{schedule_slot}" + if prompt_id is None: + raise TelegramNotificationPreferenceValidationError("prompt_id is required for open-loop prompt delivery") + return f"telegram:open-loop-prompt:{workspace_id}:{prompt_id}:{schedule_slot}" + + normalized_key = client_idempotency_key.strip() + if normalized_key == "": + raise TelegramNotificationPreferenceValidationError("idempotency_key must not be empty") + + digest_payload = ( + f"workspace={workspace_id}|job_kind={job_kind}|prompt_id={prompt_id or ''}|client_key={normalized_key}" + ) + digest = hashlib.sha256(digest_payload.encode("utf-8")).hexdigest() + return f"telegram:{job_kind}:custom:{digest}" + + +def _job_columns_sql() -> str: + return ( + "id, workspace_id, channel_type, channel_identity_id, job_kind, prompt_kind, prompt_id, " + "continuity_object_id, continuity_brief_id, schedule_slot, idempotency_key, due_at, status, " + "suppression_reason, attempt_count, delivery_receipt_id, payload, result_payload, " + "rollout_flag_state, support_evidence, rate_limit_evidence, incident_evidence, " + "attempted_at, completed_at, created_at, updated_at" + ) + + +def _receipt_columns_sql() -> str: + return ( + "id, workspace_id, channel_message_id, channel_type, status, provider_receipt_id, failure_code, " + "failure_detail, scheduled_job_id, scheduler_job_kind, scheduled_for, schedule_slot, " + "notification_policy, rollout_flag_state, support_evidence, rate_limit_evidence, " + "incident_evidence, recorded_at, created_at" + ) + + +def _brief_columns_sql() -> str: + return ( + "id, workspace_id, channel_type, channel_identity_id, brief_kind, assembly_version, " + "summary, brief_payload, message_text, compiled_at, created_at" + ) + + +def _resolve_linked_identity( + conn, + *, + user_account_id: UUID, + workspace_id: UUID, +): + identity = get_latest_linked_telegram_identity( + conn, + user_account_id=user_account_id, + workspace_id=workspace_id, + ) + if identity is None: + raise TelegramIdentityNotFoundError("telegram channel is not linked for this workspace") + return identity + + +def _subscription_defaults( + *, + timezone: str, + brief_preferences: dict[str, object], + quiet_hours: dict[str, object], +) -> dict[str, object]: + daily_brief = brief_preferences.get("daily_brief") + if not isinstance(daily_brief, dict): + daily_brief = DEFAULT_BRIEF_PREFERENCES["daily_brief"] + + quiet = quiet_hours if isinstance(quiet_hours, dict) else DEFAULT_QUIET_HOURS + + daily_brief_enabled = bool(daily_brief.get("enabled", False)) + daily_brief_window_start = _normalize_hhmm( + daily_brief.get("window_start", "07:00"), + field_name="daily_brief.window_start", + ) + + quiet_hours_enabled = bool(quiet.get("enabled", False)) + quiet_hours_start = _normalize_hhmm(quiet.get("start", "22:00"), field_name="quiet_hours.start") + quiet_hours_end = _normalize_hhmm(quiet.get("end", "07:00"), field_name="quiet_hours.end") + + return { + "notifications_enabled": True, + "daily_brief_enabled": daily_brief_enabled, + "daily_brief_window_start": daily_brief_window_start, + "open_loop_prompts_enabled": True, + "waiting_for_prompts_enabled": True, + "stale_prompts_enabled": True, + "timezone": validate_timezone(timezone), + "quiet_hours_enabled": quiet_hours_enabled, + "quiet_hours_start": quiet_hours_start, + "quiet_hours_end": quiet_hours_end, + } + + +def ensure_workspace_notification_subscription( + conn, + *, + user_account_id: UUID, + workspace_id: UUID, +) -> NotificationSubscriptionRow: + identity = _resolve_linked_identity( + conn, + user_account_id=user_account_id, + workspace_id=workspace_id, + ) + preferences = ensure_user_preferences(conn, user_account_id=user_account_id) + defaults = _subscription_defaults( + timezone=preferences.get("timezone", DEFAULT_TIMEZONE), + brief_preferences=preferences.get("brief_preferences", DEFAULT_BRIEF_PREFERENCES), + quiet_hours=preferences.get("quiet_hours", DEFAULT_QUIET_HOURS), + ) + + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO notification_subscriptions ( + workspace_id, + channel_type, + channel_identity_id, + notifications_enabled, + daily_brief_enabled, + daily_brief_window_start, + open_loop_prompts_enabled, + waiting_for_prompts_enabled, + stale_prompts_enabled, + timezone, + quiet_hours_enabled, + quiet_hours_start, + quiet_hours_end, + updated_at + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, clock_timestamp()) + ON CONFLICT (workspace_id, channel_type) DO UPDATE + SET channel_identity_id = EXCLUDED.channel_identity_id, + updated_at = clock_timestamp() + RETURNING id, + workspace_id, + channel_type, + channel_identity_id, + notifications_enabled, + daily_brief_enabled, + daily_brief_window_start, + open_loop_prompts_enabled, + waiting_for_prompts_enabled, + stale_prompts_enabled, + timezone, + quiet_hours_enabled, + quiet_hours_start, + quiet_hours_end, + created_at, + updated_at + """, + ( + workspace_id, + TELEGRAM_CHANNEL_TYPE, + identity["id"], + defaults["notifications_enabled"], + defaults["daily_brief_enabled"], + defaults["daily_brief_window_start"], + defaults["open_loop_prompts_enabled"], + defaults["waiting_for_prompts_enabled"], + defaults["stale_prompts_enabled"], + defaults["timezone"], + defaults["quiet_hours_enabled"], + defaults["quiet_hours_start"], + defaults["quiet_hours_end"], + ), + ) + row = cur.fetchone() + + if row is None: + raise RuntimeError("failed to ensure telegram notification subscription") + return row + + +def _validate_patch_fields(patch: dict[str, object]) -> dict[str, object]: + validated: dict[str, object] = {} + + boolean_fields = ( + "notifications_enabled", + "daily_brief_enabled", + "open_loop_prompts_enabled", + "waiting_for_prompts_enabled", + "stale_prompts_enabled", + "quiet_hours_enabled", + ) + for field_name in boolean_fields: + if field_name in patch: + value = patch[field_name] + if not isinstance(value, bool): + raise TelegramNotificationPreferenceValidationError(f"{field_name} must be a boolean") + validated[field_name] = value + + if "daily_brief_window_start" in patch: + validated["daily_brief_window_start"] = _normalize_hhmm( + patch["daily_brief_window_start"], + field_name="daily_brief_window_start", + ) + if "quiet_hours_start" in patch: + validated["quiet_hours_start"] = _normalize_hhmm( + patch["quiet_hours_start"], + field_name="quiet_hours_start", + ) + if "quiet_hours_end" in patch: + validated["quiet_hours_end"] = _normalize_hhmm( + patch["quiet_hours_end"], + field_name="quiet_hours_end", + ) + if "timezone" in patch: + value = patch["timezone"] + if not isinstance(value, str): + raise TelegramNotificationPreferenceValidationError("timezone must be a string") + validated["timezone"] = validate_timezone(value) + + return validated + + +def patch_workspace_notification_subscription( + conn, + *, + user_account_id: UUID, + workspace_id: UUID, + patch: dict[str, object], +) -> NotificationSubscriptionRow: + existing = ensure_workspace_notification_subscription( + conn, + user_account_id=user_account_id, + workspace_id=workspace_id, + ) + if not patch: + return existing + + validated = _validate_patch_fields(patch) + + merged = { + "notifications_enabled": existing["notifications_enabled"], + "daily_brief_enabled": existing["daily_brief_enabled"], + "daily_brief_window_start": existing["daily_brief_window_start"], + "open_loop_prompts_enabled": existing["open_loop_prompts_enabled"], + "waiting_for_prompts_enabled": existing["waiting_for_prompts_enabled"], + "stale_prompts_enabled": existing["stale_prompts_enabled"], + "timezone": existing["timezone"], + "quiet_hours_enabled": existing["quiet_hours_enabled"], + "quiet_hours_start": existing["quiet_hours_start"], + "quiet_hours_end": existing["quiet_hours_end"], + } + merged.update(validated) + + with conn.cursor() as cur: + cur.execute( + """ + UPDATE notification_subscriptions + SET notifications_enabled = %s, + daily_brief_enabled = %s, + daily_brief_window_start = %s, + open_loop_prompts_enabled = %s, + waiting_for_prompts_enabled = %s, + stale_prompts_enabled = %s, + timezone = %s, + quiet_hours_enabled = %s, + quiet_hours_start = %s, + quiet_hours_end = %s, + updated_at = clock_timestamp() + WHERE id = %s + RETURNING id, + workspace_id, + channel_type, + channel_identity_id, + notifications_enabled, + daily_brief_enabled, + daily_brief_window_start, + open_loop_prompts_enabled, + waiting_for_prompts_enabled, + stale_prompts_enabled, + timezone, + quiet_hours_enabled, + quiet_hours_start, + quiet_hours_end, + created_at, + updated_at + """, + ( + merged["notifications_enabled"], + merged["daily_brief_enabled"], + merged["daily_brief_window_start"], + merged["open_loop_prompts_enabled"], + merged["waiting_for_prompts_enabled"], + merged["stale_prompts_enabled"], + merged["timezone"], + merged["quiet_hours_enabled"], + merged["quiet_hours_start"], + merged["quiet_hours_end"], + existing["id"], + ), + ) + row = cur.fetchone() + + if row is None: + raise RuntimeError("failed to patch telegram notification subscription") + return row + + +def _evaluate_delivery_policy( + subscription: NotificationSubscriptionRow, + *, + mode: Literal["daily_brief", "open_loop_prompt"], + prompt_kind: Literal["waiting_for", "stale"] | None, + now: datetime, + force: bool, +) -> DeliveryPolicyEvaluation: + timezone_name = subscription["timezone"] + local_now = _local_now(now, timezone_name) + local_time = local_now.strftime("%Y-%m-%d %H:%M:%S %Z") + window_open = _window_open(local_now=local_now, start=subscription["daily_brief_window_start"]) + quiet_active = False + if subscription["quiet_hours_enabled"]: + quiet_active = _quiet_hours_active( + local_now=local_now, + start=subscription["quiet_hours_start"], + end=subscription["quiet_hours_end"], + ) + + if force: + return DeliveryPolicyEvaluation( + allowed=True, + suppression_status=None, + reason="forced delivery bypassed notification gating", + window_open=window_open, + quiet_hours_active=quiet_active, + timezone=timezone_name, + local_time=local_time, + ) + + if not subscription["notifications_enabled"]: + return DeliveryPolicyEvaluation( + allowed=False, + suppression_status="suppressed_disabled", + reason="telegram notifications are disabled", + window_open=window_open, + quiet_hours_active=quiet_active, + timezone=timezone_name, + local_time=local_time, + ) + + if mode == "daily_brief" and not subscription["daily_brief_enabled"]: + return DeliveryPolicyEvaluation( + allowed=False, + suppression_status="suppressed_disabled", + reason="daily brief notifications are disabled", + window_open=window_open, + quiet_hours_active=quiet_active, + timezone=timezone_name, + local_time=local_time, + ) + + if mode == "open_loop_prompt": + if not subscription["open_loop_prompts_enabled"]: + return DeliveryPolicyEvaluation( + allowed=False, + suppression_status="suppressed_disabled", + reason="open-loop prompts are disabled", + window_open=window_open, + quiet_hours_active=quiet_active, + timezone=timezone_name, + local_time=local_time, + ) + if prompt_kind == "waiting_for" and not subscription["waiting_for_prompts_enabled"]: + return DeliveryPolicyEvaluation( + allowed=False, + suppression_status="suppressed_disabled", + reason="waiting-for prompts are disabled", + window_open=window_open, + quiet_hours_active=quiet_active, + timezone=timezone_name, + local_time=local_time, + ) + if prompt_kind == "stale" and not subscription["stale_prompts_enabled"]: + return DeliveryPolicyEvaluation( + allowed=False, + suppression_status="suppressed_disabled", + reason="stale-item prompts are disabled", + window_open=window_open, + quiet_hours_active=quiet_active, + timezone=timezone_name, + local_time=local_time, + ) + + if not window_open: + return DeliveryPolicyEvaluation( + allowed=False, + suppression_status="suppressed_outside_window", + reason="current local time is before the configured daily brief window", + window_open=window_open, + quiet_hours_active=quiet_active, + timezone=timezone_name, + local_time=local_time, + ) + + if quiet_active: + return DeliveryPolicyEvaluation( + allowed=False, + suppression_status="suppressed_quiet_hours", + reason="delivery is deferred due to quiet hours", + window_open=window_open, + quiet_hours_active=quiet_active, + timezone=timezone_name, + local_time=local_time, + ) + + return DeliveryPolicyEvaluation( + allowed=True, + suppression_status=None, + reason="delivery allowed", + window_open=window_open, + quiet_hours_active=quiet_active, + timezone=timezone_name, + local_time=local_time, + ) + + +def serialize_notification_subscription( + row: NotificationSubscriptionRow, + *, + now: datetime | None = None, +) -> dict[str, object]: + effective_now = _utcnow() if now is None else now + local_now = _local_now(effective_now, row["timezone"]) + quiet_active = False + if row["quiet_hours_enabled"]: + quiet_active = _quiet_hours_active( + local_now=local_now, + start=row["quiet_hours_start"], + end=row["quiet_hours_end"], + ) + + return { + "id": str(row["id"]), + "workspace_id": str(row["workspace_id"]), + "channel_type": row["channel_type"], + "channel_identity_id": str(row["channel_identity_id"]), + "notifications_enabled": row["notifications_enabled"], + "daily_brief_enabled": row["daily_brief_enabled"], + "daily_brief_window_start": row["daily_brief_window_start"], + "open_loop_prompts_enabled": row["open_loop_prompts_enabled"], + "waiting_for_prompts_enabled": row["waiting_for_prompts_enabled"], + "stale_prompts_enabled": row["stale_prompts_enabled"], + "timezone": row["timezone"], + "quiet_hours": { + "enabled": row["quiet_hours_enabled"], + "start": row["quiet_hours_start"], + "end": row["quiet_hours_end"], + "active_now": quiet_active, + "local_time": local_now.isoformat(), + }, + "created_at": row["created_at"].isoformat(), + "updated_at": row["updated_at"].isoformat(), + } + + +def _serialize_brief_row(row: ContinuityBriefRow | None) -> dict[str, object] | None: + if row is None: + return None + return { + "id": str(row["id"]), + "workspace_id": str(row["workspace_id"]), + "channel_type": row["channel_type"], + "channel_identity_id": str(row["channel_identity_id"]), + "brief_kind": row["brief_kind"], + "assembly_version": row["assembly_version"], + "summary": row["summary"], + "brief_payload": row["brief_payload"], + "message_text": row["message_text"], + "compiled_at": row["compiled_at"].isoformat(), + "created_at": row["created_at"].isoformat(), + } + + +def _serialize_job( + row: DailyBriefJobRow, + *, + now: datetime, +) -> dict[str, object]: + return { + "id": str(row["id"]), + "workspace_id": str(row["workspace_id"]), + "channel_type": row["channel_type"], + "channel_identity_id": str(row["channel_identity_id"]), + "job_kind": row["job_kind"], + "prompt_kind": row["prompt_kind"], + "prompt_id": row["prompt_id"], + "continuity_object_id": None + if row["continuity_object_id"] is None + else str(row["continuity_object_id"]), + "continuity_brief_id": None + if row["continuity_brief_id"] is None + else str(row["continuity_brief_id"]), + "schedule_slot": row["schedule_slot"], + "idempotency_key": row["idempotency_key"], + "due_at": row["due_at"].isoformat(), + "status": row["status"], + "suppression_reason": row["suppression_reason"], + "attempt_count": row["attempt_count"], + "delivery_receipt_id": None + if row["delivery_receipt_id"] is None + else str(row["delivery_receipt_id"]), + "payload": row["payload"], + "result_payload": row["result_payload"], + "rollout_flag_state": row["rollout_flag_state"], + "support_evidence": row["support_evidence"], + "rate_limit_evidence": row["rate_limit_evidence"], + "incident_evidence": row["incident_evidence"], + "attempted_at": None if row["attempted_at"] is None else row["attempted_at"].isoformat(), + "completed_at": None if row["completed_at"] is None else row["completed_at"].isoformat(), + "created_at": row["created_at"].isoformat(), + "updated_at": row["updated_at"].isoformat(), + "is_due": row["status"] == "scheduled" and row["due_at"] <= now, + } + + +def _format_daily_brief_message( + *, + brief: dict[str, Any], + chief_brief: dict[str, Any], + timezone_name: str, + now: datetime, +) -> str: + local_day = _local_now(now, timezone_name).strftime("%Y-%m-%d") + waiting_count = int(brief["waiting_for_highlights"]["summary"]["total_count"]) + blocker_count = int(brief["blocker_highlights"]["summary"]["total_count"]) + stale_count = int(brief["stale_items"]["summary"]["total_count"]) + + next_item = brief["next_suggested_action"]["item"] + next_title = "None" + if isinstance(next_item, dict): + next_title = str(next_item.get("title", "None")) + + recommended = chief_brief.get("recommended_next_action") + recommended_title = "No chief-of-staff recommendation" + if isinstance(recommended, dict): + recommended_title = str(recommended.get("title", recommended_title)) + + return "\n".join( + [ + f"Daily Brief ({local_day})", + f"Waiting-for: {waiting_count}", + f"Blockers: {blocker_count}", + f"Stale: {stale_count}", + f"Next suggested action: {next_title}", + f"Chief-of-staff recommendation: {recommended_title}", + "Review open loops with /open-loops and /open-loop <id> done|deferred|still_blocked.", + ] + ) + + +def _build_daily_brief_bundle( + conn, + *, + user_account_id: UUID, + timezone_name: str, + now: datetime, +) -> dict[str, object]: + set_current_user(conn, user_account_id) + store = ContinuityStore(conn) + daily_payload = compile_continuity_daily_brief( + store, + user_id=user_account_id, + request=ContinuityDailyBriefRequestInput( + limit=min(DEFAULT_CONTINUITY_DAILY_BRIEF_LIMIT, MAX_CONTINUITY_DAILY_BRIEF_LIMIT), + ), + ) + chief_payload = compile_chief_of_staff_priority_brief( + store, + user_id=user_account_id, + request=ChiefOfStaffPriorityBriefRequestInput( + limit=min(DEFAULT_CHIEF_OF_STAFF_PRIORITY_LIMIT, MAX_CHIEF_OF_STAFF_PRIORITY_LIMIT), + ), + ) + + daily_brief = daily_payload["brief"] + chief_brief = chief_payload["brief"] + message_text = _format_daily_brief_message( + brief=daily_brief, + chief_brief=chief_brief, + timezone_name=timezone_name, + now=now, + ) + + chief_summary = { + "trust_confidence_posture": chief_brief["summary"].get("trust_confidence_posture"), + "follow_through_total_count": chief_brief["summary"].get("follow_through_total_count"), + "recommended_next_action": chief_brief.get("recommended_next_action"), + } + + return { + "brief": daily_brief, + "chief_of_staff_summary": chief_summary, + "message_text": message_text, + } + + +def _create_continuity_brief_row( + conn, + *, + workspace_id: UUID, + channel_identity_id: UUID, + brief_payload: dict[str, Any], + chief_summary: dict[str, Any], + message_text: str, + now: datetime, +) -> ContinuityBriefRow: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO continuity_briefs ( + workspace_id, + channel_type, + channel_identity_id, + brief_kind, + assembly_version, + summary, + brief_payload, + message_text, + compiled_at + ) + VALUES (%s, %s, %s, 'daily_brief', %s, %s, %s, %s, %s) + RETURNING + id, + workspace_id, + channel_type, + channel_identity_id, + brief_kind, + assembly_version, + summary, + brief_payload, + message_text, + compiled_at, + created_at + """, + ( + workspace_id, + TELEGRAM_CHANNEL_TYPE, + channel_identity_id, + brief_payload.get("assembly_version", "continuity_daily_brief_v0"), + Jsonb(chief_summary), + Jsonb(brief_payload), + message_text, + now, + ), + ) + row = cur.fetchone() + + if row is None: + raise RuntimeError("failed to persist continuity brief") + return row + + +def _fetch_job_by_idempotency( + conn, + *, + workspace_id: UUID, + job_kind: Literal["daily_brief", "open_loop_prompt"], + idempotency_key: str, +) -> DailyBriefJobRow | None: + with conn.cursor() as cur: + cur.execute( + f""" + SELECT {_job_columns_sql()} + FROM daily_brief_jobs + WHERE workspace_id = %s + AND channel_type = %s + AND job_kind = %s + AND idempotency_key = %s + LIMIT 1 + """, + (workspace_id, TELEGRAM_CHANNEL_TYPE, job_kind, idempotency_key), + ) + return cur.fetchone() + + +def _fetch_jobs_by_workspace( + conn, + *, + workspace_id: UUID, + limit: int, +) -> list[DailyBriefJobRow]: + with conn.cursor() as cur: + cur.execute( + f""" + SELECT {_job_columns_sql()} + FROM daily_brief_jobs + WHERE workspace_id = %s + AND channel_type = %s + ORDER BY due_at DESC, id DESC + LIMIT %s + """, + (workspace_id, TELEGRAM_CHANNEL_TYPE, limit), + ) + return cur.fetchall() + + +def _fetch_receipt_by_id( + conn, + *, + receipt_id: UUID, +) -> TelegramDeliveryReceiptRow | None: + with conn.cursor() as cur: + cur.execute( + f""" + SELECT {_receipt_columns_sql()} + FROM channel_delivery_receipts + WHERE id = %s + LIMIT 1 + """, + (receipt_id,), + ) + return cur.fetchone() + + +def _fetch_brief_by_id( + conn, + *, + brief_id: UUID, +) -> ContinuityBriefRow | None: + with conn.cursor() as cur: + cur.execute( + f""" + SELECT {_brief_columns_sql()} + FROM continuity_briefs + WHERE id = %s + LIMIT 1 + """, + (brief_id,), + ) + return cur.fetchone() + + +def _upsert_scheduled_job( + conn, + *, + workspace_id: UUID, + channel_identity_id: UUID, + job_kind: Literal["daily_brief", "open_loop_prompt"], + prompt_kind: Literal["waiting_for", "stale"] | None, + prompt_id: str | None, + continuity_object_id: UUID | None, + continuity_brief_id: UUID | None, + schedule_slot: str, + idempotency_key: str, + due_at: datetime, + payload: dict[str, object], +) -> DailyBriefJobRow: + with conn.cursor() as cur: + cur.execute( + f""" + INSERT INTO daily_brief_jobs ( + workspace_id, + channel_type, + channel_identity_id, + job_kind, + prompt_kind, + prompt_id, + continuity_object_id, + continuity_brief_id, + schedule_slot, + idempotency_key, + due_at, + status, + payload, + result_payload, + updated_at + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'scheduled', %s, '{{}}'::jsonb, clock_timestamp()) + ON CONFLICT (workspace_id, channel_type, idempotency_key) DO UPDATE + SET updated_at = daily_brief_jobs.updated_at + RETURNING {_job_columns_sql()} + """, + ( + workspace_id, + TELEGRAM_CHANNEL_TYPE, + channel_identity_id, + job_kind, + prompt_kind, + prompt_id, + continuity_object_id, + continuity_brief_id, + schedule_slot, + idempotency_key, + due_at, + Jsonb(payload), + ), + ) + row = cur.fetchone() + + if row is None: + raise RuntimeError("failed to upsert scheduled daily brief job") + return row + + +def _update_job_result( + conn, + *, + job_id: UUID, + status: str, + suppression_reason: str | None, + delivery_receipt_id: UUID | None, + continuity_brief_id: UUID | None, + result_payload: dict[str, object], + now: datetime, +) -> DailyBriefJobRow: + with conn.cursor() as cur: + cur.execute( + f""" + UPDATE daily_brief_jobs + SET status = %s, + suppression_reason = %s, + delivery_receipt_id = %s, + continuity_brief_id = COALESCE(%s, continuity_brief_id), + result_payload = %s, + attempted_at = %s, + completed_at = %s, + attempt_count = attempt_count + 1, + updated_at = clock_timestamp() + WHERE id = %s + RETURNING {_job_columns_sql()} + """, + ( + status, + suppression_reason, + delivery_receipt_id, + continuity_brief_id, + Jsonb(result_payload), + now, + now, + job_id, + ), + ) + row = cur.fetchone() + + if row is None: + raise RuntimeError("failed to update daily brief job result") + return row + + +def _build_open_loop_prompt_candidates( + conn, + *, + user_account_id: UUID, + now: datetime, + limit: int, +) -> list[OpenLoopPromptCandidate]: + set_current_user(conn, user_account_id) + bounded_limit = min(max(limit, 1), MAX_CONTINUITY_OPEN_LOOP_LIMIT) + dashboard = compile_continuity_open_loop_dashboard( + ContinuityStore(conn), + user_id=user_account_id, + request=ContinuityOpenLoopDashboardQueryInput(limit=bounded_limit), + )["dashboard"] + + def _build( + kind: Literal["waiting_for", "stale"], + *, + review_action_hint: Literal["still_blocked", "deferred"], + section_items: list[dict[str, object]], + ) -> list[OpenLoopPromptCandidate]: + prompts: list[OpenLoopPromptCandidate] = [] + for item in section_items: + continuity_object_id = str(item["id"]) + title = str(item.get("title", continuity_object_id)) + prompt_id = f"{kind}:{continuity_object_id}" + message_text = ( + f"Open-loop prompt ({kind}): {title}\n" + f"Review via /open-loop {continuity_object_id} {review_action_hint}." + ) + prompts.append( + { + "prompt_id": prompt_id, + "prompt_kind": kind, + "continuity_object_id": continuity_object_id, + "title": title, + "continuity_status": str(item.get("status", "active")), + "review_action_hint": review_action_hint, + "due_at": now.isoformat(), + "message_text": message_text, + } + ) + return prompts + + waiting_prompts = _build( + "waiting_for", + review_action_hint="still_blocked", + section_items=dashboard["waiting_for"]["items"], + ) + stale_prompts = _build( + "stale", + review_action_hint="deferred", + section_items=dashboard["stale"]["items"], + ) + return stale_prompts + waiting_prompts + + +def _prompt_key(prompt_kind: str, continuity_object_id: str) -> str: + payload = f"telegram:{prompt_kind}:{continuity_object_id}" + return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16] + + +def _fetch_latest_prompt_jobs( + conn, + *, + workspace_id: UUID, + prompt_ids: list[str], +) -> dict[str, DailyBriefJobRow]: + if not prompt_ids: + return {} + + with conn.cursor() as cur: + cur.execute( + f""" + SELECT {_job_columns_sql()} + FROM daily_brief_jobs + WHERE workspace_id = %s + AND channel_type = %s + AND job_kind = 'open_loop_prompt' + AND prompt_id = ANY(%s) + ORDER BY created_at DESC, id DESC + """, + (workspace_id, TELEGRAM_CHANNEL_TYPE, prompt_ids), + ) + rows = cur.fetchall() + + latest: dict[str, DailyBriefJobRow] = {} + for row in rows: + prompt_id = row["prompt_id"] + if prompt_id is None: + continue + if prompt_id not in latest: + latest[prompt_id] = row + return latest + + +def get_workspace_notification_preferences( + conn, + *, + user_account_id: UUID, + workspace_id: UUID, + now: datetime | None = None, +) -> dict[str, object]: + effective_now = _utcnow() if now is None else now + subscription = ensure_workspace_notification_subscription( + conn, + user_account_id=user_account_id, + workspace_id=workspace_id, + ) + return { + "workspace_id": str(workspace_id), + "notification_preferences": serialize_notification_subscription(subscription, now=effective_now), + } + + +def get_workspace_daily_brief_preview( + conn, + *, + user_account_id: UUID, + workspace_id: UUID, + now: datetime | None = None, +) -> dict[str, object]: + effective_now = _utcnow() if now is None else now + subscription = ensure_workspace_notification_subscription( + conn, + user_account_id=user_account_id, + workspace_id=workspace_id, + ) + bundle = _build_daily_brief_bundle( + conn, + user_account_id=user_account_id, + timezone_name=subscription["timezone"], + now=effective_now, + ) + policy = _evaluate_delivery_policy( + subscription, + mode="daily_brief", + prompt_kind=None, + now=effective_now, + force=False, + ) + return { + "workspace_id": str(workspace_id), + "brief": bundle["brief"], + "chief_of_staff_summary": bundle["chief_of_staff_summary"], + "preview_message_text": bundle["message_text"], + "delivery_policy": policy.as_dict(), + } + + +def _existing_delivery_artifacts( + conn, + *, + job: DailyBriefJobRow, +) -> tuple[dict[str, object] | None, dict[str, object] | None]: + receipt_payload: dict[str, object] | None = None + brief_payload: dict[str, object] | None = None + + if job["delivery_receipt_id"] is not None: + receipt = _fetch_receipt_by_id(conn, receipt_id=job["delivery_receipt_id"]) + if receipt is not None: + receipt_payload = serialize_delivery_receipt(receipt) + + if job["continuity_brief_id"] is not None: + brief = _fetch_brief_by_id(conn, brief_id=job["continuity_brief_id"]) + brief_payload = _serialize_brief_row(brief) + + return receipt_payload, brief_payload + + +def deliver_workspace_daily_brief( + conn, + *, + user_account_id: UUID, + workspace_id: UUID, + bot_token: str, + force: bool, + idempotency_key: str | None, + now: datetime | None = None, +) -> dict[str, object]: + effective_now = _utcnow() if now is None else now + subscription = ensure_workspace_notification_subscription( + conn, + user_account_id=user_account_id, + workspace_id=workspace_id, + ) + identity = _resolve_linked_identity( + conn, + user_account_id=user_account_id, + workspace_id=workspace_id, + ) + + schedule_slot = _daily_slot_for_now(now=effective_now, timezone_name=subscription["timezone"]) + due_at = _daily_due_at( + slot=schedule_slot, + timezone_name=subscription["timezone"], + window_start=subscription["daily_brief_window_start"], + ) + resolved_idempotency = _resolve_internal_idempotency_key( + workspace_id=workspace_id, + job_kind="daily_brief", + schedule_slot=schedule_slot, + prompt_id=None, + client_idempotency_key=idempotency_key, + ) + existing = _fetch_job_by_idempotency( + conn, + workspace_id=workspace_id, + job_kind="daily_brief", + idempotency_key=resolved_idempotency, + ) + if existing is not None and existing["status"] in _TERMINAL_JOB_STATUSES: + receipt_payload, brief_payload = _existing_delivery_artifacts(conn, job=existing) + return { + "workspace_id": str(workspace_id), + "job": _serialize_job(existing, now=effective_now), + "brief_record": brief_payload, + "delivery_receipt": receipt_payload, + "idempotent_replay": True, + } + + bundle = _build_daily_brief_bundle( + conn, + user_account_id=user_account_id, + timezone_name=subscription["timezone"], + now=effective_now, + ) + brief_record = _create_continuity_brief_row( + conn, + workspace_id=workspace_id, + channel_identity_id=identity["id"], + brief_payload=bundle["brief"], + chief_summary=bundle["chief_of_staff_summary"], + message_text=str(bundle["message_text"]), + now=effective_now, + ) + + policy = _evaluate_delivery_policy( + subscription, + mode="daily_brief", + prompt_kind=None, + now=effective_now, + force=force, + ) + + job = _upsert_scheduled_job( + conn, + workspace_id=workspace_id, + channel_identity_id=identity["id"], + job_kind="daily_brief", + prompt_kind=None, + prompt_id=None, + continuity_object_id=None, + continuity_brief_id=brief_record["id"], + schedule_slot=schedule_slot, + idempotency_key=resolved_idempotency, + due_at=due_at, + payload={ + "scope": "workspace", + "delivery_policy": policy.as_dict(), + "message_text_preview": bundle["message_text"], + }, + ) + + if job["status"] in _TERMINAL_JOB_STATUSES: + receipt_payload, brief_payload = _existing_delivery_artifacts(conn, job=job) + return { + "workspace_id": str(workspace_id), + "job": _serialize_job(job, now=effective_now), + "brief_record": brief_payload, + "delivery_receipt": receipt_payload, + "idempotent_replay": True, + } + + if policy.allowed: + outbound, receipt = dispatch_telegram_workspace_message( + conn, + user_account_id=user_account_id, + workspace_id=workspace_id, + text=str(bundle["message_text"]), + dispatch_idempotency_key=resolved_idempotency, + bot_token=bot_token, + dispatch_payload={"job_kind": "daily_brief", "schedule_slot": schedule_slot}, + scheduled_job_id=job["id"], + scheduler_job_kind="daily_brief", + scheduled_for=due_at, + schedule_slot=schedule_slot, + notification_policy=policy.as_dict(), + ) + del outbound + next_status = receipt["status"] + suppression_reason = None + else: + _, receipt = dispatch_telegram_workspace_message( + conn, + user_account_id=user_account_id, + workspace_id=workspace_id, + text=str(bundle["message_text"]), + dispatch_idempotency_key=resolved_idempotency, + bot_token=bot_token, + dispatch_payload={"job_kind": "daily_brief", "schedule_slot": schedule_slot}, + receipt_status_override="suppressed", + failure_code_override=policy.suppression_status, + failure_detail_override=policy.reason, + scheduled_job_id=job["id"], + scheduler_job_kind="daily_brief", + scheduled_for=due_at, + schedule_slot=schedule_slot, + notification_policy=policy.as_dict(), + ) + next_status = policy.suppression_status or "suppressed_disabled" + suppression_reason = policy.reason + + updated_job = _update_job_result( + conn, + job_id=job["id"], + status=next_status, + suppression_reason=suppression_reason, + delivery_receipt_id=receipt["id"], + continuity_brief_id=brief_record["id"], + result_payload={ + "delivery_policy": policy.as_dict(), + "delivery_receipt_id": str(receipt["id"]), + "status": next_status, + }, + now=effective_now, + ) + + return { + "workspace_id": str(workspace_id), + "job": _serialize_job(updated_job, now=effective_now), + "brief_record": _serialize_brief_row(brief_record), + "delivery_receipt": serialize_delivery_receipt(receipt), + "idempotent_replay": False, + } + + +def list_workspace_open_loop_prompts( + conn, + *, + user_account_id: UUID, + workspace_id: UUID, + now: datetime | None = None, + limit: int = 20, +) -> dict[str, object]: + effective_now = _utcnow() if now is None else now + subscription = ensure_workspace_notification_subscription( + conn, + user_account_id=user_account_id, + workspace_id=workspace_id, + ) + prompts = _build_open_loop_prompt_candidates( + conn, + user_account_id=user_account_id, + now=effective_now, + limit=limit, + ) + prompt_ids = [prompt["prompt_id"] for prompt in prompts] + latest_jobs = _fetch_latest_prompt_jobs(conn, workspace_id=workspace_id, prompt_ids=prompt_ids) + + today_slot = _daily_slot_for_now(now=effective_now, timezone_name=subscription["timezone"]) + + items: list[dict[str, object]] = [] + for prompt in prompts: + latest = latest_jobs.get(prompt["prompt_id"]) + items.append( + { + **prompt, + "prompt_key": _prompt_key(prompt["prompt_kind"], prompt["continuity_object_id"]), + "latest_job_status": None if latest is None else latest["status"], + "already_delivered_today": False + if latest is None + else latest["schedule_slot"] == today_slot and latest["status"] in _TERMINAL_JOB_STATUSES, + } + ) + + return { + "workspace_id": str(workspace_id), + "notification_preferences": serialize_notification_subscription(subscription, now=effective_now), + "items": items, + "summary": { + "total_count": len(items), + "returned_count": len(items), + "prompt_kind_order": ["stale", "waiting_for"], + "item_order": ["kind_order", "created_at_desc", "id_desc"], + }, + } + + +def _resolve_prompt_candidate( + conn, + *, + user_account_id: UUID, + prompt_id: str, + now: datetime, +) -> OpenLoopPromptCandidate: + normalized = prompt_id.strip() + if normalized == "": + raise TelegramOpenLoopPromptNotFoundError("prompt_id must not be empty") + + candidates = _build_open_loop_prompt_candidates( + conn, + user_account_id=user_account_id, + now=now, + limit=MAX_CONTINUITY_OPEN_LOOP_LIMIT, + ) + for candidate in candidates: + if candidate["prompt_id"] == normalized: + return candidate + + raise TelegramOpenLoopPromptNotFoundError(f"open-loop prompt {normalized!r} was not found") + + +def deliver_workspace_open_loop_prompt( + conn, + *, + user_account_id: UUID, + workspace_id: UUID, + prompt_id: str, + bot_token: str, + force: bool, + idempotency_key: str | None, + now: datetime | None = None, +) -> dict[str, object]: + effective_now = _utcnow() if now is None else now + subscription = ensure_workspace_notification_subscription( + conn, + user_account_id=user_account_id, + workspace_id=workspace_id, + ) + identity = _resolve_linked_identity( + conn, + user_account_id=user_account_id, + workspace_id=workspace_id, + ) + prompt = _resolve_prompt_candidate( + conn, + user_account_id=user_account_id, + prompt_id=prompt_id, + now=effective_now, + ) + + continuity_object_id = UUID(prompt["continuity_object_id"]) + schedule_slot = _daily_slot_for_now(now=effective_now, timezone_name=subscription["timezone"]) + due_at = _daily_due_at( + slot=schedule_slot, + timezone_name=subscription["timezone"], + window_start=subscription["daily_brief_window_start"], + ) + resolved_idempotency = _resolve_internal_idempotency_key( + workspace_id=workspace_id, + job_kind="open_loop_prompt", + schedule_slot=schedule_slot, + prompt_id=prompt["prompt_id"], + client_idempotency_key=idempotency_key, + ) + existing = _fetch_job_by_idempotency( + conn, + workspace_id=workspace_id, + job_kind="open_loop_prompt", + idempotency_key=resolved_idempotency, + ) + if existing is not None and existing["status"] in _TERMINAL_JOB_STATUSES: + receipt_payload, _ = _existing_delivery_artifacts(conn, job=existing) + return { + "workspace_id": str(workspace_id), + "job": _serialize_job(existing, now=effective_now), + "delivery_receipt": receipt_payload, + "prompt": prompt, + "idempotent_replay": True, + } + + policy = _evaluate_delivery_policy( + subscription, + mode="open_loop_prompt", + prompt_kind=prompt["prompt_kind"], + now=effective_now, + force=force, + ) + + job = _upsert_scheduled_job( + conn, + workspace_id=workspace_id, + channel_identity_id=identity["id"], + job_kind="open_loop_prompt", + prompt_kind=prompt["prompt_kind"], + prompt_id=prompt["prompt_id"], + continuity_object_id=continuity_object_id, + continuity_brief_id=None, + schedule_slot=schedule_slot, + idempotency_key=resolved_idempotency, + due_at=due_at, + payload={ + "prompt": prompt, + "delivery_policy": policy.as_dict(), + }, + ) + + if job["status"] in _TERMINAL_JOB_STATUSES: + receipt_payload, _ = _existing_delivery_artifacts(conn, job=job) + return { + "workspace_id": str(workspace_id), + "job": _serialize_job(job, now=effective_now), + "delivery_receipt": receipt_payload, + "prompt": prompt, + "idempotent_replay": True, + } + + if policy.allowed: + _, receipt = dispatch_telegram_workspace_message( + conn, + user_account_id=user_account_id, + workspace_id=workspace_id, + text=prompt["message_text"], + dispatch_idempotency_key=resolved_idempotency, + bot_token=bot_token, + dispatch_payload={ + "job_kind": "open_loop_prompt", + "prompt_id": prompt["prompt_id"], + "schedule_slot": schedule_slot, + }, + scheduled_job_id=job["id"], + scheduler_job_kind="open_loop_prompt", + scheduled_for=due_at, + schedule_slot=schedule_slot, + notification_policy=policy.as_dict(), + ) + next_status = receipt["status"] + suppression_reason = None + else: + _, receipt = dispatch_telegram_workspace_message( + conn, + user_account_id=user_account_id, + workspace_id=workspace_id, + text=prompt["message_text"], + dispatch_idempotency_key=resolved_idempotency, + bot_token=bot_token, + dispatch_payload={ + "job_kind": "open_loop_prompt", + "prompt_id": prompt["prompt_id"], + "schedule_slot": schedule_slot, + }, + receipt_status_override="suppressed", + failure_code_override=policy.suppression_status, + failure_detail_override=policy.reason, + scheduled_job_id=job["id"], + scheduler_job_kind="open_loop_prompt", + scheduled_for=due_at, + schedule_slot=schedule_slot, + notification_policy=policy.as_dict(), + ) + next_status = policy.suppression_status or "suppressed_disabled" + suppression_reason = policy.reason + + updated_job = _update_job_result( + conn, + job_id=job["id"], + status=next_status, + suppression_reason=suppression_reason, + delivery_receipt_id=receipt["id"], + continuity_brief_id=None, + result_payload={ + "delivery_policy": policy.as_dict(), + "delivery_receipt_id": str(receipt["id"]), + "status": next_status, + }, + now=effective_now, + ) + + return { + "workspace_id": str(workspace_id), + "job": _serialize_job(updated_job, now=effective_now), + "delivery_receipt": serialize_delivery_receipt(receipt), + "prompt": prompt, + "idempotent_replay": False, + } + + +def _materialize_due_jobs( + conn, + *, + user_account_id: UUID, + workspace_id: UUID, + subscription: NotificationSubscriptionRow, + now: datetime, + prompt_limit: int, +) -> None: + identity = _resolve_linked_identity( + conn, + user_account_id=user_account_id, + workspace_id=workspace_id, + ) + schedule_slot = _daily_slot_for_now(now=now, timezone_name=subscription["timezone"]) + due_at = _daily_due_at( + slot=schedule_slot, + timezone_name=subscription["timezone"], + window_start=subscription["daily_brief_window_start"], + ) + + daily_policy = _evaluate_delivery_policy( + subscription, + mode="daily_brief", + prompt_kind=None, + now=now, + force=False, + ) + if daily_policy.window_open and daily_policy.suppression_status != "suppressed_disabled": + daily_key = f"telegram:daily-brief:{workspace_id}:{schedule_slot}" + _upsert_scheduled_job( + conn, + workspace_id=workspace_id, + channel_identity_id=identity["id"], + job_kind="daily_brief", + prompt_kind=None, + prompt_id=None, + continuity_object_id=None, + continuity_brief_id=None, + schedule_slot=schedule_slot, + idempotency_key=daily_key, + due_at=due_at, + payload={"materialized_by": "scheduler_jobs", "delivery_policy": daily_policy.as_dict()}, + ) + + prompts = _build_open_loop_prompt_candidates( + conn, + user_account_id=user_account_id, + now=now, + limit=prompt_limit, + ) + for prompt in prompts: + prompt_policy = _evaluate_delivery_policy( + subscription, + mode="open_loop_prompt", + prompt_kind=prompt["prompt_kind"], + now=now, + force=False, + ) + if not prompt_policy.window_open: + continue + if prompt_policy.suppression_status == "suppressed_disabled": + continue + + prompt_key = f"telegram:open-loop-prompt:{workspace_id}:{prompt['prompt_id']}:{schedule_slot}" + _upsert_scheduled_job( + conn, + workspace_id=workspace_id, + channel_identity_id=identity["id"], + job_kind="open_loop_prompt", + prompt_kind=prompt["prompt_kind"], + prompt_id=prompt["prompt_id"], + continuity_object_id=UUID(prompt["continuity_object_id"]), + continuity_brief_id=None, + schedule_slot=schedule_slot, + idempotency_key=prompt_key, + due_at=due_at, + payload={ + "materialized_by": "scheduler_jobs", + "prompt": prompt, + "delivery_policy": prompt_policy.as_dict(), + }, + ) + + +def list_workspace_scheduler_jobs( + conn, + *, + user_account_id: UUID, + workspace_id: UUID, + now: datetime | None = None, + limit: int = 50, + prompt_limit: int = 20, +) -> dict[str, object]: + effective_now = _utcnow() if now is None else now + bounded_limit = min(max(limit, 1), 200) + bounded_prompt_limit = min(max(prompt_limit, 1), MAX_CONTINUITY_OPEN_LOOP_LIMIT) + + subscription = ensure_workspace_notification_subscription( + conn, + user_account_id=user_account_id, + workspace_id=workspace_id, + ) + _materialize_due_jobs( + conn, + user_account_id=user_account_id, + workspace_id=workspace_id, + subscription=subscription, + now=effective_now, + prompt_limit=bounded_prompt_limit, + ) + jobs = _fetch_jobs_by_workspace(conn, workspace_id=workspace_id, limit=bounded_limit) + serialized = [_serialize_job(row, now=effective_now) for row in jobs] + + return { + "workspace_id": str(workspace_id), + "notification_preferences": serialize_notification_subscription(subscription, now=effective_now), + "items": serialized, + "summary": { + "total_count": len(serialized), + "due_count": sum(1 for item in serialized if bool(item["is_due"])), + "order": ["due_at_desc", "id_desc"], + }, + } + + +__all__ = [ + "TelegramNotificationPreferenceValidationError", + "TelegramOpenLoopPromptNotFoundError", + "deliver_workspace_daily_brief", + "deliver_workspace_open_loop_prompt", + "ensure_workspace_notification_subscription", + "get_workspace_daily_brief_preview", + "get_workspace_notification_preferences", + "list_workspace_open_loop_prompts", + "list_workspace_scheduler_jobs", + "patch_workspace_notification_subscription", + "serialize_notification_subscription", +] diff --git a/apps/api/src/alicebot_api/tools.py b/apps/api/src/alicebot_api/tools.py new file mode 100644 index 0000000..fc2d7fc --- /dev/null +++ b/apps/api/src/alicebot_api/tools.py @@ -0,0 +1,560 @@ +from __future__ import annotations + +from dataclasses import dataclass +from uuid import UUID + +from alicebot_api.contracts import ( + DEFAULT_AGENT_PROFILE_ID, + TOOL_ALLOWLIST_EVALUATION_VERSION_V0, + TOOL_ROUTING_VERSION_V0, + TOOL_LIST_ORDER, + TRACE_KIND_TOOL_ALLOWLIST_EVALUATE, + TRACE_KIND_TOOL_ROUTE, + PolicyEvaluationRequestInput, + ToolAllowlistDecisionRecord, + ToolAllowlistEvaluationRequestInput, + ToolAllowlistEvaluationResponse, + ToolAllowlistEvaluationSummary, + ToolAllowlistReason, + ToolAllowlistTraceSummary, + ToolRoutingDecision, + ToolRoutingDecisionTracePayload, + ToolRoutingRequestInput, + ToolRoutingRequestTracePayload, + ToolRoutingResponse, + ToolRoutingSummary, + ToolRoutingSummaryTracePayload, + ToolRoutingTraceSummary, + ToolCreateInput, + ToolCreateResponse, + ToolDetailResponse, + ToolListResponse, + ToolListSummary, + ToolRecord, + isoformat_or_none, +) +from alicebot_api.policy import ( + evaluate_policy_against_context, + load_policy_evaluation_context, +) +from alicebot_api.store import ContinuityStore, ToolRow + + +class ToolValidationError(ValueError): + """Raised when a tool-registry request fails explicit validation.""" + + +class ToolNotFoundError(LookupError): + """Raised when a requested tool is not visible inside the current user scope.""" + + +class ToolAllowlistValidationError(ValueError): + """Raised when a tool-allowlist evaluation request fails explicit validation.""" + + +class ToolRoutingValidationError(ValueError): + """Raised when a tool-routing request fails explicit validation.""" + + +@dataclass(frozen=True, slots=True) +class ToolClassificationResult: + decision: str + tool: ToolRecord + reasons: list[ToolAllowlistReason] + matched_policy_id: str | None + + +def _serialize_tool(tool: ToolRow) -> ToolRecord: + return { + "id": str(tool["id"]), + "tool_key": tool["tool_key"], + "name": tool["name"], + "description": tool["description"], + "version": tool["version"], + "metadata_version": tool["metadata_version"], + "active": tool["active"], + "tags": list(tool["tags"]), + "action_hints": list(tool["action_hints"]), + "scope_hints": list(tool["scope_hints"]), + "domain_hints": list(tool["domain_hints"]), + "risk_hints": list(tool["risk_hints"]), + "metadata": tool["metadata"], + "created_at": tool["created_at"].isoformat(), + } + + +def _build_tool_reason( + *, + code: str, + source: str, + message: str, + tool_id: UUID, + policy_id: str | None = None, + consent_key: str | None = None, +) -> ToolAllowlistReason: + return { + "code": code, + "source": source, + "message": message, + "tool_id": str(tool_id), + "policy_id": policy_id, + "consent_key": consent_key, + } + + +def _metadata_match_reasons( + *, + tool: ToolRow, + request: ToolAllowlistEvaluationRequestInput, +) -> tuple[bool, list[ToolAllowlistReason]]: + reasons: list[ToolAllowlistReason] = [] + matched = True + + if request.action not in tool["action_hints"]: + matched = False + reasons.append( + _build_tool_reason( + code="tool_action_unsupported", + source="tool", + message=f"Tool '{tool['tool_key']}' does not declare support for action '{request.action}'.", + tool_id=tool["id"], + ) + ) + + if request.scope not in tool["scope_hints"]: + matched = False + reasons.append( + _build_tool_reason( + code="tool_scope_unsupported", + source="tool", + message=f"Tool '{tool['tool_key']}' does not declare support for scope '{request.scope}'.", + tool_id=tool["id"], + ) + ) + + if request.domain_hint is not None and tool["domain_hints"] and request.domain_hint not in tool["domain_hints"]: + matched = False + reasons.append( + _build_tool_reason( + code="tool_domain_mismatch", + source="tool", + message=( + f"Tool '{tool['tool_key']}' does not declare domain hint '{request.domain_hint}'." + ), + tool_id=tool["id"], + ) + ) + + if request.risk_hint is not None and tool["risk_hints"] and request.risk_hint not in tool["risk_hints"]: + matched = False + reasons.append( + _build_tool_reason( + code="tool_risk_mismatch", + source="tool", + message=f"Tool '{tool['tool_key']}' does not declare risk hint '{request.risk_hint}'.", + tool_id=tool["id"], + ) + ) + + if matched: + reasons.append( + _build_tool_reason( + code="tool_metadata_matched", + source="tool", + message="Tool metadata matched the requested action, scope, and optional hints.", + tool_id=tool["id"], + ) + ) + + return matched, reasons + + +def _policy_attributes( + *, + tool: ToolRow, + request: ToolAllowlistEvaluationRequestInput, +) -> dict[str, object]: + attributes: dict[str, object] = dict(request.attributes) + attributes["tool_key"] = tool["tool_key"] + attributes["tool_version"] = tool["version"] + attributes["metadata_version"] = tool["metadata_version"] + if request.domain_hint is not None: + attributes["domain_hint"] = request.domain_hint + if request.risk_hint is not None: + attributes["risk_hint"] = request.risk_hint + return attributes + + +def _classify_tool_request( + *, + tool: ToolRow, + request: ToolAllowlistEvaluationRequestInput, + policy_context, +) -> ToolClassificationResult: + metadata_matched, metadata_reasons = _metadata_match_reasons(tool=tool, request=request) + serialized_tool = _serialize_tool(tool) + + if not metadata_matched: + return ToolClassificationResult( + decision="denied", + tool=serialized_tool, + reasons=metadata_reasons, + matched_policy_id=None, + ) + + policy_decision = evaluate_policy_against_context( + policy_context, + request=PolicyEvaluationRequestInput( + thread_id=request.thread_id, + action=request.action, + scope=request.scope, + attributes=_policy_attributes(tool=tool, request=request), + ), + ) + reasons = metadata_reasons + [ + { + "code": reason["code"], + "source": reason["source"], + "message": reason["message"], + "tool_id": str(tool["id"]), + "policy_id": reason["policy_id"], + "consent_key": reason["consent_key"], + } + for reason in policy_decision.reasons + ] + return ToolClassificationResult( + decision={ + "allow": "allowed", + "deny": "denied", + "require_approval": "approval_required", + }[policy_decision.decision], + tool=serialized_tool, + reasons=reasons, + matched_policy_id=( + None if policy_decision.matched_policy is None else str(policy_decision.matched_policy["id"]) + ), + ) + + +def _decision_record_from_classification( + classification: ToolClassificationResult, +) -> ToolAllowlistDecisionRecord: + return { + "decision": classification.decision, + "tool": classification.tool, + "reasons": classification.reasons, + } + + +def _allowlist_trace_payload( + classification: ToolClassificationResult, +) -> dict[str, object]: + return { + "tool_id": classification.tool["id"], + "tool_key": classification.tool["tool_key"], + "tool_version": classification.tool["version"], + "decision": classification.decision, + "matched_policy_id": classification.matched_policy_id, + "reasons": classification.reasons, + } + + +def _allowlist_request_from_routing( + request: ToolRoutingRequestInput, +) -> ToolAllowlistEvaluationRequestInput: + return ToolAllowlistEvaluationRequestInput( + thread_id=request.thread_id, + action=request.action, + scope=request.scope, + domain_hint=request.domain_hint, + risk_hint=request.risk_hint, + attributes=request.attributes, + ) + + +def _routing_decision_from_allowlist(allowlist_decision: str) -> ToolRoutingDecision: + return { + "allowed": "ready", + "denied": "denied", + "approval_required": "approval_required", + }[allowlist_decision] + + +def create_tool_record( + store: ContinuityStore, + *, + user_id: UUID, + tool: ToolCreateInput, +) -> ToolCreateResponse: + del user_id + + created = store.create_tool( + tool_key=tool.tool_key, + name=tool.name, + description=tool.description, + version=tool.version, + metadata_version=tool.metadata_version, + active=tool.active, + tags=list(tool.tags), + action_hints=list(tool.action_hints), + scope_hints=list(tool.scope_hints), + domain_hints=list(tool.domain_hints), + risk_hints=list(tool.risk_hints), + metadata=tool.metadata, + ) + return {"tool": _serialize_tool(created)} + + +def list_tool_records( + store: ContinuityStore, + *, + user_id: UUID, +) -> ToolListResponse: + del user_id + + items = [_serialize_tool(tool) for tool in store.list_tools()] + summary: ToolListSummary = { + "total_count": len(items), + "order": list(TOOL_LIST_ORDER), + } + return { + "items": items, + "summary": summary, + } + + +def get_tool_record( + store: ContinuityStore, + *, + user_id: UUID, + tool_id: UUID, +) -> ToolDetailResponse: + del user_id + + tool = store.get_tool_optional(tool_id) + if tool is None: + raise ToolNotFoundError(f"tool {tool_id} was not found") + return {"tool": _serialize_tool(tool)} + + +def evaluate_tool_allowlist( + store: ContinuityStore, + *, + user_id: UUID, + request: ToolAllowlistEvaluationRequestInput, +) -> ToolAllowlistEvaluationResponse: + del user_id + + thread = store.get_thread_optional(request.thread_id) + if thread is None: + raise ToolAllowlistValidationError( + "thread_id must reference an existing thread owned by the user" + ) + + active_tools = store.list_active_tools() + policy_context = load_policy_evaluation_context( + store, + thread_agent_profile_id=thread.get("agent_profile_id", DEFAULT_AGENT_PROFILE_ID), + ) + + allowed: list[ToolAllowlistDecisionRecord] = [] + denied: list[ToolAllowlistDecisionRecord] = [] + approval_required: list[ToolAllowlistDecisionRecord] = [] + tool_trace_events: list[tuple[str, dict[str, object]]] = [] + + for tool in active_tools: + classification = _classify_tool_request( + tool=tool, + request=request, + policy_context=policy_context, + ) + decision_record = _decision_record_from_classification(classification) + + if classification.decision == "allowed": + allowed.append(decision_record) + elif classification.decision == "approval_required": + approval_required.append(decision_record) + else: + denied.append(decision_record) + + tool_trace_events.append( + ( + "tool.allowlist.decision", + _allowlist_trace_payload(classification), + ) + ) + + trace = store.create_trace( + user_id=thread["user_id"], + thread_id=thread["id"], + kind=TRACE_KIND_TOOL_ALLOWLIST_EVALUATE, + compiler_version=TOOL_ALLOWLIST_EVALUATION_VERSION_V0, + status="completed", + limits={ + "order": list(TOOL_LIST_ORDER), + "active_tool_count": len(active_tools), + "active_policy_count": len(policy_context.active_policies), + "consent_count": len(policy_context.consents_by_key), + }, + ) + + trace_events: list[tuple[str, dict[str, object]]] = [ + ( + "tool.allowlist.request", + { + "thread_id": str(request.thread_id), + "action": request.action, + "scope": request.scope, + "domain_hint": request.domain_hint, + "risk_hint": request.risk_hint, + "attributes": request.attributes, + }, + ), + ( + "tool.allowlist.order", + { + "order": list(TOOL_LIST_ORDER), + "tool_ids": [str(tool["id"]) for tool in active_tools], + }, + ), + *tool_trace_events, + ( + "tool.allowlist.summary", + { + "allowed_count": len(allowed), + "denied_count": len(denied), + "approval_required_count": len(approval_required), + }, + ), + ] + for sequence_no, (kind, payload) in enumerate(trace_events, start=1): + store.append_trace_event( + trace_id=trace["id"], + sequence_no=sequence_no, + kind=kind, + payload=payload, + ) + + summary: ToolAllowlistEvaluationSummary = { + "action": request.action, + "scope": request.scope, + "domain_hint": request.domain_hint, + "risk_hint": request.risk_hint, + "evaluated_tool_count": len(active_tools), + "allowed_count": len(allowed), + "denied_count": len(denied), + "approval_required_count": len(approval_required), + "order": list(TOOL_LIST_ORDER), + } + trace_summary: ToolAllowlistTraceSummary = { + "trace_id": str(trace["id"]), + "trace_event_count": len(trace_events), + } + return { + "allowed": allowed, + "denied": denied, + "approval_required": approval_required, + "summary": summary, + "trace": trace_summary, + } + + +def route_tool_invocation( + store: ContinuityStore, + *, + user_id: UUID, + request: ToolRoutingRequestInput, +) -> ToolRoutingResponse: + del user_id + + thread = store.get_thread_optional(request.thread_id) + if thread is None: + raise ToolRoutingValidationError( + "thread_id must reference an existing thread owned by the user" + ) + + tool = store.get_tool_optional(request.tool_id) + if tool is None or tool["active"] is not True: + raise ToolRoutingValidationError( + "tool_id must reference an existing active tool owned by the user" + ) + + policy_context = load_policy_evaluation_context( + store, + thread_agent_profile_id=thread.get("agent_profile_id", DEFAULT_AGENT_PROFILE_ID), + ) + classification = _classify_tool_request( + tool=tool, + request=_allowlist_request_from_routing(request), + policy_context=policy_context, + ) + routing_decision = _routing_decision_from_allowlist(classification.decision) + + trace = store.create_trace( + user_id=thread["user_id"], + thread_id=thread["id"], + kind=TRACE_KIND_TOOL_ROUTE, + compiler_version=TOOL_ROUTING_VERSION_V0, + status="completed", + limits={ + "order": list(TOOL_LIST_ORDER), + "evaluated_tool_count": 1, + "active_policy_count": len(policy_context.active_policies), + "consent_count": len(policy_context.consents_by_key), + }, + ) + + request_payload: ToolRoutingRequestTracePayload = request.as_payload() + decision_payload: ToolRoutingDecisionTracePayload = { + "tool_id": classification.tool["id"], + "tool_key": classification.tool["tool_key"], + "tool_version": classification.tool["version"], + "allowlist_decision": classification.decision, + "routing_decision": routing_decision, + "matched_policy_id": classification.matched_policy_id, + "reasons": classification.reasons, + } + summary_payload: ToolRoutingSummaryTracePayload = { + "decision": routing_decision, + "evaluated_tool_count": 1, + "active_policy_count": len(policy_context.active_policies), + "consent_count": len(policy_context.consents_by_key), + } + trace_events = [ + ("tool.route.request", request_payload), + ("tool.route.decision", decision_payload), + ("tool.route.summary", summary_payload), + ] + for sequence_no, (kind, payload) in enumerate(trace_events, start=1): + store.append_trace_event( + trace_id=trace["id"], + sequence_no=sequence_no, + kind=kind, + payload=payload, + ) + + summary: ToolRoutingSummary = { + "thread_id": str(request.thread_id), + "tool_id": classification.tool["id"], + "action": request.action, + "scope": request.scope, + "domain_hint": request.domain_hint, + "risk_hint": request.risk_hint, + "decision": routing_decision, + "evaluated_tool_count": 1, + "active_policy_count": len(policy_context.active_policies), + "consent_count": len(policy_context.consents_by_key), + "order": list(TOOL_LIST_ORDER), + } + trace_summary: ToolRoutingTraceSummary = { + "trace_id": str(trace["id"]), + "trace_event_count": len(trace_events), + } + return { + "request": request_payload, + "decision": routing_decision, + "tool": classification.tool, + "reasons": classification.reasons, + "summary": summary, + "trace": trace_summary, + } diff --git a/apps/api/src/alicebot_api/traces.py b/apps/api/src/alicebot_api/traces.py new file mode 100644 index 0000000..6d15719 --- /dev/null +++ b/apps/api/src/alicebot_api/traces.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from uuid import UUID + +from alicebot_api.contracts import ( + TRACE_REVIEW_EVENT_LIST_ORDER, + TRACE_REVIEW_LIST_ORDER, + TraceReviewDetailResponse, + TraceReviewEventListResponse, + TraceReviewEventListSummary, + TraceReviewEventRecord, + TraceReviewListResponse, + TraceReviewListSummary, + TraceReviewRecord, + TraceReviewSummaryRecord, +) +from alicebot_api.store import ContinuityStore, TraceEventRow, TraceReviewRow + + +class TraceNotFoundError(LookupError): + """Raised when a requested trace is not visible inside the current user scope.""" + + +def _serialize_trace_summary(trace: TraceReviewRow) -> TraceReviewSummaryRecord: + return { + "id": str(trace["id"]), + "thread_id": str(trace["thread_id"]), + "kind": trace["kind"], + "compiler_version": trace["compiler_version"], + "status": trace["status"], + "created_at": trace["created_at"].isoformat(), + "trace_event_count": trace["trace_event_count"], + } + + +def _serialize_trace(trace: TraceReviewRow) -> TraceReviewRecord: + summary = _serialize_trace_summary(trace) + return { + **summary, + "limits": trace["limits"], + } + + +def _serialize_trace_event(trace_event: TraceEventRow) -> TraceReviewEventRecord: + return { + "id": str(trace_event["id"]), + "trace_id": str(trace_event["trace_id"]), + "sequence_no": trace_event["sequence_no"], + "kind": trace_event["kind"], + "payload": trace_event["payload"], + "created_at": trace_event["created_at"].isoformat(), + } + + +def list_trace_records( + store: ContinuityStore, + *, + user_id: UUID, +) -> TraceReviewListResponse: + del user_id + + items = [_serialize_trace_summary(trace) for trace in store.list_trace_reviews()] + summary: TraceReviewListSummary = { + "total_count": len(items), + "order": list(TRACE_REVIEW_LIST_ORDER), + } + return { + "items": items, + "summary": summary, + } + + +def get_trace_record( + store: ContinuityStore, + *, + user_id: UUID, + trace_id: UUID, +) -> TraceReviewDetailResponse: + del user_id + + trace = store.get_trace_review_optional(trace_id) + if trace is None: + raise TraceNotFoundError(f"trace {trace_id} was not found") + + return {"trace": _serialize_trace(trace)} + + +def list_trace_event_records( + store: ContinuityStore, + *, + user_id: UUID, + trace_id: UUID, +) -> TraceReviewEventListResponse: + del user_id + + trace = store.get_trace_review_optional(trace_id) + if trace is None: + raise TraceNotFoundError(f"trace {trace_id} was not found") + + items = [_serialize_trace_event(trace_event) for trace_event in store.list_trace_events(trace_id)] + summary: TraceReviewEventListSummary = { + "trace_id": str(trace["id"]), + "total_count": len(items), + "order": list(TRACE_REVIEW_EVENT_LIST_ORDER), + } + return { + "items": items, + "summary": summary, + } diff --git a/apps/api/src/alicebot_api/workspaces.py b/apps/api/src/alicebot_api/workspaces.py new file mode 100644 index 0000000..d058fb0 --- /dev/null +++ b/apps/api/src/alicebot_api/workspaces.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +from pathlib import Path +from typing import cast +from uuid import UUID + +from alicebot_api.config import Settings +from alicebot_api.contracts import ( + TASK_WORKSPACE_LIST_ORDER, + TaskWorkspaceCreateInput, + TaskWorkspaceCreateResponse, + TaskWorkspaceDetailResponse, + TaskWorkspaceListResponse, + TaskWorkspaceRecord, + TaskWorkspaceStatus, +) +from alicebot_api.tasks import TaskNotFoundError +from alicebot_api.store import ContinuityStore, TaskWorkspaceRow + + +class TaskWorkspaceNotFoundError(LookupError): + """Raised when a task workspace record is not visible inside the current user scope.""" + + +class TaskWorkspaceAlreadyExistsError(RuntimeError): + """Raised when an active task workspace already exists for a task.""" + + +class TaskWorkspaceProvisioningError(RuntimeError): + """Raised when local workspace provisioning cannot satisfy rooted path rules.""" + + +def resolve_workspace_root(workspace_root: str) -> Path: + return Path(workspace_root).expanduser().resolve() + + +def build_task_workspace_path( + *, + workspace_root: Path, + user_id: UUID, + task_id: UUID, +) -> Path: + return workspace_root / str(user_id) / str(task_id) + + +def ensure_workspace_path_is_rooted( + *, + workspace_root: Path, + workspace_path: Path, +) -> None: + resolved_root = workspace_root.resolve() + resolved_path = workspace_path.resolve() + try: + resolved_path.relative_to(resolved_root) + except ValueError as exc: + raise TaskWorkspaceProvisioningError( + f"workspace path {resolved_path} escapes configured root {resolved_root}" + ) from exc + + +def serialize_task_workspace_row(row: TaskWorkspaceRow) -> TaskWorkspaceRecord: + return { + "id": str(row["id"]), + "task_id": str(row["task_id"]), + "status": cast(TaskWorkspaceStatus, row["status"]), + "local_path": row["local_path"], + "created_at": row["created_at"].isoformat(), + "updated_at": row["updated_at"].isoformat(), + } + + +def create_task_workspace_record( + store: ContinuityStore, + *, + settings: Settings, + user_id: UUID, + request: TaskWorkspaceCreateInput, +) -> TaskWorkspaceCreateResponse: + task = store.get_task_optional(request.task_id) + if task is None: + raise TaskNotFoundError(f"task {request.task_id} was not found") + + workspace_root = resolve_workspace_root(settings.task_workspace_root) + workspace_path = build_task_workspace_path( + workspace_root=workspace_root, + user_id=user_id, + task_id=request.task_id, + ) + ensure_workspace_path_is_rooted( + workspace_root=workspace_root, + workspace_path=workspace_path, + ) + + store.lock_task_workspaces(request.task_id) + existing_workspace = store.get_active_task_workspace_for_task_optional(request.task_id) + if existing_workspace is not None: + raise TaskWorkspaceAlreadyExistsError( + f"task {request.task_id} already has active workspace {existing_workspace['id']}" + ) + + try: + workspace_path.mkdir(parents=True, exist_ok=True) + except OSError as exc: + raise TaskWorkspaceProvisioningError( + f"workspace path {workspace_path} could not be provisioned" + ) from exc + + row = store.create_task_workspace( + task_id=request.task_id, + status=request.status, + local_path=str(workspace_path), + ) + return {"workspace": serialize_task_workspace_row(row)} + + +def list_task_workspace_records( + store: ContinuityStore, + *, + user_id: UUID, +) -> TaskWorkspaceListResponse: + del user_id + + items = [serialize_task_workspace_row(row) for row in store.list_task_workspaces()] + return { + "items": items, + "summary": { + "total_count": len(items), + "order": list(TASK_WORKSPACE_LIST_ORDER), + }, + } + + +def get_task_workspace_record( + store: ContinuityStore, + *, + user_id: UUID, + task_workspace_id: UUID, +) -> TaskWorkspaceDetailResponse: + del user_id + + row = store.get_task_workspace_optional(task_workspace_id) + if row is None: + raise TaskWorkspaceNotFoundError(f"task workspace {task_workspace_id} was not found") + return {"workspace": serialize_task_workspace_row(row)} diff --git a/apps/web/.gitkeep b/apps/web/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/web/.gitkeep @@ -0,0 +1 @@ + diff --git a/apps/web/app/admin/page.test.tsx b/apps/web/app/admin/page.test.tsx new file mode 100644 index 0000000..de5add7 --- /dev/null +++ b/apps/web/app/admin/page.test.tsx @@ -0,0 +1,20 @@ +import React from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import HostedAdminPage from "./page"; + +describe("HostedAdminPage", () => { + afterEach(() => { + cleanup(); + }); + + it("renders hosted admin launch-readiness controls", () => { + render(<HostedAdminPage />); + + expect(screen.getByRole("heading", { level: 1, name: "Hosted Admin" })).toBeInTheDocument(); + expect(screen.getByText("Hosted Beta Operations")).toBeInTheDocument(); + expect(screen.getByText(/rate-limit evidence for beta support/i)).toBeInTheDocument(); + expect(screen.getByText(/alice connect hosted beta operations/i)).toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/admin/page.tsx b/apps/web/app/admin/page.tsx new file mode 100644 index 0000000..eefc7b1 --- /dev/null +++ b/apps/web/app/admin/page.tsx @@ -0,0 +1,15 @@ +import { HostedAdminPanel } from "../../components/hosted-admin-panel"; +import { PageHeader } from "../../components/page-header"; + +export default function HostedAdminPage() { + return ( + <div className="page-stack"> + <PageHeader + eyebrow="Phase 10 Sprint 5" + title="Hosted Admin" + description="Inspect hosted workspace posture, delivery receipts, incidents, rollout flags, analytics, and rate-limit evidence for beta support." + /> + <HostedAdminPanel /> + </div> + ); +} diff --git a/apps/web/app/approvals/loading.tsx b/apps/web/app/approvals/loading.tsx new file mode 100644 index 0000000..2a777b6 --- /dev/null +++ b/apps/web/app/approvals/loading.tsx @@ -0,0 +1,51 @@ +import { PageHeader } from "../../components/page-header"; +import { SectionCard } from "../../components/section-card"; +import { StatusBadge } from "../../components/status-badge"; + +export default function Loading() { + return ( + <div className="page-stack" aria-busy="true"> + <PageHeader + eyebrow="Approvals" + title="Approval inbox and review" + description="Loading live approval records and the currently selected review detail." + meta={ + <div className="header-meta"> + <span className="subtle-chip">Loading route state</span> + </div> + } + /> + + <div className="split-layout"> + <SectionCard + eyebrow="Approval inbox" + title="Loading approvals" + description="The approval queue is being read from the current backing source." + className="loading-card" + > + <div className="detail-stack"> + <StatusBadge status="loading" label="Loading" /> + <div className="loading-placeholder loading-placeholder--card" /> + <div className="loading-placeholder loading-placeholder--card" /> + <div className="loading-placeholder loading-placeholder--card" /> + </div> + </SectionCard> + + <SectionCard + eyebrow="Approval detail" + title="Loading selected approval" + description="The inspector waits for the selected approval detail before actions become available." + className="loading-card" + > + <div className="detail-stack"> + <StatusBadge status="loading" label="Loading" /> + <div className="loading-placeholder loading-placeholder--line loading-placeholder--wide" /> + <div className="loading-placeholder loading-placeholder--line" /> + <div className="loading-placeholder loading-placeholder--line" /> + <div className="loading-placeholder loading-placeholder--button" /> + </div> + </SectionCard> + </div> + </div> + ); +} diff --git a/apps/web/app/approvals/page.tsx b/apps/web/app/approvals/page.tsx new file mode 100644 index 0000000..bf368c8 --- /dev/null +++ b/apps/web/app/approvals/page.tsx @@ -0,0 +1,127 @@ +import { ApprovalDetail } from "../../components/approval-detail"; +import { ApprovalList } from "../../components/approval-list"; +import { PageHeader } from "../../components/page-header"; +import { + combinePageModes, + getToolExecution, + getApiConfig, + getApprovalDetail, + hasLiveApiConfig, + listToolExecutions, + listApprovals, + pageModeLabel, + type ApiSource, +} from "../../lib/api"; +import { approvalFixtures, getFixtureApproval, getFixtureExecutionByApprovalId } from "../../lib/fixtures"; + +type SearchParams = Promise<Record<string, string | string[] | undefined>>; + +export default async function ApprovalsPage({ + searchParams, +}: { + searchParams?: SearchParams; +}) { + const params = (searchParams ? await searchParams : {}) as Record< + string, + string | string[] | undefined + >; + const selectedId = typeof params.approval === "string" ? params.approval : undefined; + const apiConfig = getApiConfig(); + const liveModeReady = hasLiveApiConfig(apiConfig); + + let items = approvalFixtures; + let listSource: ApiSource = "fixture"; + + if (liveModeReady) { + try { + const payload = await listApprovals(apiConfig.apiBaseUrl, apiConfig.userId); + items = payload.items; + listSource = "live"; + } catch { + items = approvalFixtures; + listSource = "fixture"; + } + } + + const selected = items.find((item) => item.id === selectedId) ?? items[0] ?? null; + let detail = selected; + let detailSource: ApiSource = selected ? listSource : "fixture"; + + if (selected && liveModeReady && listSource === "live") { + try { + const payload = await getApprovalDetail(apiConfig.apiBaseUrl, selected.id, apiConfig.userId); + detail = payload.approval; + detailSource = "live"; + } catch { + detail = getFixtureApproval(selected.id) ?? selected; + detailSource = detail === selected ? "live" : "fixture"; + } + } + + let execution = detail ? getFixtureExecutionByApprovalId(detail.id) : null; + let executionSource: ApiSource | null = execution ? "fixture" : null; + let executionUnavailableMessage: string | null = null; + + if (detail && liveModeReady && detailSource === "live") { + try { + const payload = await listToolExecutions(apiConfig.apiBaseUrl, apiConfig.userId); + const linked = payload.items.find((item) => item.approval_id === detail.id) ?? null; + + if (linked) { + try { + const detailPayload = await getToolExecution(apiConfig.apiBaseUrl, linked.id, apiConfig.userId); + execution = detailPayload.execution; + executionSource = "live"; + } catch { + execution = linked; + executionSource = "live"; + } + } else { + execution = null; + executionSource = null; + } + } catch { + if (detail.status === "approved") { + execution = null; + executionSource = null; + executionUnavailableMessage = + "The linked execution review could not be loaded from the configured backend."; + } + } + } + + const pageMode = combinePageModes( + listSource, + detail ? detailSource : null, + execution ? executionSource : null, + ); + + return ( + <div className="page-stack"> + <PageHeader + eyebrow="Approvals" + title="Approval inbox and review" + description="Review consequential actions in one calm split layout, then execute approved requests and inspect the resulting execution state without leaving the shell." + meta={ + <div className="header-meta"> + <span className="subtle-chip">{pageModeLabel(pageMode)}</span> + <span className="subtle-chip">{items.length} items</span> + </div> + } + /> + + <div className="split-layout"> + <ApprovalList items={items} selectedId={selected?.id} /> + <ApprovalDetail + initialApproval={detail} + detailSource={detailSource} + initialExecution={execution} + executionSource={executionSource} + executionUnavailableMessage={executionUnavailableMessage} + apiBaseUrl={apiConfig.apiBaseUrl} + userId={apiConfig.userId} + /> + </div> + </div> + ); +} diff --git a/apps/web/app/artifacts/loading.tsx b/apps/web/app/artifacts/loading.tsx new file mode 100644 index 0000000..9cd493a --- /dev/null +++ b/apps/web/app/artifacts/loading.tsx @@ -0,0 +1,77 @@ +import { PageHeader } from "../../components/page-header"; +import { SectionCard } from "../../components/section-card"; +import { StatusBadge } from "../../components/status-badge"; + +export default function Loading() { + return ( + <div className="page-stack" aria-busy="true"> + <PageHeader + eyebrow="Artifacts" + title="Artifact review workspace" + description="Loading artifact list, selected detail, linked workspace summary, and ordered chunk review." + meta={ + <div className="header-meta"> + <span className="subtle-chip">Loading route state</span> + </div> + } + /> + + <div className="artifact-layout"> + <SectionCard + eyebrow="Artifact list" + title="Loading persisted artifacts" + description="Artifact rows are loading from the current source." + className="loading-card" + > + <div className="detail-stack"> + <StatusBadge status="loading" label="Loading" /> + <div className="loading-placeholder loading-placeholder--card" /> + <div className="loading-placeholder loading-placeholder--card" /> + </div> + </SectionCard> + + <SectionCard + eyebrow="Selected artifact" + title="Loading selected artifact" + description="Metadata, ingestion status, and rooted path context are loading." + className="loading-card" + > + <div className="detail-stack"> + <StatusBadge status="loading" label="Loading" /> + <div className="loading-placeholder loading-placeholder--line loading-placeholder--wide" /> + <div className="loading-placeholder loading-placeholder--line" /> + <div className="loading-placeholder loading-placeholder--card" /> + </div> + </SectionCard> + </div> + + <div className="artifact-review-grid"> + <SectionCard + eyebrow="Linked workspace" + title="Loading workspace summary" + description="Task workspace linkage and rooted path context are loading." + className="loading-card" + > + <div className="detail-stack"> + <StatusBadge status="loading" label="Loading" /> + <div className="loading-placeholder loading-placeholder--card" /> + <div className="loading-placeholder loading-placeholder--line loading-placeholder--wide" /> + </div> + </SectionCard> + + <SectionCard + eyebrow="Chunk review" + title="Loading persisted chunks" + description="Ordered chunk rows and evidence text are loading." + className="loading-card" + > + <div className="detail-stack"> + <StatusBadge status="loading" label="Loading" /> + <div className="loading-placeholder loading-placeholder--card" /> + <div className="loading-placeholder loading-placeholder--card" /> + </div> + </SectionCard> + </div> + </div> + ); +} diff --git a/apps/web/app/artifacts/page.test.tsx b/apps/web/app/artifacts/page.test.tsx new file mode 100644 index 0000000..710d6ff --- /dev/null +++ b/apps/web/app/artifacts/page.test.tsx @@ -0,0 +1,161 @@ +import React from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import ArtifactsPage from "./page"; +import { taskArtifactFixtures } from "../../lib/fixtures"; + +const { + getApiConfigMock, + getTaskArtifactDetailMock, + getTaskWorkspaceDetailMock, + hasLiveApiConfigMock, + listTaskArtifactChunksMock, + listTaskArtifactsMock, +} = vi.hoisted(() => ({ + getApiConfigMock: vi.fn(), + getTaskArtifactDetailMock: vi.fn(), + getTaskWorkspaceDetailMock: vi.fn(), + hasLiveApiConfigMock: vi.fn(), + listTaskArtifactChunksMock: vi.fn(), + listTaskArtifactsMock: vi.fn(), +})); + +vi.mock("next/link", () => ({ + default: ({ + href, + children, + className, + "aria-current": ariaCurrent, + }: { + href: string; + children: React.ReactNode; + className?: string; + "aria-current"?: string; + }) => ( + <a href={href} className={className} aria-current={ariaCurrent}> + {children} + </a> + ), +})); + +vi.mock("../../lib/api", async () => { + const actual = await vi.importActual("../../lib/api"); + return { + ...actual, + getApiConfig: getApiConfigMock, + getTaskArtifactDetail: getTaskArtifactDetailMock, + getTaskWorkspaceDetail: getTaskWorkspaceDetailMock, + hasLiveApiConfig: hasLiveApiConfigMock, + listTaskArtifactChunks: listTaskArtifactChunksMock, + listTaskArtifacts: listTaskArtifactsMock, + }; +}); + +describe("ArtifactsPage", () => { + beforeEach(() => { + getApiConfigMock.mockReset(); + getTaskArtifactDetailMock.mockReset(); + getTaskWorkspaceDetailMock.mockReset(); + hasLiveApiConfigMock.mockReset(); + listTaskArtifactChunksMock.mockReset(); + listTaskArtifactsMock.mockReset(); + + getApiConfigMock.mockReturnValue({ + apiBaseUrl: "", + userId: "", + defaultThreadId: "", + defaultToolId: "", + }); + hasLiveApiConfigMock.mockReturnValue(false); + }); + + afterEach(() => { + cleanup(); + }); + + it("keeps route state explicit when live reads partially fail and workspace/chunks fall back to fixture", async () => { + const fixtureArtifact = taskArtifactFixtures[0]; + if (!fixtureArtifact) { + throw new Error("Expected at least one task artifact fixture."); + } + + getApiConfigMock.mockReturnValue({ + apiBaseUrl: "https://api.example.com", + userId: "user-1", + defaultThreadId: "thread-1", + defaultToolId: "tool-1", + }); + hasLiveApiConfigMock.mockReturnValue(true); + + listTaskArtifactsMock.mockResolvedValue({ + items: [ + { + id: fixtureArtifact.id, + task_id: fixtureArtifact.task_id, + task_workspace_id: fixtureArtifact.task_workspace_id, + status: "registered", + ingestion_status: "ingested", + relative_path: fixtureArtifact.relative_path, + media_type_hint: fixtureArtifact.media_type_hint, + created_at: fixtureArtifact.created_at, + updated_at: fixtureArtifact.updated_at, + }, + ], + summary: { + total_count: 1, + order: ["created_at_asc", "id_asc"], + }, + }); + + getTaskArtifactDetailMock.mockResolvedValue({ + artifact: { + id: fixtureArtifact.id, + task_id: fixtureArtifact.task_id, + task_workspace_id: fixtureArtifact.task_workspace_id, + status: "registered", + ingestion_status: "ingested", + relative_path: fixtureArtifact.relative_path, + media_type_hint: fixtureArtifact.media_type_hint, + created_at: fixtureArtifact.created_at, + updated_at: fixtureArtifact.updated_at, + }, + }); + + getTaskWorkspaceDetailMock.mockRejectedValue(new Error("workspace down")); + listTaskArtifactChunksMock.mockRejectedValue(new Error("chunks down")); + + render( + await ArtifactsPage({ + searchParams: Promise.resolve({ + artifact: fixtureArtifact.id, + }), + }), + ); + + expect(screen.getByText("Mixed fallback")).toBeInTheDocument(); + expect(screen.getByText("Live list")).toBeInTheDocument(); + expect(screen.getByText("Live detail")).toBeInTheDocument(); + expect(screen.getByText("Fixture workspace")).toBeInTheDocument(); + expect(screen.getByText("Fixture chunks")).toBeInTheDocument(); + expect(screen.getByText(/Live workspace read failed:\s*workspace down/i)).toBeInTheDocument(); + expect(screen.getByText(/Live chunk read failed:\s*chunks down/i)).toBeInTheDocument(); + + expect(listTaskArtifactsMock).toHaveBeenCalledWith("https://api.example.com", "user-1"); + expect(getTaskArtifactDetailMock).toHaveBeenCalledWith( + "https://api.example.com", + fixtureArtifact.id, + "user-1", + ); + expect(getTaskWorkspaceDetailMock).toHaveBeenCalledWith( + "https://api.example.com", + fixtureArtifact.task_workspace_id, + "user-1", + ); + expect(listTaskArtifactChunksMock).toHaveBeenCalledWith( + "https://api.example.com", + fixtureArtifact.id, + "user-1", + ); + }); +}); diff --git a/apps/web/app/artifacts/page.tsx b/apps/web/app/artifacts/page.tsx new file mode 100644 index 0000000..665d743 --- /dev/null +++ b/apps/web/app/artifacts/page.tsx @@ -0,0 +1,214 @@ +import { ArtifactChunkList } from "../../components/artifact-chunk-list"; +import { ArtifactDetail } from "../../components/artifact-detail"; +import { ArtifactList } from "../../components/artifact-list"; +import { ArtifactWorkspaceSummary } from "../../components/artifact-workspace-summary"; +import { PageHeader } from "../../components/page-header"; +import type { ApiSource, TaskArtifactRecord } from "../../lib/api"; +import { + combinePageModes, + getApiConfig, + getTaskArtifactDetail, + getTaskWorkspaceDetail, + hasLiveApiConfig, + listTaskArtifactChunks, + listTaskArtifacts, + pageModeLabel, +} from "../../lib/api"; +import { + getFixtureTaskArtifact, + getFixtureTaskArtifactChunkSummary, + getFixtureTaskArtifactChunks, + getFixtureTaskWorkspace, + taskArtifactFixtures, + taskArtifactListSummaryFixture, +} from "../../lib/fixtures"; + +type SearchParams = Promise<Record<string, string | string[] | undefined>>; + +function normalizeParam(value: string | string[] | undefined) { + if (Array.isArray(value)) { + return normalizeParam(value[0]); + } + + return value?.trim() ?? ""; +} + +function resolveSelectedArtifactId(requestedArtifactId: string, items: TaskArtifactRecord[]) { + if (!items.length) { + return ""; + } + + const availableIds = new Set(items.map((item) => item.id)); + if (requestedArtifactId && availableIds.has(requestedArtifactId)) { + return requestedArtifactId; + } + + return items[0]?.id ?? ""; +} + +export default async function ArtifactsPage({ + searchParams, +}: { + searchParams?: SearchParams; +}) { + const params = (searchParams ? await searchParams : {}) as Record< + string, + string | string[] | undefined + >; + const requestedArtifactId = normalizeParam(params.artifact); + const apiConfig = getApiConfig(); + const liveModeReady = hasLiveApiConfig(apiConfig); + + let artifacts = taskArtifactFixtures; + let artifactListSummary = taskArtifactListSummaryFixture; + let artifactListSource: ApiSource = "fixture"; + let artifactListUnavailableReason: string | undefined; + + if (liveModeReady) { + try { + const payload = await listTaskArtifacts(apiConfig.apiBaseUrl, apiConfig.userId); + artifacts = payload.items; + artifactListSummary = payload.summary; + artifactListSource = "live"; + } catch (error) { + artifactListUnavailableReason = + error instanceof Error ? error.message : "Task artifact list could not be loaded."; + } + } + + const selectedArtifactId = resolveSelectedArtifactId(requestedArtifactId, artifacts); + const selectedFromList = artifacts.find((item) => item.id === selectedArtifactId) ?? null; + + let selectedArtifact = selectedFromList; + let selectedArtifactSource: ApiSource | null = selectedArtifact ? artifactListSource : null; + let selectedArtifactUnavailableReason: string | undefined; + + if (selectedFromList && liveModeReady && artifactListSource === "live") { + try { + const payload = await getTaskArtifactDetail(apiConfig.apiBaseUrl, selectedFromList.id, apiConfig.userId); + selectedArtifact = payload.artifact; + selectedArtifactSource = "live"; + } catch (error) { + const fixtureArtifact = getFixtureTaskArtifact(selectedFromList.id); + if (fixtureArtifact) { + selectedArtifact = fixtureArtifact; + selectedArtifactSource = "fixture"; + } + selectedArtifactUnavailableReason = + error instanceof Error ? error.message : "Selected artifact detail could not be loaded."; + } + } + + let workspace = selectedArtifact ? getFixtureTaskWorkspace(selectedArtifact.task_workspace_id) : null; + let workspaceSource: ApiSource | "unavailable" | null = selectedArtifact + ? workspace + ? "fixture" + : "unavailable" + : null; + let workspaceUnavailableReason: string | undefined; + + if (selectedArtifact && liveModeReady && selectedArtifactSource === "live") { + try { + const payload = await getTaskWorkspaceDetail( + apiConfig.apiBaseUrl, + selectedArtifact.task_workspace_id, + apiConfig.userId, + ); + workspace = payload.workspace; + workspaceSource = "live"; + } catch (error) { + const fixtureWorkspace = getFixtureTaskWorkspace(selectedArtifact.task_workspace_id); + if (fixtureWorkspace) { + workspace = fixtureWorkspace; + workspaceSource = "fixture"; + } else { + workspace = null; + workspaceSource = "unavailable"; + } + workspaceUnavailableReason = + error instanceof Error ? error.message : "Linked task workspace detail could not be loaded."; + } + } + + let chunks = selectedArtifact ? getFixtureTaskArtifactChunks(selectedArtifact.id) : []; + let chunkSummary = selectedArtifact ? getFixtureTaskArtifactChunkSummary(selectedArtifact.id) : null; + let chunkSource: ApiSource | "unavailable" | null = selectedArtifact ? "fixture" : null; + let chunkUnavailableReason: string | undefined; + + if (selectedArtifact && liveModeReady && selectedArtifactSource === "live") { + try { + const payload = await listTaskArtifactChunks(apiConfig.apiBaseUrl, selectedArtifact.id, apiConfig.userId); + chunks = payload.items; + chunkSummary = payload.summary; + chunkSource = "live"; + } catch (error) { + const fixtureArtifact = getFixtureTaskArtifact(selectedArtifact.id); + if (fixtureArtifact) { + chunks = getFixtureTaskArtifactChunks(selectedArtifact.id); + chunkSummary = getFixtureTaskArtifactChunkSummary(selectedArtifact.id); + chunkSource = "fixture"; + } else { + chunks = []; + chunkSummary = null; + chunkSource = "unavailable"; + } + chunkUnavailableReason = + error instanceof Error ? error.message : "Artifact chunk rows could not be loaded."; + } + } + + const pageMode = combinePageModes( + artifactListSource, + selectedArtifactSource, + workspaceSource === "unavailable" ? null : workspaceSource, + chunkSource === "unavailable" ? null : chunkSource, + ); + + return ( + <div className="page-stack"> + <PageHeader + eyebrow="Artifacts" + title="Artifact review workspace" + description="Inspect persisted task artifacts in a bounded sequence: list first, selected detail second, then linked workspace and ordered chunk evidence." + meta={ + <div className="header-meta"> + <span className="subtle-chip">{pageModeLabel(pageMode)}</span> + <span className="subtle-chip">{artifacts.length} visible artifacts</span> + {selectedArtifact ? <span className="subtle-chip">Selected: {selectedArtifact.relative_path}</span> : null} + </div> + } + /> + + <div className="artifact-layout"> + <ArtifactList + artifacts={artifacts} + selectedArtifactId={selectedArtifact?.id} + summary={artifactListSummary} + source={artifactListSource} + unavailableReason={artifactListUnavailableReason} + /> + <ArtifactDetail + artifact={selectedArtifact} + source={selectedArtifactSource} + unavailableReason={selectedArtifactUnavailableReason} + /> + </div> + + <div className="artifact-review-grid"> + <ArtifactWorkspaceSummary + artifact={selectedArtifact} + workspace={workspace} + source={workspaceSource} + unavailableReason={workspaceUnavailableReason} + /> + <ArtifactChunkList + artifactId={selectedArtifact?.id ?? null} + chunks={chunks} + summary={chunkSummary} + source={chunkSource} + unavailableReason={chunkUnavailableReason} + /> + </div> + </div> + ); +} diff --git a/apps/web/app/calendar/loading.tsx b/apps/web/app/calendar/loading.tsx new file mode 100644 index 0000000..0001a42 --- /dev/null +++ b/apps/web/app/calendar/loading.tsx @@ -0,0 +1,93 @@ +import { PageHeader } from "../../components/page-header"; +import { SectionCard } from "../../components/section-card"; +import { StatusBadge } from "../../components/status-badge"; + +export default function Loading() { + return ( + <div className="page-stack" aria-busy="true"> + <PageHeader + eyebrow="Calendar" + title="Calendar account review workspace" + description="Loading connected account list, selected account detail, discovery controls, and single-event ingestion controls." + meta={ + <div className="header-meta"> + <span className="subtle-chip">Loading route state</span> + </div> + } + /> + + <div className="calendar-layout"> + <SectionCard + eyebrow="Account list" + title="Loading connected accounts" + description="Calendar account rows are loading from the current source." + className="loading-card" + > + <div className="detail-stack"> + <StatusBadge status="loading" label="Loading" /> + <div className="loading-placeholder loading-placeholder--card" /> + <div className="loading-placeholder loading-placeholder--card" /> + </div> + </SectionCard> + + <SectionCard + eyebrow="Selected account" + title="Loading selected account" + description="Account metadata and scope summary are loading." + className="loading-card" + > + <div className="detail-stack"> + <StatusBadge status="loading" label="Loading" /> + <div className="loading-placeholder loading-placeholder--line loading-placeholder--wide" /> + <div className="loading-placeholder loading-placeholder--line" /> + <div className="loading-placeholder loading-placeholder--card" /> + </div> + </SectionCard> + </div> + + <div className="calendar-action-grid"> + <SectionCard + eyebrow="Connect account" + title="Loading connect controls" + description="Bounded connect-account fields are loading." + className="loading-card" + > + <div className="detail-stack"> + <StatusBadge status="loading" label="Loading" /> + <div className="loading-placeholder loading-placeholder--line loading-placeholder--wide" /> + <div className="loading-placeholder loading-placeholder--card" /> + </div> + </SectionCard> + + <SectionCard + eyebrow="Event discovery" + title="Loading discovery controls" + description="Bounded event list filters and selected-event rows are loading." + className="loading-card" + > + <div className="detail-stack"> + <StatusBadge status="loading" label="Loading" /> + <div className="loading-placeholder loading-placeholder--line loading-placeholder--wide" /> + <div className="loading-placeholder loading-placeholder--line" /> + <div className="loading-placeholder loading-placeholder--card" /> + <div className="loading-placeholder loading-placeholder--card" /> + </div> + </SectionCard> + </div> + + <SectionCard + eyebrow="Ingest event" + title="Loading ingestion controls" + description="Selected-event and task-workspace ingestion controls are loading." + className="loading-card" + > + <div className="detail-stack"> + <StatusBadge status="loading" label="Loading" /> + <div className="loading-placeholder loading-placeholder--line loading-placeholder--wide" /> + <div className="loading-placeholder loading-placeholder--line" /> + <div className="loading-placeholder loading-placeholder--button" /> + </div> + </SectionCard> + </div> + ); +} diff --git a/apps/web/app/calendar/page.test.tsx b/apps/web/app/calendar/page.test.tsx new file mode 100644 index 0000000..acb226d --- /dev/null +++ b/apps/web/app/calendar/page.test.tsx @@ -0,0 +1,284 @@ +import React from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import CalendarPage from "./page"; + +const { + getApiConfigMock, + getCalendarAccountDetailMock, + hasLiveApiConfigMock, + listCalendarAccountsMock, + listCalendarEventsMock, + listTaskWorkspacesMock, +} = vi.hoisted(() => ({ + getApiConfigMock: vi.fn(), + getCalendarAccountDetailMock: vi.fn(), + hasLiveApiConfigMock: vi.fn(), + listCalendarAccountsMock: vi.fn(), + listCalendarEventsMock: vi.fn(), + listTaskWorkspacesMock: vi.fn(), +})); + +vi.mock("next/link", () => ({ + default: ({ + href, + children, + className, + "aria-current": ariaCurrent, + }: { + href: string; + children: React.ReactNode; + className?: string; + "aria-current"?: string; + }) => ( + <a href={href} className={className} aria-current={ariaCurrent}> + {children} + </a> + ), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + push: vi.fn(), + refresh: vi.fn(), + }), +})); + +vi.mock("../../lib/api", async () => { + const actual = await vi.importActual("../../lib/api"); + return { + ...actual, + getApiConfig: getApiConfigMock, + getCalendarAccountDetail: getCalendarAccountDetailMock, + hasLiveApiConfig: hasLiveApiConfigMock, + listCalendarAccounts: listCalendarAccountsMock, + listCalendarEvents: listCalendarEventsMock, + listTaskWorkspaces: listTaskWorkspacesMock, + }; +}); + +describe("CalendarPage", () => { + beforeEach(() => { + getApiConfigMock.mockReset(); + getCalendarAccountDetailMock.mockReset(); + hasLiveApiConfigMock.mockReset(); + listCalendarAccountsMock.mockReset(); + listCalendarEventsMock.mockReset(); + listTaskWorkspacesMock.mockReset(); + + getApiConfigMock.mockReturnValue({ + apiBaseUrl: "", + userId: "", + defaultThreadId: "", + defaultToolId: "", + }); + hasLiveApiConfigMock.mockReturnValue(false); + }); + + afterEach(() => { + cleanup(); + }); + + it("uses fixture discovery state when live API configuration is absent", async () => { + render(await CalendarPage({ searchParams: Promise.resolve({}) })); + + expect(screen.getByText("Fixture-backed")).toBeInTheDocument(); + expect(screen.getByText("Fixture events")).toBeInTheDocument(); + expect(screen.getByText("Select one discovered event before submitting ingestion.")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Ingest selected event" })).toBeDisabled(); + expect(listCalendarEventsMock).not.toHaveBeenCalled(); + }); + + it("renders live discovery state when live calendar event reads succeed", async () => { + getApiConfigMock.mockReturnValue({ + apiBaseUrl: "https://api.example.com", + userId: "user-1", + defaultThreadId: "thread-1", + defaultToolId: "tool-1", + }); + hasLiveApiConfigMock.mockReturnValue(true); + + listCalendarAccountsMock.mockResolvedValue({ + items: [ + { + id: "calendar-live-1", + provider: "google_calendar", + auth_kind: "oauth_access_token", + provider_account_id: "acct-live-001", + email_address: "owner@gmail.example", + display_name: "Owner", + scope: "https://www.googleapis.com/auth/calendar.readonly", + created_at: "2026-03-18T10:00:00Z", + updated_at: "2026-03-18T10:00:00Z", + }, + ], + summary: { + total_count: 1, + order: ["created_at_asc", "id_asc"], + }, + }); + + getCalendarAccountDetailMock.mockResolvedValue({ + account: { + id: "calendar-live-1", + provider: "google_calendar", + auth_kind: "oauth_access_token", + provider_account_id: "acct-live-001", + email_address: "owner@gmail.example", + display_name: "Owner", + scope: "https://www.googleapis.com/auth/calendar.readonly", + created_at: "2026-03-18T10:00:00Z", + updated_at: "2026-03-18T10:00:00Z", + }, + }); + + listCalendarEventsMock.mockResolvedValue({ + account: { + id: "calendar-live-1", + provider: "google_calendar", + auth_kind: "oauth_access_token", + provider_account_id: "acct-live-001", + email_address: "owner@gmail.example", + display_name: "Owner", + scope: "https://www.googleapis.com/auth/calendar.readonly", + created_at: "2026-03-18T10:00:00Z", + updated_at: "2026-03-18T10:00:00Z", + }, + items: [ + { + provider_event_id: "evt-live-1", + status: "confirmed", + summary: "Live planning", + start_time: "2026-03-20T09:00:00+00:00", + end_time: "2026-03-20T09:30:00+00:00", + html_link: "https://calendar.google.com/event?eid=evt-live-1", + updated_at: "2026-03-19T10:00:00+00:00", + }, + ], + summary: { + total_count: 1, + limit: 10, + order: ["start_time_asc", "provider_event_id_asc"], + time_min: "2026-03-20T00:00:00Z", + time_max: "2026-03-21T00:00:00Z", + }, + }); + + listTaskWorkspacesMock.mockResolvedValue({ + items: [ + { + id: "workspace-1", + task_id: "task-1", + status: "active", + local_path: "/tmp/task-workspaces/task-1", + created_at: "2026-03-18T10:00:00Z", + updated_at: "2026-03-18T10:00:00Z", + }, + ], + summary: { + total_count: 1, + order: ["created_at_asc", "id_asc"], + }, + }); + + render( + await CalendarPage({ + searchParams: Promise.resolve({ + account: "calendar-live-1", + event: "evt-live-1", + limit: "10", + time_min: "2026-03-20T00:00:00Z", + time_max: "2026-03-21T00:00:00Z", + }), + }), + ); + + expect(screen.getByText("Live events")).toBeInTheDocument(); + expect(screen.getAllByText("evt-live-1").length).toBeGreaterThan(0); + expect(listCalendarEventsMock).toHaveBeenCalledWith( + "https://api.example.com", + "calendar-live-1", + "user-1", + { + limit: 10, + timeMin: "2026-03-20T00:00:00Z", + timeMax: "2026-03-21T00:00:00Z", + }, + ); + }); + + it("falls back to fixture discovery state when live event discovery fails", async () => { + getApiConfigMock.mockReturnValue({ + apiBaseUrl: "https://api.example.com", + userId: "user-1", + defaultThreadId: "thread-1", + defaultToolId: "tool-1", + }); + hasLiveApiConfigMock.mockReturnValue(true); + + listCalendarAccountsMock.mockResolvedValue({ + items: [ + { + id: "c1c1c1c1-c1c1-4c1c-8c1c-c1c1c1c1c1c1", + provider: "google_calendar", + auth_kind: "oauth_access_token", + provider_account_id: "acct-owner-001", + email_address: "owner@gmail.example", + display_name: "Owner", + scope: "https://www.googleapis.com/auth/calendar.readonly", + created_at: "2026-03-18T10:00:00Z", + updated_at: "2026-03-18T10:00:00Z", + }, + ], + summary: { + total_count: 1, + order: ["created_at_asc", "id_asc"], + }, + }); + + getCalendarAccountDetailMock.mockResolvedValue({ + account: { + id: "c1c1c1c1-c1c1-4c1c-8c1c-c1c1c1c1c1c1", + provider: "google_calendar", + auth_kind: "oauth_access_token", + provider_account_id: "acct-owner-001", + email_address: "owner@gmail.example", + display_name: "Owner", + scope: "https://www.googleapis.com/auth/calendar.readonly", + created_at: "2026-03-18T10:00:00Z", + updated_at: "2026-03-18T10:00:00Z", + }, + }); + + listCalendarEventsMock.mockRejectedValue(new Error("calendar events could not be fetched")); + listTaskWorkspacesMock.mockResolvedValue({ + items: [ + { + id: "workspace-1", + task_id: "task-1", + status: "active", + local_path: "/tmp/task-workspaces/task-1", + created_at: "2026-03-18T10:00:00Z", + updated_at: "2026-03-18T10:00:00Z", + }, + ], + summary: { + total_count: 1, + order: ["created_at_asc", "id_asc"], + }, + }); + + render( + await CalendarPage({ + searchParams: Promise.resolve({ + account: "c1c1c1c1-c1c1-4c1c-8c1c-c1c1c1c1c1c1", + }), + }), + ); + + expect(screen.getByText("Mixed fallback")).toBeInTheDocument(); + expect(screen.getByText("Fixture events")).toBeInTheDocument(); + expect(screen.getAllByText(/Live event discovery read failed:/i)).toHaveLength(1); + }); +}); diff --git a/apps/web/app/calendar/page.tsx b/apps/web/app/calendar/page.tsx new file mode 100644 index 0000000..a9841c6 --- /dev/null +++ b/apps/web/app/calendar/page.tsx @@ -0,0 +1,287 @@ +import { CalendarAccountConnectForm } from "../../components/calendar-account-connect-form"; +import { CalendarAccountDetail } from "../../components/calendar-account-detail"; +import { CalendarAccountList } from "../../components/calendar-account-list"; +import { CalendarEventIngestForm } from "../../components/calendar-event-ingest-form"; +import { CalendarEventList } from "../../components/calendar-event-list"; +import { PageHeader } from "../../components/page-header"; +import type { + ApiSource, + CalendarAccountListSummary, + CalendarAccountRecord, + CalendarEventListSummary, + CalendarEventSummaryRecord, +} from "../../lib/api"; +import { + combinePageModes, + getApiConfig, + getCalendarAccountDetail, + hasLiveApiConfig, + listCalendarEvents, + listCalendarAccounts, + listTaskWorkspaces, + pageModeLabel, +} from "../../lib/api"; +import { + calendarAccountFixtures, + calendarAccountListSummaryFixture, + getFixtureCalendarAccount, + getFixtureCalendarEventList, + taskWorkspaceFixtures, + taskWorkspaceListSummaryFixture, +} from "../../lib/fixtures"; + +type SearchParams = Promise<Record<string, string | string[] | undefined>>; +const DEFAULT_CALENDAR_EVENT_LIMIT = 20; +const MAX_CALENDAR_EVENT_LIMIT = 50; + +function normalizeParam(value: string | string[] | undefined) { + if (Array.isArray(value)) { + return normalizeParam(value[0]); + } + + return value?.trim() ?? ""; +} + +function resolveSelectedAccountId(requestedAccountId: string, items: CalendarAccountRecord[]) { + if (!items.length) { + return ""; + } + + const availableIds = new Set(items.map((item) => item.id)); + if (requestedAccountId && availableIds.has(requestedAccountId)) { + return requestedAccountId; + } + + return items[0]?.id ?? ""; +} + +function resolveSelectedEventId(requestedEventId: string, items: CalendarEventSummaryRecord[]) { + if (!items.length) { + return ""; + } + + const availableIds = new Set(items.map((item) => item.provider_event_id)); + if (requestedEventId && availableIds.has(requestedEventId)) { + return requestedEventId; + } + + return ""; +} + +function resolveDiscoveryLimit(rawLimit: string) { + const parsed = Number.parseInt(rawLimit, 10); + if (!Number.isFinite(parsed)) { + return DEFAULT_CALENDAR_EVENT_LIMIT; + } + + return Math.max(1, Math.min(MAX_CALENDAR_EVENT_LIMIT, parsed)); +} + +export default async function CalendarPage({ + searchParams, +}: { + searchParams?: SearchParams; +}) { + const params = (searchParams ? await searchParams : {}) as Record< + string, + string | string[] | undefined + >; + const requestedAccountId = normalizeParam(params.account); + const requestedEventId = normalizeParam(params.event); + const requestedEventLimit = resolveDiscoveryLimit(normalizeParam(params.limit)); + const requestedTimeMin = normalizeParam(params.time_min); + const requestedTimeMax = normalizeParam(params.time_max); + const apiConfig = getApiConfig(); + const liveModeReady = hasLiveApiConfig(apiConfig); + + let accounts = calendarAccountFixtures; + let accountListSummary: CalendarAccountListSummary | null = calendarAccountListSummaryFixture; + let accountListSource: ApiSource | "unavailable" = "fixture"; + let accountListUnavailableReason: string | undefined; + + if (liveModeReady) { + try { + const payload = await listCalendarAccounts(apiConfig.apiBaseUrl, apiConfig.userId); + accounts = payload.items; + accountListSummary = payload.summary; + accountListSource = "live"; + } catch (error) { + accountListUnavailableReason = + error instanceof Error ? error.message : "Calendar account list could not be loaded."; + if (!calendarAccountFixtures.length) { + accounts = []; + accountListSummary = null; + accountListSource = "unavailable"; + } + } + } + + const selectedAccountId = resolveSelectedAccountId(requestedAccountId, accounts); + const selectedFromList = accounts.find((item) => item.id === selectedAccountId) ?? null; + + let selectedAccount = selectedFromList; + let selectedAccountSource: ApiSource | "unavailable" | null = + selectedAccount && accountListSource !== "unavailable" ? accountListSource : null; + let selectedAccountUnavailableReason: string | undefined; + + if (selectedFromList && liveModeReady && accountListSource === "live") { + try { + const payload = await getCalendarAccountDetail( + apiConfig.apiBaseUrl, + selectedFromList.id, + apiConfig.userId, + ); + selectedAccount = payload.account; + selectedAccountSource = "live"; + } catch (error) { + const fixtureAccount = getFixtureCalendarAccount(selectedFromList.id); + if (fixtureAccount) { + selectedAccount = fixtureAccount; + selectedAccountSource = "fixture"; + } else { + selectedAccountSource = "unavailable"; + } + selectedAccountUnavailableReason = + error instanceof Error ? error.message : "Selected Calendar account detail could not be loaded."; + } + } + + let discoveredEvents: CalendarEventSummaryRecord[] = []; + let discoveredEventSummary: CalendarEventListSummary | null = null; + let discoveredEventSource: ApiSource | "unavailable" | null = null; + let discoveredEventUnavailableReason: string | undefined; + + if (selectedAccount) { + const fixturePayload = getFixtureCalendarEventList(selectedAccount.id, { + limit: requestedEventLimit, + timeMin: requestedTimeMin, + timeMax: requestedTimeMax, + }); + discoveredEvents = fixturePayload.items; + discoveredEventSummary = fixturePayload.summary; + discoveredEventSource = "fixture"; + } + + if (selectedAccount && liveModeReady && selectedAccountSource === "live") { + try { + const payload = await listCalendarEvents( + apiConfig.apiBaseUrl, + selectedAccount.id, + apiConfig.userId, + { + limit: requestedEventLimit, + timeMin: requestedTimeMin || undefined, + timeMax: requestedTimeMax || undefined, + }, + ); + discoveredEvents = payload.items; + discoveredEventSummary = payload.summary; + discoveredEventSource = "live"; + } catch (error) { + discoveredEventUnavailableReason = + error instanceof Error ? error.message : "Calendar event discovery could not be loaded."; + + if (!discoveredEventSummary && discoveredEvents.length === 0) { + discoveredEventSource = "unavailable"; + } + } + } + + const selectedEventId = resolveSelectedEventId(requestedEventId, discoveredEvents); + + let taskWorkspaces = taskWorkspaceFixtures; + let taskWorkspaceSummary = taskWorkspaceListSummaryFixture; + let taskWorkspaceSource: ApiSource | "unavailable" = "fixture"; + let taskWorkspaceUnavailableReason: string | undefined; + + if (liveModeReady) { + try { + const payload = await listTaskWorkspaces(apiConfig.apiBaseUrl, apiConfig.userId); + taskWorkspaces = payload.items; + taskWorkspaceSummary = payload.summary; + taskWorkspaceSource = "live"; + } catch (error) { + taskWorkspaceUnavailableReason = + error instanceof Error ? error.message : "Task workspace list could not be loaded."; + if (!taskWorkspaceFixtures.length) { + taskWorkspaceSource = "unavailable"; + } + } + } + + const pageMode = combinePageModes( + accountListSource === "unavailable" ? null : accountListSource, + selectedAccountSource === "unavailable" ? null : selectedAccountSource, + discoveredEventSource === "unavailable" ? null : discoveredEventSource, + taskWorkspaceSource === "unavailable" ? null : taskWorkspaceSource, + ); + + return ( + <div className="page-stack"> + <PageHeader + eyebrow="Calendar" + title="Calendar account review workspace" + description="Review connected accounts first, inspect one selected account second, then run explicit connect or single-event ingestion actions." + meta={ + <div className="header-meta"> + <span className="subtle-chip">{pageModeLabel(pageMode)}</span> + <span className="subtle-chip">{accounts.length} visible accounts</span> + <span className="subtle-chip">{discoveredEventSummary?.total_count ?? 0} discovered events</span> + <span className="subtle-chip">{taskWorkspaceSummary.total_count} task workspaces</span> + {selectedAccount ? ( + <span className="subtle-chip">Selected: {selectedAccount.email_address}</span> + ) : null} + </div> + } + /> + + <div className="calendar-layout"> + <CalendarAccountList + accounts={accounts} + selectedAccountId={selectedAccount?.id} + summary={accountListSummary} + source={accountListSource} + unavailableReason={accountListUnavailableReason} + /> + <CalendarAccountDetail + account={selectedAccount} + source={selectedAccountSource} + unavailableReason={selectedAccountUnavailableReason} + /> + </div> + + <div className="calendar-action-grid"> + <CalendarAccountConnectForm + apiBaseUrl={apiConfig.apiBaseUrl} + userId={apiConfig.userId} + /> + <CalendarEventList + account={selectedAccount} + source={discoveredEventSource} + events={discoveredEvents} + summary={discoveredEventSummary} + selectedEventId={selectedEventId} + unavailableReason={discoveredEventUnavailableReason} + limit={requestedEventLimit} + timeMin={requestedTimeMin} + timeMax={requestedTimeMax} + /> + </div> + + <CalendarEventIngestForm + account={selectedAccount} + accountSource={selectedAccountSource} + selectedProviderEventId={selectedEventId} + selectedEventSource={discoveredEventSource} + taskWorkspaces={taskWorkspaces} + taskWorkspaceSource={taskWorkspaceSource} + apiBaseUrl={apiConfig.apiBaseUrl} + userId={apiConfig.userId} + /> + + {taskWorkspaceUnavailableReason ? ( + <p className="responsive-note">Live task workspace list read failed: {taskWorkspaceUnavailableReason}</p> + ) : null} + </div> + ); +} diff --git a/apps/web/app/chat/page.test.tsx b/apps/web/app/chat/page.test.tsx new file mode 100644 index 0000000..77110bf --- /dev/null +++ b/apps/web/app/chat/page.test.tsx @@ -0,0 +1,404 @@ +import React from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import ChatPage from "./page"; + +const { + getApiConfigMock, + getThreadResumptionBriefMock, + getThreadDetailMock, + getThreadEventsMock, + getThreadSessionsMock, + hasLiveApiConfigMock, + listAgentProfilesMock, + listThreadsMock, +} = vi.hoisted(() => ({ + getApiConfigMock: vi.fn(), + getThreadResumptionBriefMock: vi.fn(), + getThreadDetailMock: vi.fn(), + getThreadEventsMock: vi.fn(), + getThreadSessionsMock: vi.fn(), + hasLiveApiConfigMock: vi.fn(), + listAgentProfilesMock: vi.fn(), + listThreadsMock: vi.fn(), +})); + +vi.mock("next/link", () => ({ + default: ({ + href, + children, + className, + "aria-current": ariaCurrent, + }: { + href: string; + children: React.ReactNode; + className?: string; + "aria-current"?: string; + }) => ( + <a href={href} className={className} aria-current={ariaCurrent}> + {children} + </a> + ), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + push: vi.fn(), + refresh: vi.fn(), + }), +})); + +vi.mock("../../lib/api", async () => { + const actual = await vi.importActual("../../lib/api"); + return { + ...actual, + getApiConfig: getApiConfigMock, + getThreadResumptionBrief: getThreadResumptionBriefMock, + getThreadDetail: getThreadDetailMock, + getThreadEvents: getThreadEventsMock, + getThreadSessions: getThreadSessionsMock, + hasLiveApiConfig: hasLiveApiConfigMock, + listAgentProfiles: listAgentProfilesMock, + listThreads: listThreadsMock, + }; +}); + +function buildResumptionBriefFixture() { + return { + brief: { + assembly_version: "resumption_brief_v0", + thread: { + id: "thread-1", + title: "Gamma thread", + agent_profile_id: "assistant_default", + created_at: "2026-03-17T10:00:00Z", + updated_at: "2026-03-17T10:00:00Z", + }, + conversation: { + items: [], + summary: { + limit: 8, + returned_count: 0, + total_count: 0, + order: ["sequence_no_asc"], + kinds: ["message.user", "message.assistant"], + }, + }, + open_loops: { + items: [], + summary: { + limit: 5, + returned_count: 0, + total_count: 0, + order: ["opened_at_desc", "created_at_desc", "id_desc"], + }, + }, + memory_highlights: { + items: [], + summary: { + limit: 5, + returned_count: 0, + total_count: 0, + order: ["updated_at_asc", "created_at_asc", "id_asc"], + }, + }, + workflow: null, + sources: ["threads", "events", "open_loops", "memories"], + }, + }; +} + +describe("ChatPage", () => { + beforeEach(() => { + getApiConfigMock.mockReset(); + getThreadResumptionBriefMock.mockReset(); + getThreadDetailMock.mockReset(); + getThreadEventsMock.mockReset(); + getThreadSessionsMock.mockReset(); + hasLiveApiConfigMock.mockReset(); + listAgentProfilesMock.mockReset(); + listThreadsMock.mockReset(); + }); + + afterEach(() => { + cleanup(); + }); + + it("does not seed fixture assistant history when live API configuration is present", async () => { + getApiConfigMock.mockReturnValue({ + apiBaseUrl: "https://api.example.com", + userId: "user-1", + defaultThreadId: "thread-1", + defaultToolId: "tool-1", + }); + hasLiveApiConfigMock.mockReturnValue(true); + listAgentProfilesMock.mockResolvedValue({ + items: [ + { + id: "assistant_default", + name: "Assistant Default", + description: "General-purpose assistant profile for baseline conversations.", + }, + { + id: "coach_default", + name: "Coach Default", + description: "Coaching-oriented profile focused on guidance and accountability.", + }, + ], + summary: { total_count: 2, order: ["id_asc"] }, + }); + listThreadsMock.mockResolvedValue({ + items: [ + { + id: "thread-1", + title: "Gamma thread", + agent_profile_id: "assistant_default", + created_at: "2026-03-17T10:00:00Z", + updated_at: "2026-03-17T10:00:00Z", + }, + ], + summary: { total_count: 1, order: ["created_at_desc", "id_desc"] }, + }); + getThreadDetailMock.mockResolvedValue({ + thread: { + id: "thread-1", + title: "Gamma thread", + agent_profile_id: "assistant_default", + created_at: "2026-03-17T10:00:00Z", + updated_at: "2026-03-17T10:00:00Z", + }, + }); + getThreadSessionsMock.mockResolvedValue({ + items: [], + summary: { thread_id: "thread-1", total_count: 0, order: [] }, + }); + getThreadEventsMock.mockResolvedValue({ + items: [], + summary: { thread_id: "thread-1", total_count: 0, order: [] }, + }); + getThreadResumptionBriefMock.mockResolvedValue(buildResumptionBriefFixture()); + + render(await ChatPage({ searchParams: Promise.resolve({}) })); + + expect(screen.getByText("Live continuity enabled")).toBeInTheDocument(); + expect(screen.getByText("Selected: Gamma thread")).toBeInTheDocument(); + expect(screen.getAllByText("Profile Assistant Default").length).toBeGreaterThan(0); + expect(screen.getByText("No assistant replies yet")).toBeInTheDocument(); + expect(screen.queryByText("Fixture response preview")).not.toBeInTheDocument(); + expect(screen.queryByText(/What do I need to know about the last Vitamin D request/i)).not.toBeInTheDocument(); + expect(listAgentProfilesMock).toHaveBeenCalledWith("https://api.example.com"); + }); + + it("does not seed fixture governed-request history when live API configuration is present", async () => { + getApiConfigMock.mockReturnValue({ + apiBaseUrl: "https://api.example.com", + userId: "user-1", + defaultThreadId: "thread-1", + defaultToolId: "tool-1", + }); + hasLiveApiConfigMock.mockReturnValue(true); + listAgentProfilesMock.mockResolvedValue({ + items: [ + { + id: "assistant_default", + name: "Assistant Default", + description: "General-purpose assistant profile for baseline conversations.", + }, + ], + summary: { total_count: 1, order: ["id_asc"] }, + }); + listThreadsMock.mockResolvedValue({ + items: [ + { + id: "thread-1", + title: "Gamma thread", + agent_profile_id: "assistant_default", + created_at: "2026-03-17T10:00:00Z", + updated_at: "2026-03-17T10:00:00Z", + }, + ], + summary: { total_count: 1, order: ["created_at_desc", "id_desc"] }, + }); + getThreadDetailMock.mockResolvedValue({ + thread: { + id: "thread-1", + title: "Gamma thread", + agent_profile_id: "assistant_default", + created_at: "2026-03-17T10:00:00Z", + updated_at: "2026-03-17T10:00:00Z", + }, + }); + getThreadSessionsMock.mockResolvedValue({ + items: [], + summary: { thread_id: "thread-1", total_count: 0, order: [] }, + }); + getThreadEventsMock.mockResolvedValue({ + items: [], + summary: { thread_id: "thread-1", total_count: 0, order: [] }, + }); + getThreadResumptionBriefMock.mockResolvedValue(buildResumptionBriefFixture()); + + render( + await ChatPage({ + searchParams: Promise.resolve({ + mode: "request", + }), + }), + ); + + expect(screen.getByText("Live continuity enabled")).toBeInTheDocument(); + expect(screen.getByText("No governed requests yet")).toBeInTheDocument(); + expect(screen.queryByText("Fixture preview")).not.toBeInTheDocument(); + expect(screen.queryByText(/place_order \/ supplements/i)).not.toBeInTheDocument(); + }); + + it("shows an unavailable continuity status when live continuity reads fail", async () => { + getApiConfigMock.mockReturnValue({ + apiBaseUrl: "https://api.example.com", + userId: "user-1", + defaultThreadId: "thread-1", + defaultToolId: "tool-1", + }); + hasLiveApiConfigMock.mockReturnValue(true); + listAgentProfilesMock.mockResolvedValue({ + items: [ + { + id: "assistant_default", + name: "Assistant Default", + description: "General-purpose assistant profile for baseline conversations.", + }, + ], + summary: { total_count: 1, order: ["id_asc"] }, + }); + listThreadsMock.mockResolvedValue({ + items: [ + { + id: "thread-1", + title: "Gamma thread", + agent_profile_id: "assistant_default", + created_at: "2026-03-17T10:00:00Z", + updated_at: "2026-03-17T10:00:00Z", + }, + ], + summary: { total_count: 1, order: ["created_at_desc", "id_desc"] }, + }); + getThreadDetailMock.mockRejectedValue(new Error("detail failed")); + getThreadSessionsMock.mockResolvedValue({ + items: [], + summary: { thread_id: "thread-1", total_count: 0, order: [] }, + }); + getThreadEventsMock.mockResolvedValue({ + items: [], + summary: { thread_id: "thread-1", total_count: 0, order: [] }, + }); + getThreadResumptionBriefMock.mockRejectedValue(new Error("brief failed")); + + render(await ChatPage({ searchParams: Promise.resolve({}) })); + + expect(screen.getByText("Continuity unavailable")).toBeInTheDocument(); + expect(screen.getByText("Summary unavailable")).toBeInTheDocument(); + }); + + it("shows resumption brief unavailable state when brief read fails but continuity is live", async () => { + getApiConfigMock.mockReturnValue({ + apiBaseUrl: "https://api.example.com", + userId: "user-1", + defaultThreadId: "thread-1", + defaultToolId: "tool-1", + }); + hasLiveApiConfigMock.mockReturnValue(true); + listAgentProfilesMock.mockResolvedValue({ + items: [ + { + id: "assistant_default", + name: "Assistant Default", + description: "General-purpose assistant profile for baseline conversations.", + }, + ], + summary: { total_count: 1, order: ["id_asc"] }, + }); + listThreadsMock.mockResolvedValue({ + items: [ + { + id: "thread-1", + title: "Gamma thread", + agent_profile_id: "assistant_default", + created_at: "2026-03-17T10:00:00Z", + updated_at: "2026-03-17T10:00:00Z", + }, + ], + summary: { total_count: 1, order: ["created_at_desc", "id_desc"] }, + }); + getThreadDetailMock.mockResolvedValue({ + thread: { + id: "thread-1", + title: "Gamma thread", + agent_profile_id: "assistant_default", + created_at: "2026-03-17T10:00:00Z", + updated_at: "2026-03-17T10:00:00Z", + }, + }); + getThreadSessionsMock.mockResolvedValue({ + items: [], + summary: { thread_id: "thread-1", total_count: 0, order: [] }, + }); + getThreadEventsMock.mockResolvedValue({ + items: [], + summary: { thread_id: "thread-1", total_count: 0, order: [] }, + }); + getThreadResumptionBriefMock.mockRejectedValue(new Error("resumption brief failed")); + + render(await ChatPage({ searchParams: Promise.resolve({}) })); + + expect(screen.getByText("Live continuity enabled")).toBeInTheDocument(); + expect(screen.getByText("Resumption brief unavailable")).toBeInTheDocument(); + expect(screen.getByText("resumption brief failed")).toBeInTheDocument(); + }); + + it("falls back to fixture profiles when live profile registry read fails", async () => { + getApiConfigMock.mockReturnValue({ + apiBaseUrl: "https://api.example.com", + userId: "user-1", + defaultThreadId: "thread-1", + defaultToolId: "tool-1", + }); + hasLiveApiConfigMock.mockReturnValue(true); + listAgentProfilesMock.mockRejectedValue(new Error("profile registry failed")); + listThreadsMock.mockResolvedValue({ + items: [ + { + id: "thread-1", + title: "Gamma thread", + agent_profile_id: "coach_default", + created_at: "2026-03-17T10:00:00Z", + updated_at: "2026-03-17T10:00:00Z", + }, + ], + summary: { total_count: 1, order: ["created_at_desc", "id_desc"] }, + }); + getThreadDetailMock.mockResolvedValue({ + thread: { + id: "thread-1", + title: "Gamma thread", + agent_profile_id: "coach_default", + created_at: "2026-03-17T10:00:00Z", + updated_at: "2026-03-17T10:00:00Z", + }, + }); + getThreadSessionsMock.mockResolvedValue({ + items: [], + summary: { thread_id: "thread-1", total_count: 0, order: [] }, + }); + getThreadEventsMock.mockResolvedValue({ + items: [], + summary: { thread_id: "thread-1", total_count: 0, order: [] }, + }); + getThreadResumptionBriefMock.mockResolvedValue(buildResumptionBriefFixture()); + + render(await ChatPage({ searchParams: Promise.resolve({}) })); + + expect(screen.getByText("Live continuity enabled")).toBeInTheDocument(); + expect(screen.getAllByText("Profile Coach Default").length).toBeGreaterThan(0); + }); +}); diff --git a/apps/web/app/chat/page.tsx b/apps/web/app/chat/page.tsx new file mode 100644 index 0000000..4b77343 --- /dev/null +++ b/apps/web/app/chat/page.tsx @@ -0,0 +1,747 @@ +import { ModeToggle, type ChatMode } from "../../components/mode-toggle"; +import { PageHeader } from "../../components/page-header"; +import { RequestComposer } from "../../components/request-composer"; +import { ResponseComposer } from "../../components/response-composer"; +import { ResponseHistory } from "../../components/response-history"; +import { ThreadTracePanel, type ThreadTraceTarget } from "../../components/thread-trace-panel"; +import { ThreadWorkflowPanel } from "../../components/thread-workflow-panel"; +import { ThreadCreate } from "../../components/thread-create"; +import { ThreadEventList } from "../../components/thread-event-list"; +import { ThreadList } from "../../components/thread-list"; +import { ThreadSummary } from "../../components/thread-summary"; +import type { + AgentProfileItem, + ApiSource, + ApprovalItem, + ResumptionBrief, + TaskItem, + TaskStepItem, + TaskStepListSummary, + ThreadEventItem, + ThreadItem, + ThreadSessionItem, + ToolExecutionItem, +} from "../../lib/api"; +import { + deriveThreadWorkflowState, + DEFAULT_AGENT_PROFILE_ID, + getApiConfig, + getTaskSteps, + getThreadDetail, + getThreadEvents, + getThreadResumptionBrief, + getThreadSessions, + hasLiveApiConfig, + listAgentProfiles, + listApprovals, + listThreads, + listTasks, + listToolExecutions, + shouldExpectThreadExecutionReview, +} from "../../lib/api"; +import { + approvalFixtures, + agentProfileFixtures, + executionFixtures, + getFixtureThreadEvents, + getFixtureThreadSessions, + getFixtureTaskStepSummary, + getFixtureTaskSteps, + requestHistoryFixtures, + responseHistoryFixtures, + taskFixtures, + threadFixtures, +} from "../../lib/fixtures"; + +type ChatPageProps = { + searchParams?: Promise<Record<string, string | string[] | undefined>>; +}; + +type ContinuitySource = "live" | "fixture" | "unavailable"; + +type ContinuityViewModel = { + threadListSource: ContinuitySource; + continuitySource: ContinuitySource; + unavailableReason?: string; + threads: ThreadItem[]; + selectedThreadId: string; + selectedThread: ThreadItem | null; + sessions: ThreadSessionItem[]; + events: ThreadEventItem[]; +}; + +type ProfileRegistrySource = ApiSource | "unavailable"; + +type ProfileRegistryViewModel = { + profiles: AgentProfileItem[]; + source: ProfileRegistrySource; + unavailableReason?: string; +}; + +type WorkflowSource = ApiSource | "unavailable"; + +type WorkflowViewModel = { + approval: ApprovalItem | null; + approvalSource: WorkflowSource; + approvalUnavailableReason?: string; + task: TaskItem | null; + taskSource: WorkflowSource; + taskUnavailableReason?: string; + execution: ToolExecutionItem | null; + executionSource: WorkflowSource | null; + executionUnavailableReason?: string; + taskSteps: TaskStepItem[]; + taskStepSummary: TaskStepListSummary | null; + taskStepSource: WorkflowSource | null; + taskStepUnavailableReason?: string; +}; + +type ResumptionBriefSource = ApiSource | "unavailable" | null; + +type ResumptionBriefViewModel = { + brief: ResumptionBrief | null; + source: ResumptionBriefSource; + unavailableReason?: string; +}; + +function normalizeMode(value: string | string[] | undefined): ChatMode { + if (Array.isArray(value)) { + return normalizeMode(value[0]); + } + + return value === "request" ? "request" : "assistant"; +} + +function normalizeThreadId(value: string | string[] | undefined) { + if (Array.isArray(value)) { + return normalizeThreadId(value[0]); + } + + return value?.trim() ?? ""; +} + +function normalizeTraceId(value: string | string[] | undefined) { + if (Array.isArray(value)) { + return normalizeTraceId(value[0]); + } + + return value?.trim() ?? ""; +} + +function normalizeAgentProfileId(value: string | null | undefined) { + const profileId = value?.trim(); + return profileId && profileId.length > 0 ? profileId : DEFAULT_AGENT_PROFILE_ID; +} + +function normalizeThreadItem(thread: ThreadItem): ThreadItem { + return { + ...thread, + agent_profile_id: normalizeAgentProfileId(thread.agent_profile_id), + }; +} + +function normalizeThreadItems(items: ThreadItem[]) { + return items.map((item) => normalizeThreadItem(item)); +} + +function buildChatTraceHrefPrefix(mode: ChatMode, threadId: string) { + const params = new URLSearchParams(); + + if (mode === "request") { + params.set("mode", "request"); + } + + if (threadId) { + params.set("thread", threadId); + } + + return `/chat?${params.toString()}${params.size > 0 ? "&" : ""}trace=`; +} + +function buildThreadTraceTargets( + selectedThreadId: string, + workflow: WorkflowViewModel, + liveModeReady: boolean, +): ThreadTraceTarget[] { + const targetMap = new Map<string, ThreadTraceTarget>(); + + function registerTarget(id: string | null | undefined, label: string) { + const normalizedId = id?.trim(); + if (!normalizedId || targetMap.has(normalizedId)) { + return; + } + + targetMap.set(normalizedId, { + id: normalizedId, + label, + }); + } + + registerTarget(workflow.execution?.trace_id, "Execution trace"); + registerTarget(workflow.approval?.routing.trace.trace_id, "Approval routing trace"); + + for (const step of [...workflow.taskSteps].sort((left, right) => right.sequence_no - left.sequence_no)) { + registerTarget(step.trace.trace_id, `Task step ${step.sequence_no} trace`); + } + + if (!liveModeReady && selectedThreadId) { + for (const entry of responseHistoryFixtures + .filter((item) => item.threadId === selectedThreadId) + .sort((left, right) => new Date(right.submittedAt).getTime() - new Date(left.submittedAt).getTime())) { + registerTarget(entry.trace.responseTraceId, "Assistant response trace"); + registerTarget(entry.trace.compileTraceId, "Assistant compile trace"); + } + + for (const entry of requestHistoryFixtures + .filter((item) => item.threadId === selectedThreadId) + .sort((left, right) => new Date(right.submittedAt).getTime() - new Date(left.submittedAt).getTime())) { + registerTarget(entry.trace.requestTraceId, "Governed request trace"); + registerTarget(entry.trace.routingTraceId, "Routing decision trace"); + } + } + + return [...targetMap.values()]; +} + +function resolveSelectedThreadId( + requestedThreadId: string, + defaultThreadId: string, + threads: ThreadItem[], +) { + const availableIds = new Set(threads.map((thread) => thread.id)); + + if (requestedThreadId && availableIds.has(requestedThreadId)) { + return requestedThreadId; + } + + if (defaultThreadId && availableIds.has(defaultThreadId)) { + return defaultThreadId; + } + + return threads[0]?.id ?? ""; +} + +async function loadFixtureWorkflow(selectedThreadId: string): Promise<WorkflowViewModel> { + if (!selectedThreadId) { + return { + approval: null, + approvalSource: "fixture", + task: null, + taskSource: "fixture", + execution: null, + executionSource: null, + taskSteps: [], + taskStepSummary: null, + taskStepSource: null, + }; + } + + const { approval, task, execution } = deriveThreadWorkflowState( + selectedThreadId, + approvalFixtures, + taskFixtures, + executionFixtures, + ); + + return { + approval, + approvalSource: "fixture", + task, + taskSource: "fixture", + execution, + executionSource: execution ? "fixture" : null, + taskSteps: task ? getFixtureTaskSteps(task.id) : [], + taskStepSummary: task ? getFixtureTaskStepSummary(task.id) : null, + taskStepSource: task ? "fixture" : null, + }; +} + +async function loadLiveWorkflow( + apiBaseUrl: string, + userId: string, + selectedThreadId: string, +): Promise<WorkflowViewModel> { + if (!selectedThreadId) { + return { + approval: null, + approvalSource: "live", + task: null, + taskSource: "live", + execution: null, + executionSource: null, + taskSteps: [], + taskStepSummary: null, + taskStepSource: null, + }; + } + + const [approvalsResult, tasksResult, executionsResult] = await Promise.allSettled([ + listApprovals(apiBaseUrl, userId), + listTasks(apiBaseUrl, userId), + listToolExecutions(apiBaseUrl, userId), + ]); + + const approvalItems = approvalsResult.status === "fulfilled" ? approvalsResult.value.items : []; + const taskItems = tasksResult.status === "fulfilled" ? tasksResult.value.items : []; + const executionItems = executionsResult.status === "fulfilled" ? executionsResult.value.items : []; + const derivedWorkflow = deriveThreadWorkflowState( + selectedThreadId, + approvalItems, + taskItems, + executionItems, + ); + let taskSteps: TaskStepItem[] = []; + let taskStepSummary: TaskStepListSummary | null = null; + let taskStepSource: WorkflowSource | null = derivedWorkflow.task ? "live" : null; + let taskStepUnavailableReason: string | undefined; + + if (derivedWorkflow.task) { + try { + const taskStepPayload = await getTaskSteps(apiBaseUrl, derivedWorkflow.task.id, userId); + taskSteps = taskStepPayload.items; + taskStepSummary = taskStepPayload.summary; + taskStepSource = "live"; + } catch (error) { + taskStepSource = "unavailable"; + taskStepUnavailableReason = + error instanceof Error ? error.message : "Task-step timeline could not be loaded."; + } + } + + const expectsExecutionReview = shouldExpectThreadExecutionReview( + derivedWorkflow.approval, + derivedWorkflow.task, + ); + + return { + approval: derivedWorkflow.approval, + approvalSource: + approvalsResult.status === "fulfilled" + ? "live" + : "unavailable", + approvalUnavailableReason: + approvalsResult.status === "rejected" + ? approvalsResult.reason instanceof Error + ? approvalsResult.reason.message + : "Approvals could not be loaded." + : undefined, + task: derivedWorkflow.task, + taskSource: tasksResult.status === "fulfilled" ? "live" : "unavailable", + taskUnavailableReason: + tasksResult.status === "rejected" + ? tasksResult.reason instanceof Error + ? tasksResult.reason.message + : "Tasks could not be loaded." + : undefined, + execution: derivedWorkflow.execution, + executionSource: + executionsResult.status === "fulfilled" + ? derivedWorkflow.execution + ? "live" + : null + : expectsExecutionReview + ? "unavailable" + : null, + executionUnavailableReason: + executionsResult.status === "rejected" && expectsExecutionReview + ? executionsResult.reason instanceof Error + ? executionsResult.reason.message + : "Execution state could not be loaded." + : undefined, + taskSteps, + taskStepSummary, + taskStepSource, + taskStepUnavailableReason, + }; +} + +function buildFixtureResumptionBrief( + continuity: ContinuityViewModel, + workflow: WorkflowViewModel, +): ResumptionBrief { + const conversationItems = continuity.events + .filter((event) => event.kind === "message.user" || event.kind === "message.assistant") + .slice(-8); + const latestTaskStep = workflow.taskSteps[workflow.taskSteps.length - 1] ?? null; + + return { + assembly_version: "resumption_brief_v0", + thread: continuity.selectedThread as NonNullable<ContinuityViewModel["selectedThread"]>, + conversation: { + items: conversationItems, + summary: { + limit: 8, + returned_count: conversationItems.length, + total_count: continuity.events.filter( + (event) => event.kind === "message.user" || event.kind === "message.assistant", + ).length, + order: ["sequence_no_asc"], + kinds: ["message.user", "message.assistant"], + }, + }, + open_loops: { + items: [], + summary: { + limit: 5, + returned_count: 0, + total_count: 0, + order: ["opened_at_desc", "created_at_desc", "id_desc"], + }, + }, + memory_highlights: { + items: [], + summary: { + limit: 5, + returned_count: 0, + total_count: 0, + order: ["updated_at_asc", "created_at_asc", "id_asc"], + }, + }, + workflow: workflow.task + ? { + task: workflow.task, + latest_task_step: latestTaskStep, + summary: { + present: true, + task_order: ["created_at_asc", "id_asc"], + task_step_order: ["sequence_no_asc", "created_at_asc", "id_asc"], + }, + } + : null, + sources: workflow.task + ? ["threads", "events", "open_loops", "memories", "tasks", "task_steps"] + : ["threads", "events", "open_loops", "memories"], + }; +} + +async function loadFixtureResumptionBrief( + continuity: ContinuityViewModel, + workflow: WorkflowViewModel, +): Promise<ResumptionBriefViewModel> { + if (!continuity.selectedThread) { + return { + brief: null, + source: "fixture", + }; + } + + return { + brief: buildFixtureResumptionBrief(continuity, workflow), + source: "fixture", + }; +} + +async function loadLiveResumptionBrief( + apiBaseUrl: string, + userId: string, + selectedThreadId: string, +): Promise<ResumptionBriefViewModel> { + if (!selectedThreadId) { + return { + brief: null, + source: "live", + }; + } + + try { + const payload = await getThreadResumptionBrief(apiBaseUrl, selectedThreadId, userId); + return { + brief: payload.brief, + source: "live", + }; + } catch (error) { + return { + brief: null, + source: "unavailable", + unavailableReason: error instanceof Error ? error.message : "Resumption brief could not be loaded.", + }; + } +} + +async function loadFixtureContinuity( + requestedThreadId: string, + defaultThreadId: string, +): Promise<ContinuityViewModel> { + const threads = normalizeThreadItems(threadFixtures); + const selectedThreadId = resolveSelectedThreadId(requestedThreadId, defaultThreadId, threads); + const selectedThread = selectedThreadId + ? threads.find((item) => item.id === selectedThreadId) ?? null + : null; + + return { + threadListSource: "fixture", + continuitySource: "fixture", + threads, + selectedThreadId, + selectedThread, + sessions: selectedThreadId ? getFixtureThreadSessions(selectedThreadId) : [], + events: selectedThreadId ? getFixtureThreadEvents(selectedThreadId) : [], + }; +} + +async function loadLiveContinuity( + apiBaseUrl: string, + userId: string, + requestedThreadId: string, + defaultThreadId: string, +): Promise<ContinuityViewModel> { + try { + const threadResponse = await listThreads(apiBaseUrl, userId); + const threads = normalizeThreadItems(threadResponse.items); + const selectedThreadId = resolveSelectedThreadId(requestedThreadId, defaultThreadId, threads); + + if (!selectedThreadId) { + return { + threadListSource: "live", + continuitySource: "live", + threads, + selectedThreadId: "", + selectedThread: null, + sessions: [], + events: [], + }; + } + + const [threadResult, sessionsResult, eventsResult] = await Promise.allSettled([ + getThreadDetail(apiBaseUrl, selectedThreadId, userId), + getThreadSessions(apiBaseUrl, selectedThreadId, userId), + getThreadEvents(apiBaseUrl, selectedThreadId, userId), + ]); + + const unavailableReason = + threadResult.status === "rejected" + ? threadResult.reason instanceof Error + ? threadResult.reason.message + : "Thread detail failed to load." + : sessionsResult.status === "rejected" + ? sessionsResult.reason instanceof Error + ? sessionsResult.reason.message + : "Thread sessions failed to load." + : eventsResult.status === "rejected" + ? eventsResult.reason instanceof Error + ? eventsResult.reason.message + : "Thread events failed to load." + : undefined; + + return { + threadListSource: "live", + continuitySource: unavailableReason ? "unavailable" : "live", + unavailableReason, + threads, + selectedThreadId, + selectedThread: + threadResult.status === "fulfilled" + ? normalizeThreadItem(threadResult.value.thread) + : threads.find((thread) => thread.id === selectedThreadId) ?? null, + sessions: sessionsResult.status === "fulfilled" ? sessionsResult.value.items : [], + events: eventsResult.status === "fulfilled" ? eventsResult.value.items : [], + }; + } catch (error) { + return { + threadListSource: "unavailable", + continuitySource: "unavailable", + unavailableReason: error instanceof Error ? error.message : "Thread continuity failed to load.", + threads: [], + selectedThreadId: "", + selectedThread: null, + sessions: [], + events: [], + }; + } +} + +async function loadFixtureProfileRegistry(): Promise<ProfileRegistryViewModel> { + return { + profiles: agentProfileFixtures, + source: "fixture", + }; +} + +async function loadLiveProfileRegistry(apiBaseUrl: string): Promise<ProfileRegistryViewModel> { + try { + const response = await listAgentProfiles(apiBaseUrl); + return { + profiles: response.items, + source: "live", + }; + } catch (error) { + return { + profiles: agentProfileFixtures, + source: "fixture", + unavailableReason: + error instanceof Error ? error.message : "Agent profile registry could not be loaded.", + }; + } +} + +export default async function ChatPage({ searchParams }: ChatPageProps) { + const resolvedSearchParams = searchParams ? await searchParams : undefined; + const mode = normalizeMode(resolvedSearchParams?.mode); + const requestedThreadId = normalizeThreadId(resolvedSearchParams?.thread); + const requestedTraceId = normalizeTraceId(resolvedSearchParams?.trace); + const apiConfig = getApiConfig(); + const liveModeReady = hasLiveApiConfig(apiConfig); + const [continuity, profileRegistry] = liveModeReady + ? await Promise.all([ + loadLiveContinuity( + apiConfig.apiBaseUrl, + apiConfig.userId, + requestedThreadId, + apiConfig.defaultThreadId, + ), + loadLiveProfileRegistry(apiConfig.apiBaseUrl), + ]) + : await Promise.all([ + loadFixtureContinuity(requestedThreadId, apiConfig.defaultThreadId), + loadFixtureProfileRegistry(), + ]); + const workflow = liveModeReady + ? await loadLiveWorkflow(apiConfig.apiBaseUrl, apiConfig.userId, continuity.selectedThreadId) + : await loadFixtureWorkflow(continuity.selectedThreadId); + const resumptionBrief = liveModeReady + ? await loadLiveResumptionBrief( + apiConfig.apiBaseUrl, + apiConfig.userId, + continuity.selectedThreadId, + ) + : await loadFixtureResumptionBrief(continuity, workflow); + const traceTargets = buildThreadTraceTargets(continuity.selectedThreadId, workflow, liveModeReady); + const traceHrefPrefix = buildChatTraceHrefPrefix(mode, continuity.selectedThreadId); + const threadTracePanel = await ThreadTracePanel({ + thread: continuity.selectedThread, + source: liveModeReady ? "live" : "fixture", + traceTargets, + selectedTraceId: requestedTraceId, + traceHrefPrefix, + apiBaseUrl: liveModeReady ? apiConfig.apiBaseUrl : undefined, + userId: liveModeReady ? apiConfig.userId : undefined, + }); + + const initialRequestEntries = liveModeReady ? [] : requestHistoryFixtures; + + return ( + <div className="page-stack page-stack--chat"> + <PageHeader + eyebrow="Operator conversation surface" + title="Chat with the assistant or route a governed request" + description="Normal conversation and approval-gated actions now share one calm shell. Thread identity stays explicit, bounded, and visible so continuity never depends on a raw UUID field." + meta={ + <div className="header-meta"> + <span className="subtle-chip"> + {continuity.continuitySource === "unavailable" + ? "Continuity unavailable" + : liveModeReady + ? "Live continuity enabled" + : "Fixture continuity preview"} + </span> + <span className="subtle-chip"> + {continuity.selectedThread + ? `Selected: ${continuity.selectedThread.title}` + : "Select or create a thread"} + </span> + </div> + } + /> + + <ModeToggle currentMode={mode} selectedThreadId={continuity.selectedThreadId} /> + + <div className="chat-layout"> + <div className="chat-layout__main"> + {mode === "assistant" ? ( + <ResponseComposer + initialEntries={[]} + apiBaseUrl={apiConfig.apiBaseUrl} + userId={apiConfig.userId} + selectedThreadId={continuity.selectedThreadId} + selectedThreadTitle={continuity.selectedThread?.title} + events={continuity.events} + source={continuity.continuitySource} + unavailableReason={continuity.unavailableReason} + /> + ) : ( + <> + <ResponseHistory + entries={[]} + threadTitle={continuity.selectedThread?.title} + events={continuity.events} + source={continuity.continuitySource} + unavailableReason={continuity.unavailableReason} + traceHrefPrefix={traceHrefPrefix} + /> + <RequestComposer + initialEntries={initialRequestEntries} + apiBaseUrl={apiConfig.apiBaseUrl} + userId={apiConfig.userId} + selectedThreadId={continuity.selectedThreadId} + selectedThreadTitle={continuity.selectedThread?.title} + defaultToolId={apiConfig.defaultToolId} + /> + </> + )} + </div> + + <div className="chat-layout__rail"> + <ThreadWorkflowPanel + thread={continuity.selectedThread} + approval={workflow.approval} + approvalSource={workflow.approvalSource} + approvalUnavailableReason={workflow.approvalUnavailableReason} + task={workflow.task} + taskSource={workflow.taskSource} + taskUnavailableReason={workflow.taskUnavailableReason} + execution={workflow.execution} + executionSource={workflow.executionSource} + executionUnavailableReason={workflow.executionUnavailableReason} + taskSteps={workflow.taskSteps} + taskStepSummary={workflow.taskStepSummary} + taskStepSource={workflow.taskStepSource} + taskStepUnavailableReason={workflow.taskStepUnavailableReason} + apiBaseUrl={liveModeReady ? apiConfig.apiBaseUrl : undefined} + userId={liveModeReady ? apiConfig.userId : undefined} + traceHrefPrefix={traceHrefPrefix} + /> + + {threadTracePanel} + + <ThreadSummary + thread={continuity.selectedThread} + sessions={continuity.sessions} + events={continuity.events} + agentProfiles={profileRegistry.profiles} + source={continuity.continuitySource} + unavailableReason={continuity.unavailableReason} + resumptionBrief={resumptionBrief.brief} + resumptionSource={resumptionBrief.source} + resumptionUnavailableReason={resumptionBrief.unavailableReason} + /> + + <ThreadList + threads={continuity.threads} + selectedThreadId={continuity.selectedThreadId} + currentMode={mode} + agentProfiles={profileRegistry.profiles} + source={continuity.threadListSource} + unavailableReason={continuity.unavailableReason} + /> + + <ThreadEventList + threadTitle={continuity.selectedThread?.title} + sessions={continuity.sessions} + events={continuity.events} + source={continuity.continuitySource} + unavailableReason={continuity.unavailableReason} + apiBaseUrl={liveModeReady ? apiConfig.apiBaseUrl : undefined} + userId={liveModeReady ? apiConfig.userId : undefined} + /> + + <ThreadCreate + apiBaseUrl={liveModeReady ? apiConfig.apiBaseUrl : undefined} + userId={liveModeReady ? apiConfig.userId : undefined} + currentMode={mode} + agentProfiles={profileRegistry.profiles} + /> + </div> + </div> + </div> + ); +} diff --git a/apps/web/app/chief-of-staff/page.test.tsx b/apps/web/app/chief-of-staff/page.test.tsx new file mode 100644 index 0000000..04a34d9 --- /dev/null +++ b/apps/web/app/chief-of-staff/page.test.tsx @@ -0,0 +1,960 @@ +import React from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import ChiefOfStaffPage from "./page"; + +const { getApiConfigMock, getChiefOfStaffPriorityBriefMock, hasLiveApiConfigMock } = vi.hoisted(() => ({ + getApiConfigMock: vi.fn(), + getChiefOfStaffPriorityBriefMock: vi.fn(), + hasLiveApiConfigMock: vi.fn(), +})); + +vi.mock("next/link", () => ({ + default: ({ + href, + children, + className, + "aria-current": ariaCurrent, + }: { + href: string; + children: React.ReactNode; + className?: string; + "aria-current"?: string; + }) => ( + <a href={href} className={className} aria-current={ariaCurrent}> + {children} + </a> + ), +})); + +vi.mock("../../lib/api", async () => { + const actual = await vi.importActual("../../lib/api"); + return { + ...actual, + getApiConfig: getApiConfigMock, + getChiefOfStaffPriorityBrief: getChiefOfStaffPriorityBriefMock, + hasLiveApiConfig: hasLiveApiConfigMock, + }; +}); + +describe("ChiefOfStaffPage", () => { + beforeEach(() => { + getApiConfigMock.mockReset(); + getChiefOfStaffPriorityBriefMock.mockReset(); + hasLiveApiConfigMock.mockReset(); + + getApiConfigMock.mockReturnValue({ + apiBaseUrl: "", + userId: "", + defaultThreadId: "", + defaultToolId: "", + }); + hasLiveApiConfigMock.mockReturnValue(false); + }); + + afterEach(() => { + cleanup(); + }); + + it("uses fixture chief-of-staff brief when live API config is absent", async () => { + render(await ChiefOfStaffPage({ searchParams: Promise.resolve({}) })); + + expect(screen.getByText("Fixture-backed")).toBeInTheDocument(); + expect(screen.getByText("Chief-of-staff")).toBeInTheDocument(); + expect(screen.getByText("Fixture chief-of-staff brief")).toBeInTheDocument(); + expect(screen.getByText("Fixture follow-through")).toBeInTheDocument(); + expect(screen.getByText("Fixture preparation brief")).toBeInTheDocument(); + expect(screen.getByText("Fixture weekly review")).toBeInTheDocument(); + expect(screen.getByText("Fixture action handoff")).toBeInTheDocument(); + expect(screen.getByText("Fixture handoff queue")).toBeInTheDocument(); + expect(screen.getByText("Fixture execution routing")).toBeInTheDocument(); + expect(screen.getByText("Fixture outcome learning")).toBeInTheDocument(); + expect(screen.getAllByText("Next Action: Confirm launch checklist owner").length).toBeGreaterThan(0); + expect(screen.getAllByText("Action type: execute_next_action").length).toBeGreaterThan(0); + expect(screen.getByText("Follow-through supervision")).toBeInTheDocument(); + expect(screen.getByText("Preparation and resumption")).toBeInTheDocument(); + expect(screen.getByText("Weekly review and learning")).toBeInTheDocument(); + expect(screen.getByText("Action handoff")).toBeInTheDocument(); + expect(getChiefOfStaffPriorityBriefMock).not.toHaveBeenCalled(); + }); + + it("renders live chief-of-staff brief when API read succeeds", async () => { + getApiConfigMock.mockReturnValue({ + apiBaseUrl: "https://api.example.com", + userId: "user-1", + defaultThreadId: "thread-1", + defaultToolId: "tool-1", + }); + hasLiveApiConfigMock.mockReturnValue(true); + getChiefOfStaffPriorityBriefMock.mockResolvedValue({ + brief: { + assembly_version: "chief_of_staff_priority_brief_v0", + scope: { thread_id: "thread-1", since: null, until: null }, + ranked_items: [ + { + rank: 1, + id: "priority-live-1", + capture_event_id: "capture-live-1", + object_type: "NextAction", + status: "active", + title: "Next Action: Send partner follow-up", + priority_posture: "urgent", + confidence_posture: "medium", + confidence: 0.92, + score: 650, + provenance: { thread_id: "thread-1" }, + created_at: "2026-03-31T10:10:00Z", + updated_at: "2026-03-31T10:10:00Z", + rationale: { + reasons: ["Marked urgent because this item is a deterministic immediate focus from resumption signals."], + ranking_inputs: { + posture: "urgent", + open_loop_posture: "next_action", + recency_rank: 1, + age_hours_relative_to_latest: 0, + recall_relevance: 120, + scope_match_count: 1, + query_term_match_count: 1, + freshness_posture: "fresh", + provenance_posture: "strong", + supersession_posture: "current", + }, + provenance_references: [ + { + source_kind: "continuity_capture_event", + source_id: "capture-live-1", + }, + ], + trust_signals: { + quality_gate_status: "needs_review", + retrieval_status: "pass", + trust_confidence_cap: "medium", + downgraded_by_trust: false, + reason: "Memory quality gate needs review, so recommendation confidence is capped at medium.", + }, + }, + }, + ], + overdue_items: [ + { + rank: 1, + id: "follow-live-overdue-1", + capture_event_id: "capture-follow-live-overdue-1", + object_type: "NextAction", + status: "active", + title: "Next Action: Send partner follow-up", + current_priority_posture: "urgent", + follow_through_posture: "overdue", + recommendation_action: "escalate", + reason: + "Execution follow-through is overdue (posture=urgent, age=140.0h), so action 'escalate' is recommended.", + age_hours: 140, + provenance_references: [ + { + source_kind: "continuity_capture_event", + source_id: "capture-follow-live-overdue-1", + }, + ], + created_at: "2026-03-26T08:00:00Z", + updated_at: "2026-03-26T08:00:00Z", + }, + ], + stale_waiting_for_items: [], + slipped_commitments: [], + escalation_posture: { + posture: "critical", + reason: "At least one follow-through item requires escalation.", + total_follow_through_count: 1, + nudge_count: 0, + defer_count: 0, + escalate_count: 1, + close_loop_candidate_count: 0, + }, + draft_follow_up: { + status: "drafted", + mode: "draft_only", + approval_required: true, + auto_send: false, + reason: "Highest-severity follow-through item selected deterministically for operator review.", + target_metadata: { + continuity_object_id: "follow-live-overdue-1", + capture_event_id: "capture-follow-live-overdue-1", + object_type: "NextAction", + priority_posture: "urgent", + follow_through_posture: "overdue", + recommendation_action: "escalate", + thread_id: "thread-1", + }, + content: { + subject: "Follow-up: Next Action: Send partner follow-up", + body: "This draft is artifact-only and requires explicit approval before any external send.", + }, + }, + recommended_next_action: { + action_type: "execute_next_action", + title: "Next Action: Send partner follow-up", + target_priority_id: "priority-live-1", + priority_posture: "urgent", + confidence_posture: "medium", + reason: "Marked urgent because this item is a deterministic immediate focus from resumption signals.", + provenance_references: [ + { + source_kind: "continuity_capture_event", + source_id: "capture-live-1", + }, + ], + deterministic_rank_key: "1:priority-live-1:650.000000", + }, + preparation_brief: { + scope: { thread_id: "thread-1", since: null, until: null }, + context_items: [ + { + rank: 1, + id: "prep-context-live-1", + capture_event_id: "capture-prep-context-live-1", + object_type: "Decision", + status: "active", + title: "Decision: Keep launch phased", + reason: "Decision context carried forward for deterministic meeting prep.", + confidence_posture: "medium", + provenance_references: [ + { + source_kind: "continuity_capture_event", + source_id: "capture-prep-context-live-1", + }, + ], + created_at: "2026-03-31T08:30:00Z", + }, + ], + last_decision: { + rank: 1, + id: "prep-decision-live-1", + capture_event_id: "capture-prep-decision-live-1", + object_type: "Decision", + status: "active", + title: "Decision: Keep launch phased", + reason: "Latest scoped decision included to ground upcoming preparation context.", + confidence_posture: "medium", + provenance_references: [ + { + source_kind: "continuity_capture_event", + source_id: "capture-prep-decision-live-1", + }, + ], + created_at: "2026-03-31T08:30:00Z", + }, + open_loops: [], + next_action: null, + confidence_posture: "medium", + confidence_reason: "Memory quality gate needs review, so recommendation confidence is capped at medium.", + summary: { + limit: 6, + returned_count: 1, + total_count: 1, + order: ["rank_asc", "created_at_desc", "id_desc"], + }, + }, + what_changed_summary: { + items: [ + { + rank: 1, + id: "what-changed-live-1", + capture_event_id: "capture-what-changed-live-1", + object_type: "NextAction", + status: "active", + title: "Next Action: Send partner follow-up", + reason: "Included from deterministic continuity recent-changes ordering.", + confidence_posture: "medium", + provenance_references: [ + { + source_kind: "continuity_capture_event", + source_id: "capture-what-changed-live-1", + }, + ], + created_at: "2026-03-31T10:10:00Z", + }, + ], + confidence_posture: "medium", + confidence_reason: "Memory quality gate needs review, so recommendation confidence is capped at medium.", + summary: { + limit: 6, + returned_count: 1, + total_count: 1, + order: ["rank_asc", "created_at_desc", "id_desc"], + }, + }, + prep_checklist: { + items: [ + { + rank: 1, + id: "prep-check-live-1", + capture_event_id: "capture-prep-check-live-1", + object_type: "WaitingFor", + status: "active", + title: "Waiting For: Vendor legal review", + reason: "Prepare a status check and explicit owner for this unresolved open loop.", + confidence_posture: "medium", + provenance_references: [ + { + source_kind: "continuity_capture_event", + source_id: "capture-prep-check-live-1", + }, + ], + created_at: "2026-03-31T09:00:00Z", + }, + ], + confidence_posture: "medium", + confidence_reason: "Memory quality gate needs review, so recommendation confidence is capped at medium.", + summary: { + limit: 6, + returned_count: 1, + total_count: 1, + order: ["rank_asc", "created_at_desc", "id_desc"], + }, + }, + suggested_talking_points: { + items: [ + { + rank: 1, + id: "talking-live-1", + capture_event_id: "capture-talking-live-1", + object_type: "Blocker", + status: "active", + title: "Blocker: Launch token pending", + reason: "Raise this unresolved dependency explicitly and confirm a concrete follow-up path.", + confidence_posture: "medium", + provenance_references: [ + { + source_kind: "continuity_capture_event", + source_id: "capture-talking-live-1", + }, + ], + created_at: "2026-03-30T10:10:00Z", + }, + ], + confidence_posture: "medium", + confidence_reason: "Memory quality gate needs review, so recommendation confidence is capped at medium.", + summary: { + limit: 6, + returned_count: 1, + total_count: 1, + order: ["rank_asc", "created_at_desc", "id_desc"], + }, + }, + resumption_supervision: { + recommendations: [ + { + rank: 1, + action: "execute_next_action", + title: "Next Action: Send partner follow-up", + reason: "Marked urgent because this item is a deterministic immediate focus from resumption signals.", + confidence_posture: "medium", + target_priority_id: "priority-live-1", + provenance_references: [ + { + source_kind: "continuity_capture_event", + source_id: "capture-live-1", + }, + ], + }, + ], + confidence_posture: "medium", + confidence_reason: "Memory quality gate needs review, so recommendation confidence is capped at medium.", + summary: { + limit: 3, + returned_count: 1, + total_count: 1, + order: ["rank_asc"], + }, + }, + weekly_review_brief: { + scope: { thread_id: "thread-1", since: null, until: null }, + rollup: { + total_count: 1, + waiting_for_count: 0, + blocker_count: 1, + stale_count: 0, + correction_recurrence_count: 0, + freshness_drift_count: 0, + next_action_count: 1, + posture_order: ["waiting_for", "blocker", "stale", "next_action"], + }, + guidance: [ + { + rank: 1, + action: "escalate", + signal_count: 2, + rationale: "Escalate where blockers are concentrated.", + }, + { + rank: 2, + action: "close", + signal_count: 1, + rationale: "Close loops where deterministic close candidates exist.", + }, + { + rank: 3, + action: "defer", + signal_count: 0, + rationale: "Defer where stale load remains.", + }, + ], + summary: { + guidance_order: ["close", "defer", "escalate"], + guidance_item_order: ["signal_count_desc", "action_desc"], + }, + }, + recommendation_outcomes: { + items: [ + { + id: "outcome-live-1", + capture_event_id: "capture-outcome-live-1", + outcome: "accept", + recommendation_action_type: "execute_next_action", + recommendation_title: "Next Action: Send partner follow-up", + rewritten_title: null, + target_priority_id: "priority-live-1", + rationale: "Accepted in weekly review.", + provenance_references: [ + { + source_kind: "continuity_capture_event", + source_id: "capture-outcome-live-1", + }, + ], + created_at: "2026-03-31T12:00:00Z", + updated_at: "2026-03-31T12:00:00Z", + }, + ], + summary: { + returned_count: 1, + total_count: 1, + outcome_counts: { accept: 1, defer: 0, ignore: 0, rewrite: 0 }, + order: ["created_at_desc", "id_desc"], + }, + }, + priority_learning_summary: { + total_count: 1, + accept_count: 1, + defer_count: 0, + ignore_count: 0, + rewrite_count: 0, + acceptance_rate: 1, + override_rate: 0, + defer_hotspots: [], + ignore_hotspots: [], + priority_shift_explanation: + "Prioritization is reinforcing currently accepted recommendation patterns while tracking defer/override hotspots.", + hotspot_order: ["count_desc", "key_asc"], + }, + pattern_drift_summary: { + posture: "improving", + reason: + "Accepted outcomes are leading with bounded defers/overrides, indicating improving recommendation fit.", + supporting_signals: ["Outcomes captured: 1"], + }, + action_handoff_brief: { + summary: + "Prepared 1 deterministic handoff item from recommended_next_action signals. All task and approval drafts remain artifact-only and approval-bounded.", + confidence_posture: "medium", + non_autonomous_guarantee: + "No task, approval, connector send, or external side effect is executed by this endpoint.", + order: ["score_desc", "source_order_asc", "source_reference_id_asc"], + source_order: ["recommended_next_action", "follow_through", "prep_checklist", "weekly_review"], + provenance_references: [ + { + source_kind: "continuity_capture_event", + source_id: "capture-live-1", + }, + ], + }, + handoff_items: [ + { + rank: 1, + handoff_item_id: "handoff-1-recommended_next_action-priority-live-1", + source_kind: "recommended_next_action", + source_reference_id: "priority-live-1", + title: "Next Action: Send partner follow-up", + recommendation_action: "execute_next_action", + priority_posture: "urgent", + confidence_posture: "medium", + rationale: "Marked urgent because this item is a deterministic immediate focus from resumption signals.", + provenance_references: [ + { + source_kind: "continuity_capture_event", + source_id: "capture-live-1", + }, + ], + score: 1650, + task_draft: { + status: "draft", + mode: "governed_request_draft", + approval_required: true, + auto_execute: false, + source_handoff_item_id: "handoff-1-recommended_next_action-priority-live-1", + title: "Next Action: Send partner follow-up", + summary: + "Draft-only governed request assembled from chief-of-staff handoff artifacts; requires explicit approval before any execution.", + target: { thread_id: "thread-1", task_id: null, project: null, person: null }, + request: { + action: "execute_next_action", + scope: "chief_of_staff_priority", + domain_hint: "planning", + risk_hint: "governed_handoff", + attributes: {}, + }, + rationale: + "Marked urgent because this item is a deterministic immediate focus from resumption signals.", + provenance_references: [ + { + source_kind: "continuity_capture_event", + source_id: "capture-live-1", + }, + ], + }, + approval_draft: { + status: "draft_only", + mode: "approval_request_draft", + decision: "approval_required", + approval_required: true, + auto_submit: false, + source_handoff_item_id: "handoff-1-recommended_next_action-priority-live-1", + request: { + action: "execute_next_action", + scope: "chief_of_staff_priority", + domain_hint: "planning", + risk_hint: "governed_handoff", + attributes: {}, + }, + reason: + "Execution remains approval-bounded. This approval draft is artifact-only and must be explicitly submitted and resolved before any side effect.", + required_checks: [ + "operator_review_handoff_artifact", + "submit_governed_approval_request", + "explicit_approval_resolution", + ], + provenance_references: [ + { + source_kind: "continuity_capture_event", + source_id: "capture-live-1", + }, + ], + }, + }, + ], + handoff_queue_summary: { + total_count: 1, + ready_count: 1, + pending_approval_count: 0, + executed_count: 0, + stale_count: 0, + expired_count: 0, + state_order: ["ready", "pending_approval", "executed", "stale", "expired"], + group_order: ["ready", "pending_approval", "executed", "stale", "expired"], + item_order: ["queue_rank_asc", "handoff_rank_asc", "score_desc", "handoff_item_id_asc"], + review_action_order: ["mark_ready", "mark_pending_approval", "mark_executed", "mark_stale", "mark_expired"], + }, + handoff_queue_groups: { + ready: { + items: [ + { + queue_rank: 1, + handoff_rank: 1, + handoff_item_id: "handoff-1-recommended_next_action-priority-live-1", + lifecycle_state: "ready", + state_reason: "Handoff item is ready for explicit operator review.", + source_kind: "recommended_next_action", + source_reference_id: "priority-live-1", + title: "Next Action: Send partner follow-up", + recommendation_action: "execute_next_action", + priority_posture: "urgent", + confidence_posture: "medium", + score: 1650, + age_hours_relative_to_latest: 0, + review_action_order: ["mark_ready", "mark_pending_approval", "mark_executed", "mark_stale", "mark_expired"], + available_review_actions: ["mark_pending_approval", "mark_executed", "mark_stale", "mark_expired"], + last_review_action: null, + provenance_references: [ + { + source_kind: "continuity_capture_event", + source_id: "capture-live-1", + }, + ], + }, + ], + summary: { + lifecycle_state: "ready", + returned_count: 1, + total_count: 1, + order: ["queue_rank_asc", "handoff_rank_asc", "score_desc", "handoff_item_id_asc"], + }, + empty_state: { + is_empty: false, + message: "No ready handoff items for this scope.", + }, + }, + pending_approval: { + items: [], + summary: { + lifecycle_state: "pending_approval", + returned_count: 0, + total_count: 0, + order: ["queue_rank_asc", "handoff_rank_asc", "score_desc", "handoff_item_id_asc"], + }, + empty_state: { + is_empty: true, + message: "No handoff items are currently pending approval.", + }, + }, + executed: { + items: [], + summary: { + lifecycle_state: "executed", + returned_count: 0, + total_count: 0, + order: ["queue_rank_asc", "handoff_rank_asc", "score_desc", "handoff_item_id_asc"], + }, + empty_state: { + is_empty: true, + message: "No handoff items are currently marked executed.", + }, + }, + stale: { + items: [], + summary: { + lifecycle_state: "stale", + returned_count: 0, + total_count: 0, + order: ["queue_rank_asc", "handoff_rank_asc", "score_desc", "handoff_item_id_asc"], + }, + empty_state: { + is_empty: true, + message: "No stale handoff items are currently surfaced.", + }, + }, + expired: { + items: [], + summary: { + lifecycle_state: "expired", + returned_count: 0, + total_count: 0, + order: ["queue_rank_asc", "handoff_rank_asc", "score_desc", "handoff_item_id_asc"], + }, + empty_state: { + is_empty: true, + message: "No expired handoff items are currently surfaced.", + }, + }, + }, + handoff_review_actions: [], + handoff_outcome_summary: { + returned_count: 0, + total_count: 0, + latest_total_count: 0, + status_counts: { + reviewed: 0, + approved: 0, + rejected: 0, + rewritten: 0, + executed: 0, + ignored: 0, + expired: 0, + }, + latest_status_counts: { + reviewed: 0, + approved: 0, + rejected: 0, + rewritten: 0, + executed: 0, + ignored: 0, + expired: 0, + }, + status_order: ["reviewed", "approved", "rejected", "rewritten", "executed", "ignored", "expired"], + order: ["created_at_desc", "id_desc"], + }, + handoff_outcomes: [], + closure_quality_summary: { + posture: "insufficient_signal", + reason: "No handoff outcomes are captured yet, so closure quality remains informational.", + closed_loop_count: 0, + unresolved_count: 0, + rejected_count: 0, + ignored_count: 0, + expired_count: 0, + closure_rate: 0, + explanation: "Closure quality uses the latest immutable outcome per handoff item.", + }, + conversion_signal_summary: { + total_handoff_count: 1, + latest_outcome_count: 0, + executed_count: 0, + approved_count: 0, + reviewed_count: 0, + rewritten_count: 0, + rejected_count: 0, + ignored_count: 0, + expired_count: 0, + recommendation_to_execution_conversion_rate: 0, + recommendation_to_closure_conversion_rate: 0, + capture_coverage_rate: 0, + explanation: "Conversion signals are derived from latest immutable outcomes.", + }, + stale_ignored_escalation_posture: { + posture: "watch", + reason: "No stale queue pressure or ignored/expired latest outcomes are currently detected.", + stale_queue_count: 0, + ignored_count: 0, + expired_count: 0, + trigger_count: 0, + guidance_posture_explanation: + "Guidance posture is derived from stale queue load plus ignored/expired latest outcome counts.", + supporting_signals: [ + "stale_queue_count=0", + "ignored_count=0", + "expired_count=0", + "trigger_count=0", + ], + }, + execution_routing_summary: { + total_handoff_count: 1, + routed_handoff_count: 0, + unrouted_handoff_count: 1, + task_workflow_draft_count: 0, + approval_workflow_draft_count: 0, + follow_up_draft_only_count: 0, + route_target_order: ["task_workflow_draft", "approval_workflow_draft", "follow_up_draft_only"], + routed_item_order: ["handoff_rank_asc", "handoff_item_id_asc"], + audit_order: ["created_at_desc", "id_desc"], + transition_order: ["routed", "reaffirmed"], + approval_required: true, + non_autonomous_guarantee: + "No task, approval, connector send, or external side effect is executed by this endpoint.", + reason: + "Routing transitions are explicit and auditable; task/approval/follow-up routes remain draft-only until separately submitted through governed workflows.", + }, + routed_handoff_items: [ + { + handoff_rank: 1, + handoff_item_id: "handoff-1-recommended_next_action-priority-live-1", + title: "Next Action: Send partner follow-up", + source_kind: "recommended_next_action", + recommendation_action: "execute_next_action", + route_target_order: ["task_workflow_draft", "approval_workflow_draft", "follow_up_draft_only"], + available_route_targets: ["task_workflow_draft", "approval_workflow_draft"], + routed_targets: [], + is_routed: false, + task_workflow_draft_routed: false, + approval_workflow_draft_routed: false, + follow_up_draft_only_routed: false, + follow_up_draft_only_applicable: false, + task_draft: { + status: "draft", + mode: "governed_request_draft", + approval_required: true, + auto_execute: false, + source_handoff_item_id: "handoff-1-recommended_next_action-priority-live-1", + title: "Next Action: Send partner follow-up", + summary: + "Draft-only governed request assembled from chief-of-staff handoff artifacts; requires explicit approval before any execution.", + target: { thread_id: "thread-1", task_id: null, project: null, person: null }, + request: { + action: "execute_next_action", + scope: "chief_of_staff_priority", + domain_hint: "planning", + risk_hint: "governed_handoff", + attributes: {}, + }, + rationale: + "Marked urgent because this item is a deterministic immediate focus from resumption signals.", + provenance_references: [ + { + source_kind: "continuity_capture_event", + source_id: "capture-live-1", + }, + ], + }, + approval_draft: { + status: "draft_only", + mode: "approval_request_draft", + decision: "approval_required", + approval_required: true, + auto_submit: false, + source_handoff_item_id: "handoff-1-recommended_next_action-priority-live-1", + request: { + action: "execute_next_action", + scope: "chief_of_staff_priority", + domain_hint: "planning", + risk_hint: "governed_handoff", + attributes: {}, + }, + reason: + "Execution remains approval-bounded. This approval draft is artifact-only and must be explicitly submitted and resolved before any side effect.", + required_checks: [ + "operator_review_handoff_artifact", + "submit_governed_approval_request", + "explicit_approval_resolution", + ], + provenance_references: [ + { + source_kind: "continuity_capture_event", + source_id: "capture-live-1", + }, + ], + }, + last_routing_transition: null, + }, + ], + routing_audit_trail: [], + execution_readiness_posture: { + posture: "approval_required_draft_only", + approval_required: true, + autonomous_execution: false, + external_side_effects_allowed: false, + approval_path_visible: true, + route_target_order: ["task_workflow_draft", "approval_workflow_draft", "follow_up_draft_only"], + required_route_targets: ["task_workflow_draft", "approval_workflow_draft"], + transition_order: ["routed", "reaffirmed"], + non_autonomous_guarantee: + "No task, approval, connector send, or external side effect is executed by this endpoint.", + reason: + "Execution routing remains draft-only and approval-bounded; operators can explicitly route handoff items into governed task/approval drafts with auditable transitions.", + }, + task_draft: { + status: "draft", + mode: "governed_request_draft", + approval_required: true, + auto_execute: false, + source_handoff_item_id: "handoff-1-recommended_next_action-priority-live-1", + title: "Next Action: Send partner follow-up", + summary: + "Draft-only governed request assembled from chief-of-staff handoff artifacts; requires explicit approval before any execution.", + target: { thread_id: "thread-1", task_id: null, project: null, person: null }, + request: { + action: "execute_next_action", + scope: "chief_of_staff_priority", + domain_hint: "planning", + risk_hint: "governed_handoff", + attributes: {}, + }, + rationale: "Marked urgent because this item is a deterministic immediate focus from resumption signals.", + provenance_references: [ + { + source_kind: "continuity_capture_event", + source_id: "capture-live-1", + }, + ], + }, + approval_draft: { + status: "draft_only", + mode: "approval_request_draft", + decision: "approval_required", + approval_required: true, + auto_submit: false, + source_handoff_item_id: "handoff-1-recommended_next_action-priority-live-1", + request: { + action: "execute_next_action", + scope: "chief_of_staff_priority", + domain_hint: "planning", + risk_hint: "governed_handoff", + attributes: {}, + }, + reason: + "Execution remains approval-bounded. This approval draft is artifact-only and must be explicitly submitted and resolved before any side effect.", + required_checks: [ + "operator_review_handoff_artifact", + "submit_governed_approval_request", + "explicit_approval_resolution", + ], + provenance_references: [ + { + source_kind: "continuity_capture_event", + source_id: "capture-live-1", + }, + ], + }, + execution_posture: { + posture: "approval_bounded_artifact_only", + approval_required: true, + autonomous_execution: false, + external_side_effects_allowed: false, + default_routing_decision: "approval_required", + required_operator_actions: [ + "review_handoff_items", + "submit_task_or_approval_request", + "resolve_approval_before_execution", + ], + non_autonomous_guarantee: + "No task, approval, connector send, or external side effect is executed by this endpoint.", + reason: "Chief-of-staff handoff artifacts are deterministic execution-prep only in P8-S29.", + }, + summary: { + limit: 12, + returned_count: 1, + total_count: 1, + posture_order: ["urgent", "important", "waiting", "blocked", "stale", "defer"], + order: ["score_desc", "created_at_desc", "id_desc"], + follow_through_posture_order: ["overdue", "stale_waiting_for", "slipped_commitment"], + follow_through_item_order: [ + "recommendation_action_desc", + "age_hours_desc", + "created_at_desc", + "id_desc", + ], + follow_through_total_count: 1, + overdue_count: 1, + stale_waiting_for_count: 0, + slipped_commitment_count: 0, + trust_confidence_posture: "medium", + trust_confidence_reason: + "Memory quality gate needs review, so recommendation confidence is capped at medium.", + quality_gate_status: "needs_review", + retrieval_status: "pass", + handoff_item_count: 1, + handoff_item_order: ["score_desc", "source_order_asc", "source_reference_id_asc"], + execution_posture_order: ["approval_bounded_artifact_only"], + handoff_queue_total_count: 1, + handoff_queue_ready_count: 1, + handoff_queue_pending_approval_count: 0, + handoff_queue_executed_count: 0, + handoff_queue_stale_count: 0, + handoff_queue_expired_count: 0, + handoff_queue_state_order: ["ready", "pending_approval", "executed", "stale", "expired"], + handoff_queue_group_order: ["ready", "pending_approval", "executed", "stale", "expired"], + handoff_queue_item_order: ["queue_rank_asc", "handoff_rank_asc", "score_desc", "handoff_item_id_asc"], + handoff_outcome_total_count: 0, + handoff_outcome_latest_count: 0, + handoff_outcome_executed_count: 0, + handoff_outcome_ignored_count: 0, + closure_quality_posture: "insufficient_signal", + stale_ignored_escalation_posture: "watch", + }, + sources: [ + "continuity_recall", + "memory_trust_dashboard", + "chief_of_staff_action_handoff", + "chief_of_staff_handoff_queue", + "chief_of_staff_handoff_review_actions", + "chief_of_staff_handoff_outcomes", + ], + }, + }); + + render(await ChiefOfStaffPage({ searchParams: Promise.resolve({ thread_id: "thread-1" }) })); + + expect(screen.getByText("Live API")).toBeInTheDocument(); + expect(screen.getByText("Live chief-of-staff brief")).toBeInTheDocument(); + expect(screen.getByText("Live follow-through")).toBeInTheDocument(); + expect(screen.getByText("Live preparation brief")).toBeInTheDocument(); + expect(screen.getByText("Live weekly review")).toBeInTheDocument(); + expect(screen.getByText("Live action handoff")).toBeInTheDocument(); + expect(screen.getByText("Live handoff queue")).toBeInTheDocument(); + expect(screen.getByText("Live execution routing")).toBeInTheDocument(); + expect(screen.getByText("Live outcome learning")).toBeInTheDocument(); + expect(screen.getAllByText("Next Action: Send partner follow-up").length).toBeGreaterThan(0); + expect(getChiefOfStaffPriorityBriefMock).toHaveBeenCalledWith( + "https://api.example.com", + "user-1", + expect.objectContaining({ threadId: "thread-1" }), + ); + }); +}); diff --git a/apps/web/app/chief-of-staff/page.tsx b/apps/web/app/chief-of-staff/page.tsx new file mode 100644 index 0000000..6520d03 --- /dev/null +++ b/apps/web/app/chief-of-staff/page.tsx @@ -0,0 +1,1196 @@ +import { ChiefOfStaffActionHandoffPanel } from "../../components/chief-of-staff-action-handoff-panel"; +import { ChiefOfStaffExecutionRoutingPanel } from "../../components/chief-of-staff-execution-routing-panel"; +import { ChiefOfStaffHandoffQueuePanel } from "../../components/chief-of-staff-handoff-queue-panel"; +import { ChiefOfStaffFollowThroughPanel } from "../../components/chief-of-staff-follow-through-panel"; +import { ChiefOfStaffOutcomeLearningPanel } from "../../components/chief-of-staff-outcome-learning-panel"; +import { ChiefOfStaffPreparationPanel } from "../../components/chief-of-staff-preparation-panel"; +import { ChiefOfStaffPriorityPanel } from "../../components/chief-of-staff-priority-panel"; +import { ChiefOfStaffWeeklyReviewPanel } from "../../components/chief-of-staff-weekly-review-panel"; +import { PageHeader } from "../../components/page-header"; +import { StatusBadge } from "../../components/status-badge"; +import type { ApiSource, ChiefOfStaffPriorityBrief } from "../../lib/api"; +import { + combinePageModes, + getApiConfig, + getChiefOfStaffPriorityBrief, + hasLiveApiConfig, + pageModeLabel, +} from "../../lib/api"; + +type SearchParams = Promise<Record<string, string | string[] | undefined>>; + +function normalizeParam(value: string | string[] | undefined) { + if (Array.isArray(value)) { + return normalizeParam(value[0]); + } + return value?.trim() ?? ""; +} + +function parseNonNegativeInt(value: string, fallback: number) { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed < 0) { + return fallback; + } + return parsed; +} + +const chiefOfStaffFixture: ChiefOfStaffPriorityBrief = { + assembly_version: "chief_of_staff_priority_brief_v0", + scope: { + thread_id: "thread-fixture-1", + since: null, + until: null, + }, + ranked_items: [ + { + rank: 1, + id: "priority-fixture-1", + capture_event_id: "capture-priority-fixture-1", + object_type: "NextAction", + status: "active", + title: "Next Action: Confirm launch checklist owner", + priority_posture: "urgent", + confidence_posture: "low", + confidence: 0.97, + score: 642.5, + provenance: { + thread_id: "thread-fixture-1", + source_event_ids: ["event-fixture-1"], + }, + created_at: "2026-03-31T10:05:00Z", + updated_at: "2026-03-31T10:05:00Z", + rationale: { + reasons: [ + "Marked urgent because this item is a deterministic immediate focus from resumption signals.", + "Confidence is explicitly downgraded by current memory trust posture.", + "Provenance references are attached from continuity recall evidence.", + ], + ranking_inputs: { + posture: "urgent", + open_loop_posture: "next_action", + recency_rank: 1, + age_hours_relative_to_latest: 0, + recall_relevance: 120, + scope_match_count: 1, + query_term_match_count: 1, + freshness_posture: "fresh", + provenance_posture: "strong", + supersession_posture: "current", + }, + provenance_references: [ + { + source_kind: "continuity_capture_event", + source_id: "capture-priority-fixture-1", + }, + ], + trust_signals: { + quality_gate_status: "insufficient_sample", + retrieval_status: "pass", + trust_confidence_cap: "low", + downgraded_by_trust: true, + reason: + "Memory quality gate is weak (insufficient sample or degraded), so recommendation confidence is capped at low.", + }, + }, + }, + { + rank: 2, + id: "priority-fixture-2", + capture_event_id: "capture-priority-fixture-2", + object_type: "WaitingFor", + status: "active", + title: "Waiting For: Vendor legal review", + priority_posture: "waiting", + confidence_posture: "low", + confidence: 0.88, + score: 434, + provenance: { + thread_id: "thread-fixture-1", + source_event_ids: ["event-fixture-2"], + }, + created_at: "2026-03-31T09:15:00Z", + updated_at: "2026-03-31T09:15:00Z", + rationale: { + reasons: [ + "Marked waiting because this item is in waiting-for posture and requires follow-through tracking.", + "Aging evidence: 0.8h older than the newest scoped priority candidate.", + "Provenance references are attached from continuity recall evidence.", + ], + ranking_inputs: { + posture: "waiting", + open_loop_posture: "waiting_for", + recency_rank: 2, + age_hours_relative_to_latest: 0.833333, + recall_relevance: 108, + scope_match_count: 1, + query_term_match_count: 0, + freshness_posture: "aging", + provenance_posture: "strong", + supersession_posture: "current", + }, + provenance_references: [ + { + source_kind: "continuity_capture_event", + source_id: "capture-priority-fixture-2", + }, + ], + trust_signals: { + quality_gate_status: "insufficient_sample", + retrieval_status: "pass", + trust_confidence_cap: "low", + downgraded_by_trust: true, + reason: + "Memory quality gate is weak (insufficient sample or degraded), so recommendation confidence is capped at low.", + }, + }, + }, + ], + overdue_items: [ + { + rank: 1, + id: "follow-fixture-overdue-1", + capture_event_id: "capture-follow-fixture-overdue-1", + object_type: "NextAction", + status: "active", + title: "Next Action: Send partner status follow-up", + current_priority_posture: "urgent", + follow_through_posture: "overdue", + recommendation_action: "escalate", + reason: + "Execution follow-through is overdue (posture=urgent, age=140.0h), so action 'escalate' is recommended.", + age_hours: 140, + provenance_references: [ + { + source_kind: "continuity_capture_event", + source_id: "capture-follow-fixture-overdue-1", + }, + ], + created_at: "2026-03-26T08:00:00Z", + updated_at: "2026-03-26T08:00:00Z", + }, + ], + stale_waiting_for_items: [ + { + rank: 1, + id: "follow-fixture-stale-waiting-1", + capture_event_id: "capture-follow-fixture-stale-waiting-1", + object_type: "WaitingFor", + status: "stale", + title: "Waiting For: Procurement approval", + current_priority_posture: "stale", + follow_through_posture: "stale_waiting_for", + recommendation_action: "nudge", + reason: + "Waiting-for item is stale (status=stale, age=96.0h from latest scoped item), so action 'nudge' is recommended.", + age_hours: 96, + provenance_references: [ + { + source_kind: "continuity_capture_event", + source_id: "capture-follow-fixture-stale-waiting-1", + }, + ], + created_at: "2026-03-27T00:00:00Z", + updated_at: "2026-03-27T00:00:00Z", + }, + ], + slipped_commitments: [ + { + rank: 1, + id: "follow-fixture-slipped-commitment-1", + capture_event_id: "capture-follow-fixture-slipped-commitment-1", + object_type: "Commitment", + status: "active", + title: "Commitment: Publish weekly status digest", + current_priority_posture: "important", + follow_through_posture: "slipped_commitment", + recommendation_action: "defer", + reason: + "Commitment is slipping (status=active, age=60.0h from latest scoped item), so action 'defer' is recommended.", + age_hours: 60, + provenance_references: [ + { + source_kind: "continuity_capture_event", + source_id: "capture-follow-fixture-slipped-commitment-1", + }, + ], + created_at: "2026-03-28T12:00:00Z", + updated_at: "2026-03-28T12:00:00Z", + }, + ], + escalation_posture: { + posture: "critical", + reason: "At least one follow-through item requires escalation.", + total_follow_through_count: 3, + nudge_count: 1, + defer_count: 1, + escalate_count: 1, + close_loop_candidate_count: 0, + }, + draft_follow_up: { + status: "drafted", + mode: "draft_only", + approval_required: true, + auto_send: false, + reason: "Highest-severity follow-through item selected deterministically for operator review.", + target_metadata: { + continuity_object_id: "follow-fixture-overdue-1", + capture_event_id: "capture-follow-fixture-overdue-1", + object_type: "NextAction", + priority_posture: "urgent", + follow_through_posture: "overdue", + recommendation_action: "escalate", + thread_id: "thread-fixture-1", + }, + content: { + subject: "Follow-up: Next Action: Send partner status follow-up", + body: [ + "Following up on: Next Action: Send partner status follow-up", + "Current follow-through posture: overdue", + "Current priority posture: urgent", + "Recommended action: escalate", + "Reason: Execution follow-through is overdue (posture=urgent, age=140.0h), so action 'escalate' is recommended.", + "", + "This draft is artifact-only and requires explicit approval before any external send.", + ].join("\\n"), + }, + }, + recommended_next_action: { + action_type: "execute_next_action", + title: "Next Action: Confirm launch checklist owner", + target_priority_id: "priority-fixture-1", + priority_posture: "urgent", + confidence_posture: "low", + reason: "Marked urgent because this item is a deterministic immediate focus from resumption signals.", + provenance_references: [ + { + source_kind: "continuity_capture_event", + source_id: "capture-priority-fixture-1", + }, + ], + deterministic_rank_key: "1:priority-fixture-1:642.500000", + }, + preparation_brief: { + scope: { + thread_id: "thread-fixture-1", + since: null, + until: null, + }, + context_items: [ + { + rank: 1, + id: "prep-context-fixture-1", + capture_event_id: "capture-prep-context-fixture-1", + object_type: "Decision", + status: "active", + title: "Decision: Keep rollout to one launch cohort", + reason: "Decision context carried forward for deterministic meeting prep.", + confidence_posture: "low", + provenance_references: [ + { + source_kind: "continuity_capture_event", + source_id: "capture-prep-context-fixture-1", + }, + ], + created_at: "2026-03-31T07:45:00Z", + }, + ], + last_decision: { + rank: 1, + id: "prep-decision-fixture-1", + capture_event_id: "capture-prep-decision-fixture-1", + object_type: "Decision", + status: "active", + title: "Decision: Keep rollout to one launch cohort", + reason: "Latest scoped decision included to ground upcoming preparation context.", + confidence_posture: "low", + provenance_references: [ + { + source_kind: "continuity_capture_event", + source_id: "capture-prep-decision-fixture-1", + }, + ], + created_at: "2026-03-31T07:45:00Z", + }, + open_loops: [], + next_action: { + rank: 1, + id: "prep-next-action-fixture-1", + capture_event_id: "capture-prep-next-action-fixture-1", + object_type: "NextAction", + status: "active", + title: "Next Action: Confirm launch checklist owner", + reason: "Next action is included to keep immediate execution focus explicit after interruption.", + confidence_posture: "low", + provenance_references: [ + { + source_kind: "continuity_capture_event", + source_id: "capture-prep-next-action-fixture-1", + }, + ], + created_at: "2026-03-31T10:05:00Z", + }, + confidence_posture: "low", + confidence_reason: + "Memory quality gate is weak (insufficient sample or degraded), so recommendation confidence is capped at low.", + summary: { + limit: 6, + returned_count: 1, + total_count: 1, + order: ["rank_asc", "created_at_desc", "id_desc"], + }, + }, + what_changed_summary: { + items: [ + { + rank: 1, + id: "what-changed-fixture-1", + capture_event_id: "capture-what-changed-fixture-1", + object_type: "NextAction", + status: "active", + title: "Next Action: Confirm launch checklist owner", + reason: "Included from deterministic continuity recent-changes ordering.", + confidence_posture: "low", + provenance_references: [ + { + source_kind: "continuity_capture_event", + source_id: "capture-what-changed-fixture-1", + }, + ], + created_at: "2026-03-31T10:05:00Z", + }, + ], + confidence_posture: "low", + confidence_reason: + "Memory quality gate is weak (insufficient sample or degraded), so recommendation confidence is capped at low.", + summary: { + limit: 6, + returned_count: 1, + total_count: 1, + order: ["rank_asc", "created_at_desc", "id_desc"], + }, + }, + prep_checklist: { + items: [ + { + rank: 1, + id: "prep-checklist-fixture-1", + capture_event_id: "capture-prep-checklist-fixture-1", + object_type: "WaitingFor", + status: "active", + title: "Waiting For: Vendor legal review", + reason: "Prepare a status check and explicit owner for this unresolved open loop.", + confidence_posture: "low", + provenance_references: [ + { + source_kind: "continuity_capture_event", + source_id: "capture-prep-checklist-fixture-1", + }, + ], + created_at: "2026-03-31T09:15:00Z", + }, + ], + confidence_posture: "low", + confidence_reason: + "Memory quality gate is weak (insufficient sample or degraded), so recommendation confidence is capped at low.", + summary: { + limit: 6, + returned_count: 1, + total_count: 1, + order: ["rank_asc", "created_at_desc", "id_desc"], + }, + }, + suggested_talking_points: { + items: [ + { + rank: 1, + id: "suggested-point-fixture-1", + capture_event_id: "capture-suggested-point-fixture-1", + object_type: "Blocker", + status: "active", + title: "Blocker: Release token rotation lag", + reason: "Raise this unresolved dependency explicitly and confirm a concrete follow-up path.", + confidence_posture: "low", + provenance_references: [ + { + source_kind: "continuity_capture_event", + source_id: "capture-suggested-point-fixture-1", + }, + ], + created_at: "2026-03-30T10:00:00Z", + }, + ], + confidence_posture: "low", + confidence_reason: + "Memory quality gate is weak (insufficient sample or degraded), so recommendation confidence is capped at low.", + summary: { + limit: 6, + returned_count: 1, + total_count: 1, + order: ["rank_asc", "created_at_desc", "id_desc"], + }, + }, + resumption_supervision: { + recommendations: [ + { + rank: 1, + action: "execute_next_action", + title: "Next Action: Confirm launch checklist owner", + reason: "Marked urgent because this item is a deterministic immediate focus from resumption signals.", + confidence_posture: "low", + target_priority_id: "priority-fixture-1", + provenance_references: [ + { + source_kind: "continuity_capture_event", + source_id: "capture-priority-fixture-1", + }, + ], + }, + { + rank: 2, + action: "review_scope", + title: "Calibrate recommendation confidence before execution", + reason: + "Memory quality gate is weak (insufficient sample or degraded), so recommendation confidence is capped at low.", + confidence_posture: "low", + target_priority_id: null, + provenance_references: [], + }, + ], + confidence_posture: "low", + confidence_reason: + "Memory quality gate is weak (insufficient sample or degraded), so recommendation confidence is capped at low.", + summary: { + limit: 3, + returned_count: 2, + total_count: 2, + order: ["rank_asc"], + }, + }, + weekly_review_brief: { + scope: { + thread_id: "thread-fixture-1", + since: null, + until: null, + }, + rollup: { + total_count: 4, + waiting_for_count: 1, + blocker_count: 1, + stale_count: 1, + correction_recurrence_count: 1, + freshness_drift_count: 1, + next_action_count: 1, + posture_order: ["waiting_for", "blocker", "stale", "next_action"], + }, + guidance: [ + { + rank: 1, + action: "escalate", + signal_count: 2, + rationale: + "Escalate where blockers (1) and escalate actions (1) indicate execution risk.", + }, + { + rank: 2, + action: "close", + signal_count: 1, + rationale: + "Close loops where close candidates (0) and actionable next steps (1) support deterministic closure.", + }, + { + rank: 3, + action: "defer", + signal_count: 2, + rationale: + "Defer or park work where defer actions (1), stale items (1), and waiting-for load (1) are concentrated.", + }, + ], + summary: { + guidance_order: ["close", "defer", "escalate"], + guidance_item_order: ["signal_count_desc", "action_desc"], + }, + }, + recommendation_outcomes: { + items: [ + { + id: "outcome-fixture-1", + capture_event_id: "capture-outcome-fixture-1", + outcome: "accept", + recommendation_action_type: "execute_next_action", + recommendation_title: "Next Action: Confirm launch checklist owner", + rewritten_title: null, + target_priority_id: "priority-fixture-1", + rationale: "Accepted in weekly review because blockers were already explicit.", + provenance_references: [ + { + source_kind: "continuity_capture_event", + source_id: "capture-outcome-fixture-1", + }, + ], + created_at: "2026-03-31T12:00:00Z", + updated_at: "2026-03-31T12:00:00Z", + }, + ], + summary: { + returned_count: 1, + total_count: 1, + outcome_counts: { + accept: 1, + defer: 0, + ignore: 0, + rewrite: 0, + }, + order: ["created_at_desc", "id_desc"], + }, + }, + priority_learning_summary: { + total_count: 1, + accept_count: 1, + defer_count: 0, + ignore_count: 0, + rewrite_count: 0, + acceptance_rate: 1, + override_rate: 0, + defer_hotspots: [], + ignore_hotspots: [], + priority_shift_explanation: + "Prioritization is reinforcing currently accepted recommendation patterns while tracking defer/override hotspots.", + hotspot_order: ["count_desc", "key_asc"], + }, + pattern_drift_summary: { + posture: "improving", + reason: + "Accepted outcomes are leading with bounded defers/overrides, indicating improving recommendation fit.", + supporting_signals: [ + "Outcomes captured: 1", + "Accept=1, Defer=0, Ignore=0, Rewrite=0", + "Acceptance rate=1.000000, Override rate=0.000000", + ], + }, + action_handoff_brief: { + summary: + "Prepared 1 deterministic handoff item from recommended_next_action signals. All task and approval drafts remain artifact-only and approval-bounded.", + confidence_posture: "low", + non_autonomous_guarantee: + "No task, approval, connector send, or external side effect is executed by this endpoint.", + order: ["score_desc", "source_order_asc", "source_reference_id_asc"], + source_order: ["recommended_next_action", "follow_through", "prep_checklist", "weekly_review"], + provenance_references: [ + { + source_kind: "continuity_capture_event", + source_id: "capture-priority-fixture-1", + }, + { + source_kind: "continuity_capture_event", + source_id: "capture-follow-fixture-overdue-1", + }, + ], + }, + handoff_items: [ + { + rank: 1, + handoff_item_id: "handoff-1-recommended_next_action-priority-fixture-1", + source_kind: "recommended_next_action", + source_reference_id: "priority-fixture-1", + title: "Next Action: Confirm launch checklist owner", + recommendation_action: "execute_next_action", + priority_posture: "urgent", + confidence_posture: "low", + rationale: "Marked urgent because this item is a deterministic immediate focus from resumption signals.", + provenance_references: [ + { + source_kind: "continuity_capture_event", + source_id: "capture-priority-fixture-1", + }, + ], + score: 1650, + task_draft: { + status: "draft", + mode: "governed_request_draft", + approval_required: true, + auto_execute: false, + source_handoff_item_id: "handoff-1-recommended_next_action-priority-fixture-1", + title: "Next Action: Confirm launch checklist owner", + summary: + "Draft-only governed request assembled from chief-of-staff handoff artifacts; requires explicit approval before any execution.", + target: { + thread_id: "thread-fixture-1", + task_id: null, + project: null, + person: null, + }, + request: { + action: "execute_next_action", + scope: "chief_of_staff_priority", + domain_hint: "planning", + risk_hint: "governed_handoff", + attributes: {}, + }, + rationale: "Marked urgent because this item is a deterministic immediate focus from resumption signals.", + provenance_references: [ + { + source_kind: "continuity_capture_event", + source_id: "capture-priority-fixture-1", + }, + ], + }, + approval_draft: { + status: "draft_only", + mode: "approval_request_draft", + decision: "approval_required", + approval_required: true, + auto_submit: false, + source_handoff_item_id: "handoff-1-recommended_next_action-priority-fixture-1", + request: { + action: "execute_next_action", + scope: "chief_of_staff_priority", + domain_hint: "planning", + risk_hint: "governed_handoff", + attributes: {}, + }, + reason: + "Execution remains approval-bounded. This approval draft is artifact-only and must be explicitly submitted and resolved before any side effect.", + required_checks: [ + "operator_review_handoff_artifact", + "submit_governed_approval_request", + "explicit_approval_resolution", + ], + provenance_references: [ + { + source_kind: "continuity_capture_event", + source_id: "capture-priority-fixture-1", + }, + ], + }, + }, + ], + handoff_queue_summary: { + total_count: 1, + ready_count: 1, + pending_approval_count: 0, + executed_count: 0, + stale_count: 0, + expired_count: 0, + state_order: ["ready", "pending_approval", "executed", "stale", "expired"], + group_order: ["ready", "pending_approval", "executed", "stale", "expired"], + item_order: ["queue_rank_asc", "handoff_rank_asc", "score_desc", "handoff_item_id_asc"], + review_action_order: ["mark_ready", "mark_pending_approval", "mark_executed", "mark_stale", "mark_expired"], + }, + handoff_queue_groups: { + ready: { + items: [ + { + queue_rank: 1, + handoff_rank: 1, + handoff_item_id: "handoff-1-recommended_next_action-priority-fixture-1", + lifecycle_state: "ready", + state_reason: "Handoff item is ready for explicit operator review.", + source_kind: "recommended_next_action", + source_reference_id: "priority-fixture-1", + title: "Next Action: Confirm launch checklist owner", + recommendation_action: "execute_next_action", + priority_posture: "urgent", + confidence_posture: "low", + score: 1650, + age_hours_relative_to_latest: 0, + review_action_order: ["mark_ready", "mark_pending_approval", "mark_executed", "mark_stale", "mark_expired"], + available_review_actions: ["mark_pending_approval", "mark_executed", "mark_stale", "mark_expired"], + last_review_action: null, + provenance_references: [ + { + source_kind: "continuity_capture_event", + source_id: "capture-priority-fixture-1", + }, + ], + }, + ], + summary: { + lifecycle_state: "ready", + returned_count: 1, + total_count: 1, + order: ["queue_rank_asc", "handoff_rank_asc", "score_desc", "handoff_item_id_asc"], + }, + empty_state: { + is_empty: false, + message: "No ready handoff items for this scope.", + }, + }, + pending_approval: { + items: [], + summary: { + lifecycle_state: "pending_approval", + returned_count: 0, + total_count: 0, + order: ["queue_rank_asc", "handoff_rank_asc", "score_desc", "handoff_item_id_asc"], + }, + empty_state: { + is_empty: true, + message: "No handoff items are currently pending approval.", + }, + }, + executed: { + items: [], + summary: { + lifecycle_state: "executed", + returned_count: 0, + total_count: 0, + order: ["queue_rank_asc", "handoff_rank_asc", "score_desc", "handoff_item_id_asc"], + }, + empty_state: { + is_empty: true, + message: "No handoff items are currently marked executed.", + }, + }, + stale: { + items: [], + summary: { + lifecycle_state: "stale", + returned_count: 0, + total_count: 0, + order: ["queue_rank_asc", "handoff_rank_asc", "score_desc", "handoff_item_id_asc"], + }, + empty_state: { + is_empty: true, + message: "No stale handoff items are currently surfaced.", + }, + }, + expired: { + items: [], + summary: { + lifecycle_state: "expired", + returned_count: 0, + total_count: 0, + order: ["queue_rank_asc", "handoff_rank_asc", "score_desc", "handoff_item_id_asc"], + }, + empty_state: { + is_empty: true, + message: "No expired handoff items are currently surfaced.", + }, + }, + }, + handoff_review_actions: [], + handoff_outcome_summary: { + returned_count: 0, + total_count: 0, + latest_total_count: 0, + status_counts: { + reviewed: 0, + approved: 0, + rejected: 0, + rewritten: 0, + executed: 0, + ignored: 0, + expired: 0, + }, + latest_status_counts: { + reviewed: 0, + approved: 0, + rejected: 0, + rewritten: 0, + executed: 0, + ignored: 0, + expired: 0, + }, + status_order: ["reviewed", "approved", "rejected", "rewritten", "executed", "ignored", "expired"], + order: ["created_at_desc", "id_desc"], + }, + handoff_outcomes: [], + closure_quality_summary: { + posture: "insufficient_signal", + reason: "No handoff outcomes are captured yet, so closure quality remains informational.", + closed_loop_count: 0, + unresolved_count: 0, + rejected_count: 0, + ignored_count: 0, + expired_count: 0, + closure_rate: 0, + explanation: + "Closure quality uses the latest immutable outcome per handoff item; no outcomes are currently captured.", + }, + conversion_signal_summary: { + total_handoff_count: 1, + latest_outcome_count: 0, + executed_count: 0, + approved_count: 0, + reviewed_count: 0, + rewritten_count: 0, + rejected_count: 0, + ignored_count: 0, + expired_count: 0, + recommendation_to_execution_conversion_rate: 0, + recommendation_to_closure_conversion_rate: 0, + capture_coverage_rate: 0, + explanation: + "Conversion signals are derived from latest immutable handoff outcomes per handoff item; outcomes have not been captured yet.", + }, + stale_ignored_escalation_posture: { + posture: "watch", + reason: "No stale queue pressure or ignored/expired latest outcomes are currently detected.", + stale_queue_count: 0, + ignored_count: 0, + expired_count: 0, + trigger_count: 0, + guidance_posture_explanation: + "Guidance posture is derived from stale queue load plus ignored/expired latest outcome counts.", + supporting_signals: [ + "stale_queue_count=0", + "ignored_count=0", + "expired_count=0", + "trigger_count=0", + ], + }, + execution_routing_summary: { + total_handoff_count: 1, + routed_handoff_count: 0, + unrouted_handoff_count: 1, + task_workflow_draft_count: 0, + approval_workflow_draft_count: 0, + follow_up_draft_only_count: 0, + route_target_order: ["task_workflow_draft", "approval_workflow_draft", "follow_up_draft_only"], + routed_item_order: ["handoff_rank_asc", "handoff_item_id_asc"], + audit_order: ["created_at_desc", "id_desc"], + transition_order: ["routed", "reaffirmed"], + approval_required: true, + non_autonomous_guarantee: + "No task, approval, connector send, or external side effect is executed by this endpoint.", + reason: + "Routing transitions are explicit and auditable; task/approval/follow-up routes remain draft-only until separately submitted through governed workflows.", + }, + routed_handoff_items: [ + { + handoff_rank: 1, + handoff_item_id: "handoff-1-recommended_next_action-priority-fixture-1", + title: "Next Action: Confirm launch checklist owner", + source_kind: "recommended_next_action", + recommendation_action: "execute_next_action", + route_target_order: ["task_workflow_draft", "approval_workflow_draft", "follow_up_draft_only"], + available_route_targets: ["task_workflow_draft", "approval_workflow_draft"], + routed_targets: [], + is_routed: false, + task_workflow_draft_routed: false, + approval_workflow_draft_routed: false, + follow_up_draft_only_routed: false, + follow_up_draft_only_applicable: false, + task_draft: { + status: "draft", + mode: "governed_request_draft", + approval_required: true, + auto_execute: false, + source_handoff_item_id: "handoff-1-recommended_next_action-priority-fixture-1", + title: "Next Action: Confirm launch checklist owner", + summary: + "Draft-only governed request assembled from chief-of-staff handoff artifacts; requires explicit approval before any execution.", + target: { + thread_id: "thread-fixture-1", + task_id: null, + project: null, + person: null, + }, + request: { + action: "execute_next_action", + scope: "chief_of_staff_priority", + domain_hint: "planning", + risk_hint: "governed_handoff", + attributes: {}, + }, + rationale: "Marked urgent because this item is a deterministic immediate focus from resumption signals.", + provenance_references: [ + { + source_kind: "continuity_capture_event", + source_id: "capture-priority-fixture-1", + }, + ], + }, + approval_draft: { + status: "draft_only", + mode: "approval_request_draft", + decision: "approval_required", + approval_required: true, + auto_submit: false, + source_handoff_item_id: "handoff-1-recommended_next_action-priority-fixture-1", + request: { + action: "execute_next_action", + scope: "chief_of_staff_priority", + domain_hint: "planning", + risk_hint: "governed_handoff", + attributes: {}, + }, + reason: + "Execution remains approval-bounded. This approval draft is artifact-only and must be explicitly submitted and resolved before any side effect.", + required_checks: [ + "operator_review_handoff_artifact", + "submit_governed_approval_request", + "explicit_approval_resolution", + ], + provenance_references: [ + { + source_kind: "continuity_capture_event", + source_id: "capture-priority-fixture-1", + }, + ], + }, + last_routing_transition: null, + }, + ], + routing_audit_trail: [], + execution_readiness_posture: { + posture: "approval_required_draft_only", + approval_required: true, + autonomous_execution: false, + external_side_effects_allowed: false, + approval_path_visible: true, + route_target_order: ["task_workflow_draft", "approval_workflow_draft", "follow_up_draft_only"], + required_route_targets: ["task_workflow_draft", "approval_workflow_draft"], + transition_order: ["routed", "reaffirmed"], + non_autonomous_guarantee: + "No task, approval, connector send, or external side effect is executed by this endpoint.", + reason: + "Execution routing remains draft-only and approval-bounded; operators can explicitly route handoff items into governed task/approval drafts with auditable transitions.", + }, + task_draft: { + status: "draft", + mode: "governed_request_draft", + approval_required: true, + auto_execute: false, + source_handoff_item_id: "handoff-1-recommended_next_action-priority-fixture-1", + title: "Next Action: Confirm launch checklist owner", + summary: + "Draft-only governed request assembled from chief-of-staff handoff artifacts; requires explicit approval before any execution.", + target: { + thread_id: "thread-fixture-1", + task_id: null, + project: null, + person: null, + }, + request: { + action: "execute_next_action", + scope: "chief_of_staff_priority", + domain_hint: "planning", + risk_hint: "governed_handoff", + attributes: {}, + }, + rationale: "Marked urgent because this item is a deterministic immediate focus from resumption signals.", + provenance_references: [ + { + source_kind: "continuity_capture_event", + source_id: "capture-priority-fixture-1", + }, + ], + }, + approval_draft: { + status: "draft_only", + mode: "approval_request_draft", + decision: "approval_required", + approval_required: true, + auto_submit: false, + source_handoff_item_id: "handoff-1-recommended_next_action-priority-fixture-1", + request: { + action: "execute_next_action", + scope: "chief_of_staff_priority", + domain_hint: "planning", + risk_hint: "governed_handoff", + attributes: {}, + }, + reason: + "Execution remains approval-bounded. This approval draft is artifact-only and must be explicitly submitted and resolved before any side effect.", + required_checks: [ + "operator_review_handoff_artifact", + "submit_governed_approval_request", + "explicit_approval_resolution", + ], + provenance_references: [ + { + source_kind: "continuity_capture_event", + source_id: "capture-priority-fixture-1", + }, + ], + }, + execution_posture: { + posture: "approval_bounded_artifact_only", + approval_required: true, + autonomous_execution: false, + external_side_effects_allowed: false, + default_routing_decision: "approval_required", + required_operator_actions: [ + "review_handoff_items", + "submit_task_or_approval_request", + "resolve_approval_before_execution", + ], + non_autonomous_guarantee: + "No task, approval, connector send, or external side effect is executed by this endpoint.", + reason: "Chief-of-staff handoff artifacts are deterministic execution-prep only in P8-S29.", + }, + summary: { + limit: 12, + returned_count: 2, + total_count: 2, + posture_order: ["urgent", "important", "waiting", "blocked", "stale", "defer"], + order: ["score_desc", "created_at_desc", "id_desc"], + follow_through_posture_order: ["overdue", "stale_waiting_for", "slipped_commitment"], + follow_through_item_order: [ + "recommendation_action_desc", + "age_hours_desc", + "created_at_desc", + "id_desc", + ], + follow_through_total_count: 3, + overdue_count: 1, + stale_waiting_for_count: 1, + slipped_commitment_count: 1, + trust_confidence_posture: "low", + trust_confidence_reason: + "Memory quality gate is weak (insufficient sample or degraded), so recommendation confidence is capped at low.", + quality_gate_status: "insufficient_sample", + retrieval_status: "pass", + handoff_item_count: 1, + handoff_item_order: ["score_desc", "source_order_asc", "source_reference_id_asc"], + execution_posture_order: ["approval_bounded_artifact_only"], + handoff_queue_total_count: 1, + handoff_queue_ready_count: 1, + handoff_queue_pending_approval_count: 0, + handoff_queue_executed_count: 0, + handoff_queue_stale_count: 0, + handoff_queue_expired_count: 0, + handoff_queue_state_order: ["ready", "pending_approval", "executed", "stale", "expired"], + handoff_queue_group_order: ["ready", "pending_approval", "executed", "stale", "expired"], + handoff_queue_item_order: ["queue_rank_asc", "handoff_rank_asc", "score_desc", "handoff_item_id_asc"], + handoff_outcome_total_count: 0, + handoff_outcome_latest_count: 0, + handoff_outcome_executed_count: 0, + handoff_outcome_ignored_count: 0, + closure_quality_posture: "insufficient_signal", + stale_ignored_escalation_posture: "watch", + }, + sources: [ + "continuity_recall", + "continuity_open_loops", + "continuity_resumption_brief", + "chief_of_staff_action_handoff", + "chief_of_staff_handoff_queue", + "chief_of_staff_handoff_review_actions", + "chief_of_staff_handoff_outcomes", + "chief_of_staff_execution_routing", + "memory_trust_dashboard", + ], +}; + +export default async function ChiefOfStaffPage({ + searchParams, +}: { + searchParams?: SearchParams; +}) { + const params = (searchParams ? await searchParams : {}) as Record< + string, + string | string[] | undefined + >; + const query = normalizeParam(params.query); + const threadId = normalizeParam(params.thread_id); + const taskId = normalizeParam(params.task_id); + const project = normalizeParam(params.project); + const person = normalizeParam(params.person); + const since = normalizeParam(params.since); + const until = normalizeParam(params.until); + const limit = parseNonNegativeInt(normalizeParam(params.limit), 12); + + const apiConfig = getApiConfig(); + const liveModeReady = hasLiveApiConfig(apiConfig); + + let brief = chiefOfStaffFixture; + let briefSource: ApiSource = "fixture"; + let briefUnavailableReason: string | undefined; + + if (liveModeReady) { + try { + const payload = await getChiefOfStaffPriorityBrief(apiConfig.apiBaseUrl, apiConfig.userId, { + query: query || undefined, + threadId: threadId || undefined, + taskId: taskId || undefined, + project: project || undefined, + person: person || undefined, + since: since || undefined, + until: until || undefined, + limit, + }); + brief = payload.brief; + briefSource = "live"; + } catch (error) { + briefUnavailableReason = + error instanceof Error ? error.message : "Chief-of-staff brief could not be loaded."; + } + } + + const mode = combinePageModes(briefSource); + + return ( + <div className="page-stack"> + <PageHeader + eyebrow="Phase 8" + title="Chief-of-staff" + description="Deterministic priority ranking, follow-through/preparation supervision, weekly review learning, and approval-bounded action handoff artifacts with explicit rationale." + meta={ + <div className="header-meta"> + <span className="subtle-chip">{pageModeLabel(mode)}</span> + <span className="subtle-chip">{brief.summary.returned_count} ranked priorities</span> + <span className="subtle-chip">{brief.summary.follow_through_total_count} follow-through items</span> + <span className="subtle-chip">{brief.prep_checklist.summary.returned_count} prep checklist items</span> + <span className="subtle-chip">{brief.summary.handoff_item_count} handoff items</span> + <span className="subtle-chip">{brief.summary.handoff_queue_ready_count} ready queue items</span> + <StatusBadge + status={brief.summary.trust_confidence_posture} + label={`${brief.summary.trust_confidence_posture} confidence`} + /> + </div> + } + /> + + <ChiefOfStaffPriorityPanel + brief={brief} + source={briefSource} + unavailableReason={briefUnavailableReason} + /> + <ChiefOfStaffFollowThroughPanel + brief={brief} + source={briefSource} + unavailableReason={briefUnavailableReason} + /> + <ChiefOfStaffPreparationPanel + brief={brief} + source={briefSource} + unavailableReason={briefUnavailableReason} + /> + <ChiefOfStaffWeeklyReviewPanel + apiBaseUrl={briefSource === "live" ? apiConfig.apiBaseUrl : undefined} + userId={briefSource === "live" ? apiConfig.userId : undefined} + brief={brief} + source={briefSource} + unavailableReason={briefUnavailableReason} + /> + <ChiefOfStaffActionHandoffPanel + brief={brief} + source={briefSource} + unavailableReason={briefUnavailableReason} + /> + <ChiefOfStaffHandoffQueuePanel + apiBaseUrl={briefSource === "live" ? apiConfig.apiBaseUrl : undefined} + userId={briefSource === "live" ? apiConfig.userId : undefined} + brief={brief} + source={briefSource} + unavailableReason={briefUnavailableReason} + /> + <ChiefOfStaffExecutionRoutingPanel + apiBaseUrl={briefSource === "live" ? apiConfig.apiBaseUrl : undefined} + userId={briefSource === "live" ? apiConfig.userId : undefined} + brief={brief} + source={briefSource} + unavailableReason={briefUnavailableReason} + /> + <ChiefOfStaffOutcomeLearningPanel + apiBaseUrl={briefSource === "live" ? apiConfig.apiBaseUrl : undefined} + userId={briefSource === "live" ? apiConfig.userId : undefined} + brief={brief} + source={briefSource} + unavailableReason={briefUnavailableReason} + /> + </div> + ); +} diff --git a/apps/web/app/continuity/page.test.tsx b/apps/web/app/continuity/page.test.tsx new file mode 100644 index 0000000..24dd057 --- /dev/null +++ b/apps/web/app/continuity/page.test.tsx @@ -0,0 +1,546 @@ +import React from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import ContinuityPage from "./page"; + +const { + getApiConfigMock, + getContinuityDailyBriefMock, + getContinuityOpenLoopDashboardMock, + getContinuityCaptureDetailMock, + getContinuityReviewDetailMock, + getContinuityResumptionBriefMock, + getContinuityWeeklyReviewMock, + hasLiveApiConfigMock, + listContinuityReviewQueueMock, + listContinuityCapturesMock, + queryContinuityRecallMock, + refreshMock, +} = vi.hoisted(() => ({ + getApiConfigMock: vi.fn(), + getContinuityDailyBriefMock: vi.fn(), + getContinuityOpenLoopDashboardMock: vi.fn(), + getContinuityCaptureDetailMock: vi.fn(), + getContinuityReviewDetailMock: vi.fn(), + getContinuityResumptionBriefMock: vi.fn(), + getContinuityWeeklyReviewMock: vi.fn(), + hasLiveApiConfigMock: vi.fn(), + listContinuityReviewQueueMock: vi.fn(), + listContinuityCapturesMock: vi.fn(), + queryContinuityRecallMock: vi.fn(), + refreshMock: vi.fn(), +})); + +vi.mock("next/link", () => ({ + default: ({ + href, + children, + className, + "aria-current": ariaCurrent, + }: { + href: string; + children: React.ReactNode; + className?: string; + "aria-current"?: string; + }) => ( + <a href={href} className={className} aria-current={ariaCurrent}> + {children} + </a> + ), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + refresh: refreshMock, + }), +})); + +vi.mock("../../lib/api", async () => { + const actual = await vi.importActual("../../lib/api"); + return { + ...actual, + getApiConfig: getApiConfigMock, + getContinuityDailyBrief: getContinuityDailyBriefMock, + getContinuityOpenLoopDashboard: getContinuityOpenLoopDashboardMock, + getContinuityCaptureDetail: getContinuityCaptureDetailMock, + getContinuityReviewDetail: getContinuityReviewDetailMock, + getContinuityResumptionBrief: getContinuityResumptionBriefMock, + getContinuityWeeklyReview: getContinuityWeeklyReviewMock, + hasLiveApiConfig: hasLiveApiConfigMock, + listContinuityReviewQueue: listContinuityReviewQueueMock, + listContinuityCaptures: listContinuityCapturesMock, + queryContinuityRecall: queryContinuityRecallMock, + }; +}); + +describe("ContinuityPage", () => { + beforeEach(() => { + getApiConfigMock.mockReset(); + getContinuityDailyBriefMock.mockReset(); + getContinuityOpenLoopDashboardMock.mockReset(); + getContinuityCaptureDetailMock.mockReset(); + getContinuityReviewDetailMock.mockReset(); + getContinuityResumptionBriefMock.mockReset(); + getContinuityWeeklyReviewMock.mockReset(); + hasLiveApiConfigMock.mockReset(); + listContinuityReviewQueueMock.mockReset(); + listContinuityCapturesMock.mockReset(); + queryContinuityRecallMock.mockReset(); + refreshMock.mockReset(); + + getApiConfigMock.mockReturnValue({ + apiBaseUrl: "", + userId: "", + defaultThreadId: "", + defaultToolId: "", + }); + hasLiveApiConfigMock.mockReturnValue(false); + }); + + afterEach(() => { + cleanup(); + }); + + it("uses fixture continuity state when live API config is absent", async () => { + render(await ContinuityPage({ searchParams: Promise.resolve({}) })); + + expect(screen.getByText("Fixture-backed")).toBeInTheDocument(); + expect(screen.getByText("Fixture inbox")).toBeInTheDocument(); + expect(screen.getByText("Fixture recall")).toBeInTheDocument(); + expect(screen.getByText("Fixture brief")).toBeInTheDocument(); + expect(screen.getByText("Fixture open loops")).toBeInTheDocument(); + expect(screen.getByText("Fixture daily brief")).toBeInTheDocument(); + expect(screen.getByText("Fixture weekly review")).toBeInTheDocument(); + expect(screen.getByText("Fixture review queue")).toBeInTheDocument(); + expect(screen.getByText("Continuity recall")).toBeInTheDocument(); + expect(screen.getAllByText("Correction actions").length).toBeGreaterThan(0); + expect(listContinuityCapturesMock).not.toHaveBeenCalled(); + expect(queryContinuityRecallMock).not.toHaveBeenCalled(); + expect(getContinuityResumptionBriefMock).not.toHaveBeenCalled(); + expect(getContinuityOpenLoopDashboardMock).not.toHaveBeenCalled(); + expect(getContinuityDailyBriefMock).not.toHaveBeenCalled(); + expect(getContinuityWeeklyReviewMock).not.toHaveBeenCalled(); + expect(listContinuityReviewQueueMock).not.toHaveBeenCalled(); + }); + + it("renders live continuity inbox, recall, and resumption when reads succeed", async () => { + getApiConfigMock.mockReturnValue({ + apiBaseUrl: "https://api.example.com", + userId: "user-1", + defaultThreadId: "thread-1", + defaultToolId: "tool-1", + }); + hasLiveApiConfigMock.mockReturnValue(true); + listContinuityCapturesMock.mockResolvedValue({ + items: [ + { + capture_event: { + id: "capture-live-1", + raw_content: "Decision: Keep admission conservative", + explicit_signal: null, + admission_posture: "DERIVED", + admission_reason: "high_confidence_prefix_decision", + created_at: "2026-03-29T09:20:00Z", + }, + derived_object: { + id: "object-live-1", + capture_event_id: "capture-live-1", + object_type: "Decision", + status: "active", + title: "Decision: Keep admission conservative", + body: { + decision_text: "Keep admission conservative", + }, + provenance: { + capture_event_id: "capture-live-1", + source_kind: "continuity_capture_event", + }, + confidence: 0.95, + created_at: "2026-03-29T09:20:00Z", + updated_at: "2026-03-29T09:20:00Z", + }, + }, + ], + summary: { + limit: 20, + returned_count: 1, + total_count: 1, + derived_count: 1, + triage_count: 0, + order: ["created_at_desc", "id_desc"], + }, + }); + getContinuityCaptureDetailMock.mockResolvedValue({ + capture: { + capture_event: { + id: "capture-live-1", + raw_content: "Decision: Keep admission conservative", + explicit_signal: null, + admission_posture: "DERIVED", + admission_reason: "high_confidence_prefix_decision", + created_at: "2026-03-29T09:20:00Z", + }, + derived_object: { + id: "object-live-1", + capture_event_id: "capture-live-1", + object_type: "Decision", + status: "active", + title: "Decision: Keep admission conservative", + body: { + decision_text: "Keep admission conservative", + }, + provenance: { + capture_event_id: "capture-live-1", + source_kind: "continuity_capture_event", + }, + confidence: 0.95, + created_at: "2026-03-29T09:20:00Z", + updated_at: "2026-03-29T09:20:00Z", + }, + }, + }); + queryContinuityRecallMock.mockResolvedValue({ + items: [ + { + id: "recall-live-1", + capture_event_id: "capture-live-1", + object_type: "Decision", + status: "active", + title: "Decision: Keep admission conservative", + body: { decision_text: "Keep admission conservative" }, + provenance: { thread_id: "thread-1" }, + confirmation_status: "confirmed", + admission_posture: "DERIVED", + confidence: 0.95, + relevance: 130, + last_confirmed_at: "2026-03-29T09:20:00Z", + supersedes_object_id: null, + superseded_by_object_id: null, + scope_matches: [{ kind: "thread", value: "thread-1" }], + provenance_references: [{ source_kind: "continuity_capture_event", source_id: "capture-live-1" }], + ordering: { + scope_match_count: 1, + query_term_match_count: 1, + confirmation_rank: 3, + freshness_posture: "fresh", + freshness_rank: 4, + provenance_posture: "partial", + provenance_rank: 2, + supersession_posture: "current", + supersession_rank: 3, + posture_rank: 2, + lifecycle_rank: 4, + confidence: 0.95, + }, + created_at: "2026-03-29T09:20:00Z", + updated_at: "2026-03-29T09:20:00Z", + }, + ], + summary: { + query: "decision", + filters: { + thread_id: "thread-1", + since: null, + until: null, + }, + limit: 20, + returned_count: 1, + total_count: 1, + order: ["relevance_desc", "created_at_desc", "id_desc"], + }, + }); + getContinuityResumptionBriefMock.mockResolvedValue({ + brief: { + assembly_version: "continuity_resumption_brief_v0", + scope: { thread_id: "thread-1", since: null, until: null }, + last_decision: { + item: { + id: "recall-live-1", + capture_event_id: "capture-live-1", + object_type: "Decision", + status: "active", + title: "Decision: Keep admission conservative", + body: { decision_text: "Keep admission conservative" }, + provenance: { thread_id: "thread-1" }, + confirmation_status: "confirmed", + admission_posture: "DERIVED", + confidence: 0.95, + relevance: 130, + last_confirmed_at: "2026-03-29T09:20:00Z", + supersedes_object_id: null, + superseded_by_object_id: null, + scope_matches: [{ kind: "thread", value: "thread-1" }], + provenance_references: [{ source_kind: "continuity_capture_event", source_id: "capture-live-1" }], + ordering: { + scope_match_count: 1, + query_term_match_count: 1, + confirmation_rank: 3, + freshness_posture: "fresh", + freshness_rank: 4, + provenance_posture: "partial", + provenance_rank: 2, + supersession_posture: "current", + supersession_rank: 3, + posture_rank: 2, + lifecycle_rank: 4, + confidence: 0.95, + }, + created_at: "2026-03-29T09:20:00Z", + updated_at: "2026-03-29T09:20:00Z", + }, + empty_state: { is_empty: false, message: "No decision found in the requested scope." }, + }, + open_loops: { + items: [], + summary: { limit: 5, returned_count: 0, total_count: 0, order: ["created_at_desc", "id_desc"] }, + empty_state: { is_empty: true, message: "No open loops found in the requested scope." }, + }, + recent_changes: { + items: [], + summary: { limit: 5, returned_count: 0, total_count: 1, order: ["created_at_desc", "id_desc"] }, + empty_state: { is_empty: true, message: "No recent changes found in the requested scope." }, + }, + next_action: { + item: null, + empty_state: { is_empty: true, message: "No next action found in the requested scope." }, + }, + sources: ["continuity_capture_events", "continuity_objects"], + }, + }); + getContinuityOpenLoopDashboardMock.mockResolvedValue({ + dashboard: { + scope: { thread_id: "thread-1", since: null, until: null }, + waiting_for: { + items: [], + summary: { limit: 20, returned_count: 0, total_count: 0, order: ["created_at_desc", "id_desc"] }, + empty_state: { is_empty: true, message: "No waiting-for items in the requested scope." }, + }, + blocker: { + items: [], + summary: { limit: 20, returned_count: 0, total_count: 0, order: ["created_at_desc", "id_desc"] }, + empty_state: { is_empty: true, message: "No blocker items in the requested scope." }, + }, + stale: { + items: [], + summary: { limit: 20, returned_count: 0, total_count: 0, order: ["created_at_desc", "id_desc"] }, + empty_state: { is_empty: true, message: "No stale items in the requested scope." }, + }, + next_action: { + items: [], + summary: { limit: 20, returned_count: 0, total_count: 0, order: ["created_at_desc", "id_desc"] }, + empty_state: { is_empty: true, message: "No next-action items in the requested scope." }, + }, + summary: { + limit: 20, + total_count: 0, + posture_order: ["waiting_for", "blocker", "stale", "next_action"], + item_order: ["created_at_desc", "id_desc"], + }, + sources: ["continuity_capture_events", "continuity_objects", "continuity_correction_events"], + }, + }); + getContinuityDailyBriefMock.mockResolvedValue({ + brief: { + assembly_version: "continuity_daily_brief_v0", + scope: { thread_id: "thread-1", since: null, until: null }, + waiting_for_highlights: { + items: [], + summary: { limit: 3, returned_count: 0, total_count: 0, order: ["created_at_desc", "id_desc"] }, + empty_state: { is_empty: true, message: "No waiting-for highlights for today in the requested scope." }, + }, + blocker_highlights: { + items: [], + summary: { limit: 3, returned_count: 0, total_count: 0, order: ["created_at_desc", "id_desc"] }, + empty_state: { is_empty: true, message: "No blocker highlights for today in the requested scope." }, + }, + stale_items: { + items: [], + summary: { limit: 3, returned_count: 0, total_count: 0, order: ["created_at_desc", "id_desc"] }, + empty_state: { is_empty: true, message: "No stale items for today in the requested scope." }, + }, + next_suggested_action: { + item: null, + empty_state: { is_empty: true, message: "No next suggested action in the requested scope." }, + }, + sources: ["continuity_capture_events", "continuity_objects", "continuity_correction_events"], + }, + }); + getContinuityWeeklyReviewMock.mockResolvedValue({ + review: { + assembly_version: "continuity_weekly_review_v0", + scope: { thread_id: "thread-1", since: null, until: null }, + rollup: { + total_count: 0, + waiting_for_count: 0, + blocker_count: 0, + stale_count: 0, + correction_recurrence_count: 0, + freshness_drift_count: 0, + next_action_count: 0, + posture_order: ["waiting_for", "blocker", "stale", "next_action"], + }, + waiting_for: { + items: [], + summary: { limit: 5, returned_count: 0, total_count: 0, order: ["created_at_desc", "id_desc"] }, + empty_state: { is_empty: true, message: "No waiting-for items in the requested scope." }, + }, + blocker: { + items: [], + summary: { limit: 5, returned_count: 0, total_count: 0, order: ["created_at_desc", "id_desc"] }, + empty_state: { is_empty: true, message: "No blocker items in the requested scope." }, + }, + stale: { + items: [], + summary: { limit: 5, returned_count: 0, total_count: 0, order: ["created_at_desc", "id_desc"] }, + empty_state: { is_empty: true, message: "No stale items in the requested scope." }, + }, + next_action: { + items: [], + summary: { limit: 5, returned_count: 0, total_count: 0, order: ["created_at_desc", "id_desc"] }, + empty_state: { is_empty: true, message: "No next-action items in the requested scope." }, + }, + sources: ["continuity_capture_events", "continuity_objects", "continuity_correction_events"], + }, + }); + listContinuityReviewQueueMock.mockResolvedValue({ + items: [ + { + id: "object-live-1", + capture_event_id: "capture-live-1", + object_type: "Decision", + status: "active", + title: "Decision: Keep admission conservative", + body: { + decision_text: "Keep admission conservative", + }, + provenance: { + capture_event_id: "capture-live-1", + }, + confidence: 0.95, + last_confirmed_at: "2026-03-29T09:20:00Z", + supersedes_object_id: null, + superseded_by_object_id: null, + created_at: "2026-03-29T09:20:00Z", + updated_at: "2026-03-29T09:20:00Z", + }, + ], + summary: { + status: "correction_ready", + limit: 20, + returned_count: 1, + total_count: 1, + order: ["updated_at_desc", "created_at_desc", "id_desc"], + }, + }); + getContinuityReviewDetailMock.mockResolvedValue({ + review: { + continuity_object: { + id: "object-live-1", + capture_event_id: "capture-live-1", + object_type: "Decision", + status: "active", + title: "Decision: Keep admission conservative", + body: { decision_text: "Keep admission conservative" }, + provenance: { capture_event_id: "capture-live-1" }, + confidence: 0.95, + last_confirmed_at: "2026-03-29T09:20:00Z", + supersedes_object_id: null, + superseded_by_object_id: null, + created_at: "2026-03-29T09:20:00Z", + updated_at: "2026-03-29T09:20:00Z", + }, + correction_events: [], + supersession_chain: { + supersedes: null, + superseded_by: null, + }, + }, + }); + + render(await ContinuityPage({ searchParams: Promise.resolve({ capture: "capture-live-1", recall_query: "decision" }) })); + + expect(screen.getByText("Live API")).toBeInTheDocument(); + expect(screen.getByText("Live inbox")).toBeInTheDocument(); + expect(screen.getByText("Live recall")).toBeInTheDocument(); + expect(screen.getByText("Live brief")).toBeInTheDocument(); + expect(screen.getByText("Live open loops")).toBeInTheDocument(); + expect(screen.getByText("Live daily brief")).toBeInTheDocument(); + expect(screen.getByText("Live weekly review")).toBeInTheDocument(); + expect(screen.getByText("Live review queue")).toBeInTheDocument(); + expect(screen.getAllByText("Decision: Keep admission conservative").length).toBeGreaterThan(0); + + expect(listContinuityCapturesMock).toHaveBeenCalledWith("https://api.example.com", "user-1", { + limit: 20, + }); + expect(getContinuityCaptureDetailMock).toHaveBeenCalledWith( + "https://api.example.com", + "capture-live-1", + "user-1", + ); + expect(queryContinuityRecallMock).toHaveBeenCalledWith("https://api.example.com", "user-1", { + query: "decision", + threadId: "", + taskId: "", + project: "", + person: "", + since: "", + until: "", + limit: 20, + }); + expect(listContinuityReviewQueueMock).toHaveBeenCalledWith("https://api.example.com", "user-1", { + status: "correction_ready", + limit: 20, + }); + expect(getContinuityReviewDetailMock).toHaveBeenCalledWith( + "https://api.example.com", + "object-live-1", + "user-1", + ); + expect(getContinuityResumptionBriefMock).toHaveBeenCalledWith("https://api.example.com", "user-1", { + query: "decision", + threadId: "", + taskId: "", + project: "", + person: "", + since: "", + until: "", + maxRecentChanges: 5, + maxOpenLoops: 5, + }); + expect(getContinuityOpenLoopDashboardMock).toHaveBeenCalledWith( + "https://api.example.com", + "user-1", + { + query: "decision", + threadId: "", + taskId: "", + project: "", + person: "", + since: "", + until: "", + limit: 20, + }, + ); + expect(getContinuityDailyBriefMock).toHaveBeenCalledWith("https://api.example.com", "user-1", { + query: "decision", + threadId: "", + taskId: "", + project: "", + person: "", + since: "", + until: "", + limit: 3, + }); + expect(getContinuityWeeklyReviewMock).toHaveBeenCalledWith("https://api.example.com", "user-1", { + query: "decision", + threadId: "", + taskId: "", + project: "", + person: "", + since: "", + until: "", + limit: 5, + }); + }); +}); diff --git a/apps/web/app/continuity/page.tsx b/apps/web/app/continuity/page.tsx new file mode 100644 index 0000000..0c57a80 --- /dev/null +++ b/apps/web/app/continuity/page.tsx @@ -0,0 +1,1004 @@ +import { ContinuityCaptureForm } from "../../components/continuity-capture-form"; +import { ContinuityCorrectionForm } from "../../components/continuity-correction-form"; +import { ContinuityDailyBriefPanel } from "../../components/continuity-daily-brief"; +import { ContinuityInboxList } from "../../components/continuity-inbox-list"; +import { ContinuityOpenLoopsPanel } from "../../components/continuity-open-loops-panel"; +import { ContinuityRecallPanel } from "../../components/continuity-recall-panel"; +import { ContinuityReviewQueue } from "../../components/continuity-review-queue"; +import { ContinuityWeeklyReviewPanel } from "../../components/continuity-weekly-review"; +import { EmptyState } from "../../components/empty-state"; +import { PageHeader } from "../../components/page-header"; +import { ResumptionBrief } from "../../components/resumption-brief"; +import { SectionCard } from "../../components/section-card"; +import { StatusBadge } from "../../components/status-badge"; +import type { + ApiSource, + ContinuityCaptureInboxItem, + ContinuityCaptureInboxSummary, + ContinuityDailyBrief, + ContinuityOpenLoopDashboard, + ContinuityRecallResult, + ContinuityRecallSummary, + ContinuityReviewDetail, + ContinuityReviewObject, + ContinuityReviewQueueSummary, + ContinuityReviewStatusFilter, + ContinuityResumptionBrief, + ContinuityWeeklyReview, +} from "../../lib/api"; +import { + getContinuityReviewDetail, + combinePageModes, + getApiConfig, + getContinuityCaptureDetail, + getContinuityDailyBrief, + getContinuityOpenLoopDashboard, + getContinuityResumptionBrief, + getContinuityWeeklyReview, + hasLiveApiConfig, + listContinuityReviewQueue, + listContinuityCaptures, + pageModeLabel, + queryContinuityRecall, +} from "../../lib/api"; + +type SearchParams = Promise<Record<string, string | string[] | undefined>>; + +function normalizeParam(value: string | string[] | undefined) { + if (Array.isArray(value)) { + return normalizeParam(value[0]); + } + return value?.trim() ?? ""; +} + +function parsePositiveInt(value: string, fallback: number) { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return fallback; + } + return parsed; +} + +function parseNonNegativeInt(value: string, fallback: number) { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed < 0) { + return fallback; + } + return parsed; +} + +function resolveSelectedCaptureId(requestedCaptureId: string, items: ContinuityCaptureInboxItem[]) { + if (!items.length) { + return ""; + } + + const available = new Set(items.map((item) => item.capture_event.id)); + if (requestedCaptureId && available.has(requestedCaptureId)) { + return requestedCaptureId; + } + + return items[0]?.capture_event.id ?? ""; +} + +function resolveSelectedReviewObjectId(requestedObjectId: string, items: ContinuityReviewObject[]) { + if (!items.length) { + return ""; + } + + const available = new Set(items.map((item) => item.id)); + if (requestedObjectId && available.has(requestedObjectId)) { + return requestedObjectId; + } + + return items[0]?.id ?? ""; +} + +function parseReviewStatus(value: string): ContinuityReviewStatusFilter { + if ( + value === "correction_ready" || + value === "active" || + value === "stale" || + value === "superseded" || + value === "deleted" || + value === "all" + ) { + return value; + } + return "correction_ready"; +} + +const continuityCaptureFixtures: ContinuityCaptureInboxItem[] = [ + { + capture_event: { + id: "capture-fixture-1", + raw_content: "Finalize launch checklist", + explicit_signal: "task", + admission_posture: "DERIVED", + admission_reason: "explicit_signal_task", + created_at: "2026-03-29T09:00:00Z", + }, + derived_object: { + id: "object-fixture-1", + capture_event_id: "capture-fixture-1", + object_type: "NextAction", + status: "active", + title: "Next Action: Finalize launch checklist", + body: { + action_text: "Finalize launch checklist", + raw_content: "Finalize launch checklist", + explicit_signal: "task", + }, + provenance: { + capture_event_id: "capture-fixture-1", + source_kind: "continuity_capture_event", + admission_reason: "explicit_signal_task", + }, + confidence: 1, + created_at: "2026-03-29T09:00:00Z", + updated_at: "2026-03-29T09:00:00Z", + }, + }, + { + capture_event: { + id: "capture-fixture-2", + raw_content: "Maybe revisit this next month", + explicit_signal: null, + admission_posture: "TRIAGE", + admission_reason: "ambiguous_capture_requires_triage", + created_at: "2026-03-29T09:10:00Z", + }, + derived_object: null, + }, +]; + +const continuityCaptureSummaryFixture: ContinuityCaptureInboxSummary = { + limit: 20, + returned_count: continuityCaptureFixtures.length, + total_count: continuityCaptureFixtures.length, + derived_count: 1, + triage_count: 1, + order: ["created_at_desc", "id_desc"], +}; + +const continuityRecallFixtures: ContinuityRecallResult[] = [ + { + id: "recall-fixture-1", + capture_event_id: "capture-fixture-1", + object_type: "NextAction", + status: "active", + title: "Next Action: Finalize launch checklist", + body: { + action_text: "Finalize launch checklist", + }, + provenance: { + thread_id: "thread-fixture-1", + project: "Launch Project", + person: "Alex", + source_event_ids: ["event-fixture-1"], + }, + confirmation_status: "unconfirmed", + admission_posture: "DERIVED", + confidence: 1, + relevance: 121, + last_confirmed_at: null, + supersedes_object_id: null, + superseded_by_object_id: null, + scope_matches: [ + { kind: "project", value: "launch project" }, + { kind: "person", value: "alex" }, + ], + provenance_references: [ + { source_kind: "continuity_capture_event", source_id: "capture-fixture-1" }, + { source_kind: "event", source_id: "event-fixture-1" }, + { source_kind: "thread", source_id: "thread-fixture-1" }, + ], + ordering: { + scope_match_count: 2, + query_term_match_count: 1, + confirmation_rank: 2, + freshness_posture: "aging", + freshness_rank: 3, + provenance_posture: "strong", + provenance_rank: 3, + supersession_posture: "current", + supersession_rank: 3, + posture_rank: 2, + lifecycle_rank: 4, + confidence: 1, + }, + created_at: "2026-03-29T09:00:00Z", + updated_at: "2026-03-29T09:00:00Z", + }, +]; + +const continuityRecallSummaryFixture: ContinuityRecallSummary = { + query: null, + filters: { + since: null, + until: null, + }, + limit: 20, + returned_count: continuityRecallFixtures.length, + total_count: continuityRecallFixtures.length, + order: ["relevance_desc", "created_at_desc", "id_desc"], +}; + +const continuityResumptionFixture: ContinuityResumptionBrief = { + assembly_version: "continuity_resumption_brief_v0", + scope: { + since: null, + until: null, + }, + last_decision: { + item: null, + empty_state: { + is_empty: true, + message: "No decision found in the requested scope.", + }, + }, + open_loops: { + items: [], + summary: { + limit: 5, + returned_count: 0, + total_count: 0, + order: ["created_at_desc", "id_desc"], + }, + empty_state: { + is_empty: true, + message: "No open loops found in the requested scope.", + }, + }, + recent_changes: { + items: continuityRecallFixtures, + summary: { + limit: 5, + returned_count: continuityRecallFixtures.length, + total_count: continuityRecallFixtures.length, + order: ["created_at_desc", "id_desc"], + }, + empty_state: { + is_empty: false, + message: "No recent changes found in the requested scope.", + }, + }, + next_action: { + item: continuityRecallFixtures[0], + empty_state: { + is_empty: false, + message: "No next action found in the requested scope.", + }, + }, + sources: ["continuity_capture_events", "continuity_objects"], +}; + +const continuityOpenLoopWaitingFixture: ContinuityRecallResult = { + id: "open-loop-waiting-1", + capture_event_id: "capture-open-loop-waiting-1", + object_type: "WaitingFor", + status: "active", + title: "Waiting For: Vendor quote", + body: { + waiting_for_text: "Vendor quote", + }, + provenance: { + thread_id: "thread-fixture-1", + project: "Launch Project", + }, + confirmation_status: "unconfirmed", + admission_posture: "DERIVED", + confidence: 1, + relevance: 96, + last_confirmed_at: null, + supersedes_object_id: null, + superseded_by_object_id: null, + scope_matches: [], + provenance_references: [{ source_kind: "continuity_capture_event", source_id: "capture-open-loop-waiting-1" }], + ordering: { + scope_match_count: 0, + query_term_match_count: 0, + confirmation_rank: 2, + freshness_posture: "aging", + freshness_rank: 3, + provenance_posture: "partial", + provenance_rank: 2, + supersession_posture: "current", + supersession_rank: 3, + posture_rank: 2, + lifecycle_rank: 4, + confidence: 1, + }, + created_at: "2026-03-29T09:30:00Z", + updated_at: "2026-03-29T09:30:00Z", +}; + +const continuityOpenLoopBlockerFixture: ContinuityRecallResult = { + id: "open-loop-blocker-1", + capture_event_id: "capture-open-loop-blocker-1", + object_type: "Blocker", + status: "active", + title: "Blocker: Await security approval", + body: { + blocking_reason: "Await security approval", + }, + provenance: { + thread_id: "thread-fixture-1", + project: "Launch Project", + }, + confirmation_status: "unconfirmed", + admission_posture: "DERIVED", + confidence: 1, + relevance: 95, + last_confirmed_at: null, + supersedes_object_id: null, + superseded_by_object_id: null, + scope_matches: [], + provenance_references: [{ source_kind: "continuity_capture_event", source_id: "capture-open-loop-blocker-1" }], + ordering: { + scope_match_count: 0, + query_term_match_count: 0, + confirmation_rank: 2, + freshness_posture: "aging", + freshness_rank: 3, + provenance_posture: "partial", + provenance_rank: 2, + supersession_posture: "current", + supersession_rank: 3, + posture_rank: 2, + lifecycle_rank: 4, + confidence: 1, + }, + created_at: "2026-03-29T09:35:00Z", + updated_at: "2026-03-29T09:35:00Z", +}; + +const continuityOpenLoopStaleFixture: ContinuityRecallResult = { + id: "open-loop-stale-1", + capture_event_id: "capture-open-loop-stale-1", + object_type: "WaitingFor", + status: "stale", + title: "Waiting For: Stale finance response", + body: { + waiting_for_text: "Stale finance response", + }, + provenance: { + thread_id: "thread-fixture-1", + project: "Launch Project", + }, + confirmation_status: "unconfirmed", + admission_posture: "DERIVED", + confidence: 1, + relevance: 90, + last_confirmed_at: null, + supersedes_object_id: null, + superseded_by_object_id: null, + scope_matches: [], + provenance_references: [{ source_kind: "continuity_capture_event", source_id: "capture-open-loop-stale-1" }], + ordering: { + scope_match_count: 0, + query_term_match_count: 0, + confirmation_rank: 2, + freshness_posture: "stale", + freshness_rank: 2, + provenance_posture: "partial", + provenance_rank: 2, + supersession_posture: "historical", + supersession_rank: 2, + posture_rank: 2, + lifecycle_rank: 3, + confidence: 1, + }, + created_at: "2026-03-29T09:40:00Z", + updated_at: "2026-03-29T09:40:00Z", +}; + +const continuityOpenLoopDashboardFixture: ContinuityOpenLoopDashboard = { + scope: { + since: null, + until: null, + }, + waiting_for: { + items: [continuityOpenLoopWaitingFixture], + summary: { + limit: 20, + returned_count: 1, + total_count: 1, + order: ["created_at_desc", "id_desc"], + }, + empty_state: { + is_empty: false, + message: "No waiting-for items in the requested scope.", + }, + }, + blocker: { + items: [continuityOpenLoopBlockerFixture], + summary: { + limit: 20, + returned_count: 1, + total_count: 1, + order: ["created_at_desc", "id_desc"], + }, + empty_state: { + is_empty: false, + message: "No blocker items in the requested scope.", + }, + }, + stale: { + items: [continuityOpenLoopStaleFixture], + summary: { + limit: 20, + returned_count: 1, + total_count: 1, + order: ["created_at_desc", "id_desc"], + }, + empty_state: { + is_empty: false, + message: "No stale items in the requested scope.", + }, + }, + next_action: { + items: [continuityRecallFixtures[0]], + summary: { + limit: 20, + returned_count: 1, + total_count: 1, + order: ["created_at_desc", "id_desc"], + }, + empty_state: { + is_empty: false, + message: "No next-action items in the requested scope.", + }, + }, + summary: { + limit: 20, + total_count: 4, + posture_order: ["waiting_for", "blocker", "stale", "next_action"], + item_order: ["created_at_desc", "id_desc"], + }, + sources: ["continuity_capture_events", "continuity_objects", "continuity_correction_events"], +}; + +const continuityDailyBriefFixture: ContinuityDailyBrief = { + assembly_version: "continuity_daily_brief_v0", + scope: { + since: null, + until: null, + }, + waiting_for_highlights: continuityOpenLoopDashboardFixture.waiting_for, + blocker_highlights: continuityOpenLoopDashboardFixture.blocker, + stale_items: continuityOpenLoopDashboardFixture.stale, + next_suggested_action: { + item: continuityRecallFixtures[0], + empty_state: { + is_empty: false, + message: "No next suggested action in the requested scope.", + }, + }, + sources: ["continuity_capture_events", "continuity_objects", "continuity_correction_events"], +}; + +const continuityWeeklyReviewFixture: ContinuityWeeklyReview = { + assembly_version: "continuity_weekly_review_v0", + scope: { + since: null, + until: null, + }, + rollup: { + total_count: 4, + waiting_for_count: 1, + blocker_count: 1, + stale_count: 1, + correction_recurrence_count: 0, + freshness_drift_count: 1, + next_action_count: 1, + posture_order: ["waiting_for", "blocker", "stale", "next_action"], + }, + waiting_for: continuityOpenLoopDashboardFixture.waiting_for, + blocker: continuityOpenLoopDashboardFixture.blocker, + stale: continuityOpenLoopDashboardFixture.stale, + next_action: continuityOpenLoopDashboardFixture.next_action, + sources: ["continuity_capture_events", "continuity_objects", "continuity_correction_events"], +}; + +const continuityReviewFixtures: ContinuityReviewObject[] = [ + { + id: "review-fixture-1", + capture_event_id: "capture-fixture-1", + object_type: "NextAction", + status: "active", + title: "Next Action: Finalize launch checklist", + body: { + action_text: "Finalize launch checklist", + }, + provenance: { + thread_id: "thread-fixture-1", + }, + confidence: 1, + last_confirmed_at: null, + supersedes_object_id: null, + superseded_by_object_id: null, + created_at: "2026-03-29T09:00:00Z", + updated_at: "2026-03-29T09:00:00Z", + }, +]; + +const continuityReviewSummaryFixture: ContinuityReviewQueueSummary = { + status: "correction_ready", + limit: 20, + returned_count: continuityReviewFixtures.length, + total_count: continuityReviewFixtures.length, + order: ["updated_at_desc", "created_at_desc", "id_desc"], +}; + +const continuityReviewDetailFixture: ContinuityReviewDetail = { + continuity_object: continuityReviewFixtures[0], + correction_events: [], + supersession_chain: { + supersedes: null, + superseded_by: null, + }, +}; + +function renderDetail(item: ContinuityCaptureInboxItem | null, source: ApiSource | "unavailable" | null, unavailableReason?: string) { + if (!item) { + return ( + <SectionCard + eyebrow="Capture detail" + title="No capture selected" + description="Select one capture row to inspect its immutable event payload and derived-object provenance." + > + <EmptyState + title="Detail panel is idle" + description="Choose one capture from the inbox to inspect posture and provenance." + /> + </SectionCard> + ); + } + + const capture = item.capture_event; + const derived = item.derived_object; + + return ( + <SectionCard + eyebrow="Capture detail" + title={capture.raw_content} + description="Derived objects remain explicitly linked to immutable capture evidence through provenance fields." + > + <div className="detail-grid"> + <div className="detail-summary"> + <StatusBadge + status={source ?? "unavailable"} + label={ + source === "live" + ? "Live detail" + : source === "fixture" + ? "Fixture detail" + : "Detail unavailable" + } + /> + <StatusBadge + status={capture.admission_posture} + label={capture.admission_posture === "TRIAGE" ? "Triage" : "Derived"} + /> + </div> + + {unavailableReason ? ( + <div className="execution-summary__note execution-summary__note--danger"> + <p className="execution-summary__label">Detail read</p> + <p>{unavailableReason}</p> + </div> + ) : null} + + <dl className="key-value-grid key-value-grid--compact"> + <div> + <dt>Capture ID</dt> + <dd className="mono">{capture.id}</dd> + </div> + <div> + <dt>Created</dt> + <dd>{capture.created_at}</dd> + </div> + <div> + <dt>Explicit signal</dt> + <dd>{capture.explicit_signal ?? "None"}</dd> + </div> + <div> + <dt>Admission reason</dt> + <dd>{capture.admission_reason}</dd> + </div> + </dl> + + {derived ? ( + <> + <div className="detail-group"> + <h3>Derived object</h3> + <pre className="execution-summary__code">{JSON.stringify(derived, null, 2)}</pre> + </div> + <div className="detail-group detail-group--muted"> + <h3>Provenance</h3> + <pre className="execution-summary__code">{JSON.stringify(derived.provenance, null, 2)}</pre> + </div> + </> + ) : ( + <div className="detail-group detail-group--muted"> + <h3>Triage posture</h3> + <p className="muted-copy"> + This capture is stored immutably and visible in inbox triage. No durable object was promoted. + </p> + </div> + )} + </div> + </SectionCard> + ); +} + +export default async function ContinuityPage({ + searchParams, +}: { + searchParams?: SearchParams; +}) { + const params = (searchParams ? await searchParams : {}) as Record< + string, + string | string[] | undefined + >; + + const requestedCaptureId = normalizeParam(params.capture); + const requestedReviewObjectId = normalizeParam(params.review_object); + const recallQuery = normalizeParam(params.recall_query); + const recallThreadId = normalizeParam(params.recall_thread); + const recallTaskId = normalizeParam(params.recall_task); + const recallProject = normalizeParam(params.recall_project); + const recallPerson = normalizeParam(params.recall_person); + const recallSince = normalizeParam(params.recall_since); + const recallUntil = normalizeParam(params.recall_until); + const recallLimit = parsePositiveInt(normalizeParam(params.recall_limit), 20); + const reviewStatus = parseReviewStatus(normalizeParam(params.review_status)); + const reviewLimit = parsePositiveInt(normalizeParam(params.review_limit), 20); + const openLoopLimit = parseNonNegativeInt(normalizeParam(params.open_loop_limit), 20); + const dailyBriefLimit = parseNonNegativeInt(normalizeParam(params.daily_limit), 3); + const weeklyReviewLimit = parseNonNegativeInt(normalizeParam(params.weekly_limit), 5); + const resumptionRecentChanges = parseNonNegativeInt(normalizeParam(params.resumption_recent), 5); + const resumptionOpenLoops = parseNonNegativeInt(normalizeParam(params.resumption_open), 5); + + const apiConfig = getApiConfig(); + const liveModeReady = hasLiveApiConfig(apiConfig); + + let items = continuityCaptureFixtures; + let summary = continuityCaptureSummaryFixture; + let listSource: ApiSource = "fixture"; + let listUnavailableReason: string | undefined; + + if (liveModeReady) { + try { + const payload = await listContinuityCaptures(apiConfig.apiBaseUrl, apiConfig.userId, { + limit: 20, + }); + items = payload.items; + summary = payload.summary; + listSource = "live"; + } catch (error) { + listUnavailableReason = + error instanceof Error + ? error.message + : "Continuity capture inbox could not be loaded."; + } + } + + const selectedCaptureId = resolveSelectedCaptureId(requestedCaptureId, items); + const selectedFromList = items.find((item) => item.capture_event.id === selectedCaptureId) ?? null; + let selectedItem = selectedFromList; + let selectedSource: ApiSource | "unavailable" | null = selectedFromList ? listSource : null; + let selectedUnavailableReason: string | undefined; + + if (selectedFromList && liveModeReady && listSource === "live") { + try { + const payload = await getContinuityCaptureDetail( + apiConfig.apiBaseUrl, + selectedFromList.capture_event.id, + apiConfig.userId, + ); + selectedItem = payload.capture; + selectedSource = "live"; + } catch (error) { + selectedUnavailableReason = + error instanceof Error + ? error.message + : "Selected continuity capture detail could not be loaded."; + selectedSource = "unavailable"; + } + } + + let recallResults = continuityRecallFixtures; + let recallSummary = continuityRecallSummaryFixture; + let recallSource: ApiSource = "fixture"; + let recallUnavailableReason: string | undefined; + + if (liveModeReady) { + try { + const payload = await queryContinuityRecall(apiConfig.apiBaseUrl, apiConfig.userId, { + query: recallQuery, + threadId: recallThreadId, + taskId: recallTaskId, + project: recallProject, + person: recallPerson, + since: recallSince, + until: recallUntil, + limit: recallLimit, + }); + recallResults = payload.items; + recallSummary = payload.summary; + recallSource = "live"; + } catch (error) { + recallUnavailableReason = + error instanceof Error + ? error.message + : "Continuity recall query could not be loaded."; + } + } + + let brief = continuityResumptionFixture; + let resumptionSource: ApiSource = "fixture"; + let resumptionUnavailableReason: string | undefined; + + if (liveModeReady) { + try { + const payload = await getContinuityResumptionBrief(apiConfig.apiBaseUrl, apiConfig.userId, { + query: recallQuery, + threadId: recallThreadId, + taskId: recallTaskId, + project: recallProject, + person: recallPerson, + since: recallSince, + until: recallUntil, + maxRecentChanges: resumptionRecentChanges, + maxOpenLoops: resumptionOpenLoops, + }); + brief = payload.brief; + resumptionSource = "live"; + } catch (error) { + resumptionUnavailableReason = + error instanceof Error + ? error.message + : "Continuity resumption brief could not be loaded."; + } + } + + let openLoopDashboard = continuityOpenLoopDashboardFixture; + let openLoopSource: ApiSource = "fixture"; + let openLoopUnavailableReason: string | undefined; + + if (liveModeReady) { + try { + const payload = await getContinuityOpenLoopDashboard(apiConfig.apiBaseUrl, apiConfig.userId, { + query: recallQuery, + threadId: recallThreadId, + taskId: recallTaskId, + project: recallProject, + person: recallPerson, + since: recallSince, + until: recallUntil, + limit: openLoopLimit, + }); + openLoopDashboard = payload.dashboard; + openLoopSource = "live"; + } catch (error) { + openLoopUnavailableReason = + error instanceof Error + ? error.message + : "Continuity open-loop dashboard could not be loaded."; + } + } + + let dailyBrief = continuityDailyBriefFixture; + let dailyBriefSource: ApiSource = "fixture"; + let dailyBriefUnavailableReason: string | undefined; + + if (liveModeReady) { + try { + const payload = await getContinuityDailyBrief(apiConfig.apiBaseUrl, apiConfig.userId, { + query: recallQuery, + threadId: recallThreadId, + taskId: recallTaskId, + project: recallProject, + person: recallPerson, + since: recallSince, + until: recallUntil, + limit: dailyBriefLimit, + }); + dailyBrief = payload.brief; + dailyBriefSource = "live"; + } catch (error) { + dailyBriefUnavailableReason = + error instanceof Error + ? error.message + : "Continuity daily brief could not be loaded."; + } + } + + let weeklyReview = continuityWeeklyReviewFixture; + let weeklyReviewSource: ApiSource = "fixture"; + let weeklyReviewUnavailableReason: string | undefined; + + if (liveModeReady) { + try { + const payload = await getContinuityWeeklyReview(apiConfig.apiBaseUrl, apiConfig.userId, { + query: recallQuery, + threadId: recallThreadId, + taskId: recallTaskId, + project: recallProject, + person: recallPerson, + since: recallSince, + until: recallUntil, + limit: weeklyReviewLimit, + }); + weeklyReview = payload.review; + weeklyReviewSource = "live"; + } catch (error) { + weeklyReviewUnavailableReason = + error instanceof Error + ? error.message + : "Continuity weekly review could not be loaded."; + } + } + + let reviewItems = continuityReviewFixtures; + let reviewSummary = continuityReviewSummaryFixture; + let reviewSource: ApiSource = "fixture"; + let reviewUnavailableReason: string | undefined; + + if (liveModeReady) { + try { + const payload = await listContinuityReviewQueue(apiConfig.apiBaseUrl, apiConfig.userId, { + status: reviewStatus, + limit: reviewLimit, + }); + reviewItems = payload.items; + reviewSummary = payload.summary; + reviewSource = "live"; + } catch (error) { + reviewUnavailableReason = + error instanceof Error + ? error.message + : "Continuity review queue could not be loaded."; + } + } + + const selectedReviewObjectId = resolveSelectedReviewObjectId(requestedReviewObjectId, reviewItems); + const selectedReviewFromQueue = reviewItems.find((item) => item.id === selectedReviewObjectId) ?? null; + let selectedReviewDetail: ContinuityReviewDetail | null = selectedReviewFromQueue + ? { ...continuityReviewDetailFixture, continuity_object: selectedReviewFromQueue } + : null; + let correctionSource: ApiSource | "unavailable" = selectedReviewFromQueue ? reviewSource : "unavailable"; + let correctionUnavailableReason: string | undefined; + + if (selectedReviewFromQueue && liveModeReady && reviewSource === "live") { + try { + const payload = await getContinuityReviewDetail( + apiConfig.apiBaseUrl, + selectedReviewFromQueue.id, + apiConfig.userId, + ); + selectedReviewDetail = payload.review; + correctionSource = "live"; + } catch (error) { + correctionUnavailableReason = + error instanceof Error + ? error.message + : "Selected continuity review detail could not be loaded."; + correctionSource = "unavailable"; + } + } + + const pageMode = combinePageModes( + listSource, + selectedSource, + recallSource, + resumptionSource, + openLoopSource, + dailyBriefSource, + weeklyReviewSource, + reviewSource, + correctionSource, + ); + + return ( + <main className="stack"> + <PageHeader + eyebrow="Continuity" + title="Continuity workspace" + description="Capture quickly, query continuity with provenance-backed recall, and compile deterministic resumption sections." + meta={<StatusBadge status={pageMode} label={pageModeLabel(pageMode)} />} + /> + + <ContinuityCaptureForm + apiBaseUrl={apiConfig.apiBaseUrl} + userId={apiConfig.userId} + source={listSource} + /> + + <div className="grid grid--two"> + <ContinuityInboxList + items={items} + selectedCaptureId={selectedCaptureId} + summary={summary} + source={listSource} + unavailableReason={listUnavailableReason} + /> + {renderDetail(selectedItem, selectedSource, selectedUnavailableReason)} + </div> + + <div className="grid grid--two"> + <ContinuityRecallPanel + results={recallResults} + summary={recallSummary} + source={recallSource} + unavailableReason={recallUnavailableReason} + filters={{ + query: recallQuery, + threadId: recallThreadId, + taskId: recallTaskId, + project: recallProject, + person: recallPerson, + since: recallSince, + until: recallUntil, + limit: recallLimit, + }} + /> + <ResumptionBrief + brief={brief} + source={resumptionSource} + unavailableReason={resumptionUnavailableReason} + /> + </div> + + <div className="grid grid--two"> + <ContinuityOpenLoopsPanel + apiBaseUrl={apiConfig.apiBaseUrl} + userId={apiConfig.userId} + dashboard={openLoopDashboard} + source={openLoopSource} + unavailableReason={openLoopUnavailableReason} + /> + <ContinuityDailyBriefPanel + brief={dailyBrief} + source={dailyBriefSource} + unavailableReason={dailyBriefUnavailableReason} + /> + </div> + + <ContinuityWeeklyReviewPanel + review={weeklyReview} + source={weeklyReviewSource} + unavailableReason={weeklyReviewUnavailableReason} + /> + + <div className="grid grid--two"> + <ContinuityReviewQueue + items={reviewItems} + summary={reviewSummary} + selectedObjectId={selectedReviewObjectId} + source={reviewSource} + unavailableReason={reviewUnavailableReason} + filters={{ + status: reviewStatus, + limit: reviewLimit, + }} + /> + <div className="detail-stack"> + {correctionUnavailableReason ? ( + <div className="execution-summary__note execution-summary__note--danger"> + <p className="execution-summary__label">Review detail</p> + <p>{correctionUnavailableReason}</p> + </div> + ) : null} + <ContinuityCorrectionForm + apiBaseUrl={apiConfig.apiBaseUrl} + userId={apiConfig.userId} + source={correctionSource} + review={selectedReviewDetail} + /> + </div> + </div> + </main> + ); +} diff --git a/apps/web/app/entities/loading.tsx b/apps/web/app/entities/loading.tsx new file mode 100644 index 0000000..4893c66 --- /dev/null +++ b/apps/web/app/entities/loading.tsx @@ -0,0 +1,62 @@ +import { PageHeader } from "../../components/page-header"; +import { SectionCard } from "../../components/section-card"; +import { StatusBadge } from "../../components/status-badge"; + +export default function Loading() { + return ( + <div className="page-stack" aria-busy="true"> + <PageHeader + eyebrow="Entities" + title="Entity review workspace" + description="Loading entity list, selected detail, and related edge review state." + meta={ + <div className="header-meta"> + <span className="subtle-chip">Loading route state</span> + </div> + } + /> + + <div className="entity-layout"> + <SectionCard + eyebrow="Entity list" + title="Loading tracked entities" + description="Entity records are loading from the current source." + className="loading-card" + > + <div className="detail-stack"> + <StatusBadge status="loading" label="Loading" /> + <div className="loading-placeholder loading-placeholder--card" /> + <div className="loading-placeholder loading-placeholder--card" /> + </div> + </SectionCard> + + <SectionCard + eyebrow="Selected entity" + title="Loading selected entity" + description="Type, source memories, and timestamps are loading." + className="loading-card" + > + <div className="detail-stack"> + <StatusBadge status="loading" label="Loading" /> + <div className="loading-placeholder loading-placeholder--line loading-placeholder--wide" /> + <div className="loading-placeholder loading-placeholder--line" /> + <div className="loading-placeholder loading-placeholder--card" /> + </div> + </SectionCard> + </div> + + <SectionCard + eyebrow="Related edges" + title="Loading edge review" + description="Ordered relationship edges are loading for the selected entity." + className="loading-card" + > + <div className="detail-stack"> + <StatusBadge status="loading" label="Loading" /> + <div className="loading-placeholder loading-placeholder--card" /> + <div className="loading-placeholder loading-placeholder--card" /> + </div> + </SectionCard> + </div> + ); +} diff --git a/apps/web/app/entities/page.test.tsx b/apps/web/app/entities/page.test.tsx new file mode 100644 index 0000000..293ee39 --- /dev/null +++ b/apps/web/app/entities/page.test.tsx @@ -0,0 +1,208 @@ +import React from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import EntitiesPage from "./page"; + +const { + getApiConfigMock, + getEntityDetailMock, + hasLiveApiConfigMock, + listEntitiesMock, + listEntityEdgesMock, +} = vi.hoisted(() => ({ + getApiConfigMock: vi.fn(), + getEntityDetailMock: vi.fn(), + hasLiveApiConfigMock: vi.fn(), + listEntitiesMock: vi.fn(), + listEntityEdgesMock: vi.fn(), +})); + +vi.mock("next/link", () => ({ + default: ({ + href, + children, + className, + "aria-current": ariaCurrent, + }: { + href: string; + children: React.ReactNode; + className?: string; + "aria-current"?: string; + }) => ( + <a href={href} className={className} aria-current={ariaCurrent}> + {children} + </a> + ), +})); + +vi.mock("../../lib/api", async () => { + const actual = await vi.importActual("../../lib/api"); + return { + ...actual, + getApiConfig: getApiConfigMock, + getEntityDetail: getEntityDetailMock, + hasLiveApiConfig: hasLiveApiConfigMock, + listEntities: listEntitiesMock, + listEntityEdges: listEntityEdgesMock, + }; +}); + +describe("EntitiesPage", () => { + beforeEach(() => { + getApiConfigMock.mockReset(); + getEntityDetailMock.mockReset(); + hasLiveApiConfigMock.mockReset(); + listEntitiesMock.mockReset(); + listEntityEdgesMock.mockReset(); + + getApiConfigMock.mockReturnValue({ + apiBaseUrl: "", + userId: "", + defaultThreadId: "", + defaultToolId: "", + }); + hasLiveApiConfigMock.mockReturnValue(false); + }); + + afterEach(() => { + cleanup(); + }); + + it("uses fixture-backed entity workspace state when live API config is absent", async () => { + render(await EntitiesPage({ searchParams: Promise.resolve({}) })); + + expect(screen.getByText("Fixture-backed")).toBeInTheDocument(); + expect(screen.getByText("Fixture list")).toBeInTheDocument(); + expect(screen.getByText("Fixture detail")).toBeInTheDocument(); + expect(screen.getByText("Fixture edges")).toBeInTheDocument(); + expect(listEntitiesMock).not.toHaveBeenCalled(); + }); + + it("renders live-backed entity list, detail, and edges when live reads succeed", async () => { + getApiConfigMock.mockReturnValue({ + apiBaseUrl: "https://api.example.com", + userId: "user-1", + defaultThreadId: "thread-1", + defaultToolId: "tool-1", + }); + hasLiveApiConfigMock.mockReturnValue(true); + + listEntitiesMock.mockResolvedValue({ + items: [ + { + id: "entity-live-1", + entity_type: "person", + name: "Live Alice", + source_memory_ids: ["memory-live-1"], + created_at: "2026-03-18T10:00:00Z", + }, + ], + summary: { + total_count: 1, + order: ["created_at_asc", "id_asc"], + }, + }); + + getEntityDetailMock.mockResolvedValue({ + entity: { + id: "entity-live-1", + entity_type: "person", + name: "Live Alice", + source_memory_ids: ["memory-live-1", "memory-live-2"], + created_at: "2026-03-18T10:00:00Z", + }, + }); + + listEntityEdgesMock.mockResolvedValue({ + items: [ + { + id: "edge-live-1", + from_entity_id: "entity-live-1", + to_entity_id: "entity-live-2", + relationship_type: "prefers_merchant", + valid_from: "2026-03-18T10:00:00Z", + valid_to: null, + source_memory_ids: ["memory-live-1"], + created_at: "2026-03-18T10:01:00Z", + }, + ], + summary: { + entity_id: "entity-live-1", + total_count: 1, + order: ["created_at_asc", "id_asc"], + }, + }); + + render( + await EntitiesPage({ + searchParams: Promise.resolve({ entity: "entity-live-1" }), + }), + ); + + expect(screen.getByText("Live API")).toBeInTheDocument(); + expect(screen.getByText("Live list")).toBeInTheDocument(); + expect(screen.getByText("Live detail")).toBeInTheDocument(); + expect(screen.getByText("Live edges")).toBeInTheDocument(); + + expect(listEntitiesMock).toHaveBeenCalledWith("https://api.example.com", "user-1"); + expect(getEntityDetailMock).toHaveBeenCalledWith( + "https://api.example.com", + "entity-live-1", + "user-1", + ); + expect(listEntityEdgesMock).toHaveBeenCalledWith( + "https://api.example.com", + "entity-live-1", + "user-1", + ); + }); + + it("shows explicit edge unavailable state when live edge read fails and no fixture fallback exists", async () => { + getApiConfigMock.mockReturnValue({ + apiBaseUrl: "https://api.example.com", + userId: "user-1", + defaultThreadId: "thread-1", + defaultToolId: "tool-1", + }); + hasLiveApiConfigMock.mockReturnValue(true); + + listEntitiesMock.mockResolvedValue({ + items: [ + { + id: "entity-live-missing", + entity_type: "project", + name: "Missing Fixture Entity", + source_memory_ids: ["memory-live-missing"], + created_at: "2026-03-18T11:00:00Z", + }, + ], + summary: { + total_count: 1, + order: ["created_at_asc", "id_asc"], + }, + }); + + getEntityDetailMock.mockResolvedValue({ + entity: { + id: "entity-live-missing", + entity_type: "project", + name: "Missing Fixture Entity", + source_memory_ids: ["memory-live-missing"], + created_at: "2026-03-18T11:00:00Z", + }, + }); + + listEntityEdgesMock.mockRejectedValue(new Error("edges down")); + + render( + await EntitiesPage({ + searchParams: Promise.resolve({ entity: "entity-live-missing" }), + }), + ); + + expect(screen.getByText("Edge review unavailable")).toBeInTheDocument(); + expect(screen.getByText("Edges unavailable")).toBeInTheDocument(); + expect(screen.getByText("edges down")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/entities/page.tsx b/apps/web/app/entities/page.tsx new file mode 100644 index 0000000..e3a0368 --- /dev/null +++ b/apps/web/app/entities/page.tsx @@ -0,0 +1,171 @@ +import { EntityDetail } from "../../components/entity-detail"; +import { EntityEdgeList } from "../../components/entity-edge-list"; +import { EntityList } from "../../components/entity-list"; +import { PageHeader } from "../../components/page-header"; +import type { ApiSource, EntityRecord } from "../../lib/api"; +import { + combinePageModes, + getApiConfig, + getEntityDetail, + hasLiveApiConfig, + listEntities, + listEntityEdges, + pageModeLabel, +} from "../../lib/api"; +import { + entityFixtures, + entityListSummaryFixture, + getFixtureEntity, + getFixtureEntityEdgeSummary, + getFixtureEntityEdges, +} from "../../lib/fixtures"; + +type SearchParams = Promise<Record<string, string | string[] | undefined>>; + +function normalizeParam(value: string | string[] | undefined) { + if (Array.isArray(value)) { + return normalizeParam(value[0]); + } + + return value?.trim() ?? ""; +} + +function resolveSelectedEntityId(requestedEntityId: string, items: EntityRecord[]) { + if (!items.length) { + return ""; + } + + const availableIds = new Set(items.map((item) => item.id)); + if (requestedEntityId && availableIds.has(requestedEntityId)) { + return requestedEntityId; + } + + return items[0]?.id ?? ""; +} + +export default async function EntitiesPage({ + searchParams, +}: { + searchParams?: SearchParams; +}) { + const params = (searchParams ? await searchParams : {}) as Record< + string, + string | string[] | undefined + >; + const requestedEntityId = normalizeParam(params.entity); + const apiConfig = getApiConfig(); + const liveModeReady = hasLiveApiConfig(apiConfig); + + let entities = entityFixtures; + let entityListSummary = entityListSummaryFixture; + let entityListSource: ApiSource = "fixture"; + let entityListUnavailableReason: string | undefined; + + if (liveModeReady) { + try { + const payload = await listEntities(apiConfig.apiBaseUrl, apiConfig.userId); + entities = payload.items; + entityListSummary = payload.summary; + entityListSource = "live"; + } catch (error) { + entityListUnavailableReason = + error instanceof Error ? error.message : "Entity list could not be loaded."; + } + } + + const selectedEntityId = resolveSelectedEntityId(requestedEntityId, entities); + const selectedFromList = entities.find((item) => item.id === selectedEntityId) ?? null; + + let selectedEntity = selectedFromList; + let selectedEntitySource: ApiSource | null = selectedEntity ? entityListSource : null; + let selectedEntityUnavailableReason: string | undefined; + + if (selectedFromList && liveModeReady && entityListSource === "live") { + try { + const payload = await getEntityDetail(apiConfig.apiBaseUrl, selectedFromList.id, apiConfig.userId); + selectedEntity = payload.entity; + selectedEntitySource = "live"; + } catch (error) { + const fixtureEntity = getFixtureEntity(selectedFromList.id); + if (fixtureEntity) { + selectedEntity = fixtureEntity; + selectedEntitySource = "fixture"; + } + selectedEntityUnavailableReason = + error instanceof Error ? error.message : "Selected entity detail could not be loaded."; + } + } + + let edges = selectedEntity ? getFixtureEntityEdges(selectedEntity.id) : []; + let edgeSummary = selectedEntity ? getFixtureEntityEdgeSummary(selectedEntity.id) : null; + let edgeSource: ApiSource | "unavailable" | null = selectedEntity ? "fixture" : null; + let edgeUnavailableReason: string | undefined; + + if (selectedEntity && liveModeReady && selectedEntitySource === "live") { + try { + const payload = await listEntityEdges(apiConfig.apiBaseUrl, selectedEntity.id, apiConfig.userId); + edges = payload.items; + edgeSummary = payload.summary; + edgeSource = "live"; + } catch (error) { + const fixtureEdges = getFixtureEntityEdges(selectedEntity.id); + if (fixtureEdges.length > 0) { + edges = fixtureEdges; + edgeSummary = getFixtureEntityEdgeSummary(selectedEntity.id); + edgeSource = "fixture"; + } else { + edges = []; + edgeSummary = null; + edgeSource = "unavailable"; + } + edgeUnavailableReason = + error instanceof Error ? error.message : "Entity edges could not be loaded."; + } + } + + const pageMode = combinePageModes( + entityListSource, + selectedEntitySource, + edgeSource === "unavailable" ? null : edgeSource, + ); + + return ( + <div className="page-stack"> + <PageHeader + eyebrow="Entities" + title="Entity review workspace" + description="Review entities in a bounded sequence: list first, selected detail second, and related edge context third." + meta={ + <div className="header-meta"> + <span className="subtle-chip">{pageModeLabel(pageMode)}</span> + <span className="subtle-chip">{entities.length} visible entities</span> + {selectedEntity ? <span className="subtle-chip">Selected: {selectedEntity.name}</span> : null} + </div> + } + /> + + <div className="entity-layout"> + <EntityList + entities={entities} + selectedEntityId={selectedEntity?.id} + summary={entityListSummary} + source={entityListSource} + unavailableReason={entityListUnavailableReason} + /> + <EntityDetail + entity={selectedEntity} + source={selectedEntitySource} + unavailableReason={selectedEntityUnavailableReason} + /> + </div> + + <EntityEdgeList + entityId={selectedEntity?.id ?? null} + edges={edges} + summary={edgeSummary} + source={edgeSource} + unavailableReason={edgeUnavailableReason} + /> + </div> + ); +} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css new file mode 100644 index 0000000..627d403 --- /dev/null +++ b/apps/web/app/globals.css @@ -0,0 +1,1747 @@ +:root { + --font-sans: "Avenir Next", "Segoe UI", "Helvetica Neue", sans-serif; + --font-serif: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Georgia, serif; + --bg: #f3efe8; + --bg-accent: rgba(68, 88, 112, 0.12); + --surface: rgba(255, 252, 248, 0.88); + --surface-strong: rgba(255, 255, 255, 0.94); + --surface-muted: rgba(246, 240, 232, 0.82); + --border: rgba(42, 52, 66, 0.12); + --border-strong: rgba(42, 52, 66, 0.18); + --text: #18202a; + --text-soft: #566172; + --text-muted: #707988; + --accent: #274b63; + --accent-soft: rgba(39, 75, 99, 0.09); + --success: #2c6e62; + --success-soft: rgba(44, 110, 98, 0.1); + --warning: #8e6220; + --warning-soft: rgba(142, 98, 32, 0.11); + --danger: #8d4440; + --danger-soft: rgba(141, 68, 64, 0.1); + --info: #365d7c; + --info-soft: rgba(54, 93, 124, 0.11); + --shadow-lg: 0 22px 70px rgba(32, 43, 56, 0.09); + --shadow-md: 0 14px 36px rgba(32, 43, 56, 0.06); + --radius-xl: 28px; + --radius-lg: 22px; + --radius-md: 16px; + --radius-sm: 12px; + --content-width: 1360px; +} + +* { + box-sizing: border-box; + min-width: 0; +} + +html { + background: + radial-gradient(circle at top left, rgba(211, 221, 232, 0.5), transparent 28%), + radial-gradient(circle at top right, rgba(233, 220, 205, 0.55), transparent 24%), + var(--bg); + color: var(--text); +} + +body { + margin: 0; + font-family: var(--font-sans); + color: var(--text); + min-height: 100vh; +} + +a { + color: inherit; + text-decoration: none; +} + +button, +input, +textarea, +select { + font: inherit; +} + +button { + cursor: pointer; +} + +img, +svg { + display: block; + max-width: 100%; +} + +code, +.mono { + font-family: + "SFMono-Regular", "SF Mono", "JetBrains Mono", "Roboto Mono", "Menlo", monospace; +} + +.shell-chrome { + position: relative; + min-height: 100vh; +} + +.shell-chrome::before { + content: ""; + position: fixed; + inset: 0; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.35), transparent 32%), + radial-gradient(circle at 15% 20%, rgba(39, 75, 99, 0.08), transparent 24%); + pointer-events: none; +} + +.shell { + position: relative; + z-index: 1; + max-width: var(--content-width); + margin: 0 auto; + padding: 24px; + display: grid; + grid-template-columns: 280px minmax(0, 1fr); + gap: 24px; +} + +.shell-sidebar { + position: sticky; + top: 24px; + align-self: start; + display: grid; + gap: 18px; + padding: 22px; + background: rgba(255, 252, 248, 0.72); + border: 1px solid var(--border); + border-radius: 30px; + box-shadow: var(--shadow-md); + backdrop-filter: blur(16px); +} + +.brand-mark { + display: inline-grid; + place-items: center; + width: 42px; + height: 42px; + border-radius: 14px; + background: linear-gradient(180deg, rgba(39, 75, 99, 0.16), rgba(39, 75, 99, 0.08)); + border: 1px solid rgba(39, 75, 99, 0.16); + color: var(--accent); + font-weight: 700; + letter-spacing: 0.08em; +} + +.brand-copy { + display: grid; + gap: 6px; +} + +.eyebrow { + margin: 0; + font-size: 0.7rem; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--text-muted); +} + +.brand-title { + margin: 0; + font-family: var(--font-serif); + font-size: 1.5rem; + font-weight: 600; + letter-spacing: -0.02em; +} + +.brand-description, +.muted-copy { + margin: 0; + color: var(--text-soft); + line-height: 1.6; +} + +.shell-nav, +.shell-nav--mobile { + display: grid; + gap: 10px; +} + +.shell-nav__item { + display: grid; + gap: 4px; + padding: 14px 16px; + border-radius: 16px; + border: 1px solid transparent; + transition: + border-color 140ms ease, + background-color 140ms ease, + transform 140ms ease; +} + +.shell-nav__item:hover, +.shell-nav__item:focus-visible { + border-color: var(--border); + background: rgba(255, 255, 255, 0.6); + transform: translateY(-1px); +} + +.shell-nav__item.is-active { + border-color: rgba(39, 75, 99, 0.18); + background: var(--accent-soft); +} + +.shell-nav__title { + font-size: 0.95rem; + font-weight: 600; +} + +.shell-nav__caption { + color: var(--text-soft); + font-size: 0.86rem; + line-height: 1.45; +} + +.shell-note { + padding: 16px; + border-radius: 18px; + background: rgba(244, 238, 230, 0.8); + border: 1px solid var(--border); +} + +.shell-note__title { + margin: 0 0 8px; + font-size: 0.88rem; + font-weight: 600; +} + +.shell-column { + display: grid; + gap: 22px; +} + +.shell-topbar { + display: grid; + gap: 18px; + padding: 22px 24px; + background: rgba(255, 252, 248, 0.72); + border: 1px solid var(--border); + border-radius: 30px; + box-shadow: var(--shadow-md); + backdrop-filter: blur(16px); +} + +.shell-topbar__row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.shell-topbar__title { + margin: 0; + font-family: var(--font-serif); + font-size: clamp(1.6rem, 3vw, 2rem); + letter-spacing: -0.03em; +} + +.topbar-status { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.subtle-chip { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 9px 12px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.74); + border: 1px solid var(--border); + color: var(--text-soft); + font-size: 0.82rem; + line-height: 1.3; + max-width: 100%; + white-space: normal; + overflow-wrap: anywhere; +} + +.shell-main { + padding-bottom: 24px; +} + +.content-frame { + display: grid; + gap: 24px; +} + +.page-stack, +.stack { + display: grid; + gap: 24px; +} + +.page-stack { + gap: 28px; +} + +.page-stack--chat { + gap: 24px; +} + +.page-header { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + justify-content: space-between; + gap: 20px 28px; +} + +.page-header__copy { + display: grid; + gap: 12px; + max-width: 780px; +} + +.page-header h1 { + margin: 0; + font-family: var(--font-serif); + font-size: clamp(2rem, 4vw, 3rem); + line-height: 1.02; + letter-spacing: -0.04em; + text-wrap: balance; +} + +.page-header p { + margin: 0; + color: var(--text-soft); + line-height: 1.7; + max-width: 74ch; +} + +.header-meta { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.content-grid, +.dashboard-grid { + display: grid; + gap: 24px; +} + +.chat-layout, +.chat-layout__main, +.chat-layout__rail { + display: grid; + gap: 20px; +} + +.memory-layout, +.memory-followup-grid, +.entity-layout, +.artifact-layout, +.gmail-layout, +.gmail-action-grid, +.calendar-layout, +.calendar-action-grid { + display: grid; + gap: 24px; +} + +.memory-layout { + grid-template-columns: minmax(320px, 0.92fr) minmax(0, 1.2fr); + align-items: flex-start; +} + +.memory-followup-grid { + grid-template-columns: minmax(0, 1.05fr) minmax(320px, 0.95fr); + align-items: flex-start; +} + +.entity-layout { + grid-template-columns: minmax(320px, 0.92fr) minmax(0, 1.2fr); + align-items: flex-start; +} + +.artifact-layout { + grid-template-columns: minmax(320px, 0.95fr) minmax(0, 1.17fr); + align-items: flex-start; +} + +.gmail-layout, +.calendar-layout { + grid-template-columns: minmax(320px, 0.95fr) minmax(0, 1.17fr); + align-items: flex-start; +} + +.artifact-review-grid { + display: grid; + gap: 24px; + grid-template-columns: minmax(300px, 0.9fr) minmax(0, 1.3fr); + align-items: flex-start; +} + +.gmail-action-grid, +.calendar-action-grid { + grid-template-columns: minmax(300px, 0.95fr) minmax(0, 1.05fr); + align-items: flex-start; +} + +.chat-layout__rail { + align-content: start; + grid-auto-rows: max-content; +} + +.chat-layout__rail > * { + min-width: 0; +} + +.chat-layout { + grid-template-columns: minmax(0, 1.5fr) minmax(340px, 0.84fr); + align-items: flex-start; +} + +.content-grid--wide { + grid-template-columns: minmax(0, 1.4fr) minmax(320px, 0.86fr); + align-items: flex-start; +} + +.dashboard-grid--detail { + grid-template-columns: minmax(320px, 0.95fr) minmax(0, 1.25fr); + align-items: flex-start; +} + +.metric-grid, +.route-grid { + display: grid; + gap: 18px; +} + +.metric-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.route-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.section-card { + display: grid; + gap: 20px; + padding: 28px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.5), rgba(255, 252, 248, 0.72)), + var(--surface); + border: 1px solid rgba(42, 52, 66, 0.1); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-md); + backdrop-filter: blur(14px); + overflow: hidden; +} + +.section-card--embedded { + gap: 16px; + padding: 20px; + border-radius: 22px; + background: rgba(255, 255, 255, 0.56); + box-shadow: none; + backdrop-filter: blur(10px); +} + +.section-card--history { + gap: 18px; +} + +.section-card--metric { + gap: 10px; + min-height: 100%; +} + +.section-card__header { + display: grid; + gap: 12px; +} + +.section-card__title { + margin: 0; + font-size: 1.14rem; + font-weight: 600; + letter-spacing: -0.02em; + line-height: 1.2; + overflow-wrap: anywhere; +} + +.section-card__description { + margin: 0; + color: var(--text-soft); + line-height: 1.6; + max-width: 72ch; + overflow-wrap: anywhere; +} + +.metric-value { + font-size: clamp(2rem, 5vw, 2.65rem); + font-weight: 600; + letter-spacing: -0.05em; +} + +.metric-label { + font-size: 0.94rem; + font-weight: 600; +} + +.metric-detail { + margin: 0; + color: var(--text-soft); + line-height: 1.6; +} + +.nav-card { + display: grid; + gap: 12px; + padding: 18px; + border-radius: 20px; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.56); + transition: + transform 140ms ease, + border-color 140ms ease, + box-shadow 140ms ease; +} + +.nav-card:hover, +.nav-card:focus-visible { + transform: translateY(-1px); + border-color: var(--border-strong); + box-shadow: 0 14px 32px rgba(32, 43, 56, 0.05); +} + +.nav-card__topline { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; +} + +.nav-card__topline h3 { + margin: 0; + font-size: 1rem; + letter-spacing: -0.02em; +} + +.nav-card p { + margin: 0; + color: var(--text-soft); + line-height: 1.6; +} + +.nav-card__cta { + color: var(--accent); + font-size: 0.9rem; + font-weight: 600; +} + +.bullet-list { + margin: 0; + padding-left: 1.2rem; + color: var(--text-soft); + display: grid; + gap: 12px; + line-height: 1.6; +} + +.key-value-grid { + display: grid; + gap: 14px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.key-value-grid--compact { + gap: 12px; +} + +.key-value-grid div { + display: grid; + gap: 7px; + padding: 15px 16px; + border-radius: 16px; + background: rgba(255, 255, 255, 0.72); + border: 1px solid rgba(42, 52, 66, 0.08); + align-content: start; +} + +.key-value-grid dt { + color: var(--text-muted); + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.12em; +} + +.key-value-grid dd { + margin: 0; + line-height: 1.55; + overflow-wrap: anywhere; +} + +.status-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 8px 11px; + border-radius: 999px; + border: 1px solid transparent; + font-size: 0.78rem; + font-weight: 600; + letter-spacing: 0.04em; + line-height: 1; + text-transform: uppercase; + white-space: nowrap; + max-width: 100%; +} + +.status-badge--success { + color: var(--success); + background: var(--success-soft); + border-color: rgba(44, 110, 98, 0.18); +} + +.status-badge--warning { + color: var(--warning); + background: var(--warning-soft); + border-color: rgba(142, 98, 32, 0.18); +} + +.status-badge--danger { + color: var(--danger); + background: var(--danger-soft); + border-color: rgba(141, 68, 64, 0.18); +} + +.status-badge--info { + color: var(--info); + background: var(--info-soft); + border-color: rgba(54, 93, 124, 0.18); +} + +.status-badge--neutral { + color: var(--text-soft); + background: rgba(255, 255, 255, 0.72); + border-color: var(--border); +} + +.empty-state { + display: grid; + gap: 12px; + justify-items: start; + padding: 28px; + border-radius: 22px; + background: rgba(255, 255, 255, 0.5); + border: 1px dashed rgba(42, 52, 66, 0.16); +} + +.empty-state--compact { + padding: 18px; + border-radius: 18px; +} + +.empty-state__title { + margin: 0; + font-size: 1rem; + font-weight: 600; +} + +.empty-state__description { + margin: 0; + color: var(--text-soft); + line-height: 1.6; + overflow-wrap: anywhere; +} + +.button, +.button-secondary { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 44px; + padding: 12px 16px; + border-radius: 14px; + border: 1px solid transparent; + font-weight: 600; + line-height: 1.2; + text-align: center; + overflow-wrap: anywhere; + transition: + transform 140ms ease, + background-color 140ms ease, + border-color 140ms ease; +} + +.button { + background: var(--accent); + color: #f7fafc; +} + +.button:hover, +.button:focus-visible, +.button-secondary:hover, +.button-secondary:focus-visible { + transform: translateY(-1px); +} + +.button:disabled { + opacity: 0.62; + cursor: not-allowed; + transform: none; +} + +.button-secondary { + background: rgba(255, 255, 255, 0.72); + border-color: var(--border); + color: var(--text); +} + +.button-secondary--compact { + min-height: 38px; + padding: 8px 12px; + border-radius: 12px; + font-size: 0.84rem; + line-height: 1.35; +} + +.button-secondary.is-current { + border-color: rgba(39, 75, 99, 0.24); + background: rgba(39, 75, 99, 0.08); +} + +.button-secondary--danger { + color: var(--danger); + border-color: rgba(141, 68, 64, 0.16); + background: rgba(141, 68, 64, 0.06); +} + +.composer-card { + display: grid; + gap: 22px; + padding: 30px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-md); + backdrop-filter: blur(14px); +} + +.composer-card--chat-primary, +.section-card--history { + min-height: 100%; +} + +.composer-card--assistant { + gap: 20px; +} + +.composer-card__header, +.detail-stack, +.trace-panel, +.trace-panel__detail, +.list-panel { + display: grid; + gap: 16px; +} + +.composer-card__header--tight { + gap: 16px; +} + +.composer-intro { + display: grid; + gap: 8px; +} + +.composer-title { + margin: 0; + font-size: 1.18rem; + font-weight: 600; + letter-spacing: -0.03em; + line-height: 1.2; +} + +.governance-banner { + display: grid; + gap: 10px; + padding: 15px 17px; + background: rgba(39, 75, 99, 0.05); + border: 1px solid rgba(39, 75, 99, 0.1); + border-radius: 18px; + color: var(--text-soft); +} + +.governance-banner strong { + color: var(--text); +} + +.governance-banner--assistant { + background: rgba(54, 93, 124, 0.05); + border-color: rgba(54, 93, 124, 0.1); +} + +.mode-toggle { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.mode-toggle__item { + display: grid; + gap: 10px; + padding: 20px 22px; + border-radius: 22px; + border: 1px solid rgba(42, 52, 66, 0.08); + background: rgba(255, 252, 248, 0.66); + box-shadow: var(--shadow-md); + transition: + border-color 140ms ease, + background-color 140ms ease, + transform 140ms ease, + box-shadow 140ms ease; +} + +.mode-toggle__item:hover, +.mode-toggle__item:focus-visible { + transform: translateY(-1px); + border-color: var(--border-strong); + background: rgba(255, 255, 255, 0.78); +} + +.mode-toggle__item.is-active { + border-color: rgba(39, 75, 99, 0.2); + background: rgba(255, 255, 255, 0.84); + box-shadow: 0 18px 36px rgba(32, 43, 56, 0.06); +} + +.mode-toggle__label { + font-size: 1rem; + font-weight: 600; + letter-spacing: -0.02em; +} + +.mode-toggle__description { + color: var(--text-soft); + line-height: 1.6; +} + +.chat-workspace { + display: grid; + gap: 24px; +} + +.form-field { + display: grid; + gap: 10px; +} + +.form-field-group { + display: grid; + gap: 16px; +} + +.form-field-group--two-up { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.form-field label { + font-size: 0.9rem; + font-weight: 600; +} + +.form-field textarea, +.form-field input, +.form-field select { + width: 100%; + padding: 16px 18px; + background: rgba(255, 255, 255, 0.74); + border: 1px solid var(--border); + border-radius: 18px; + color: var(--text); + resize: vertical; + transition: + border-color 140ms ease, + box-shadow 140ms ease, + background-color 140ms ease; +} + +.form-field textarea { + min-height: 168px; + line-height: 1.6; +} + +.form-field textarea:focus-visible, +.form-field input:focus-visible, +.form-field select:focus-visible { + outline: none; + border-color: rgba(39, 75, 99, 0.26); + box-shadow: 0 0 0 4px rgba(39, 75, 99, 0.08); + background: rgba(255, 255, 255, 0.9); +} + +.field-hint { + margin: 0; + color: var(--text-muted); + font-size: 0.86rem; + line-height: 1.5; +} + +.composer-actions { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; + justify-content: space-between; +} + +.composer-status { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + color: var(--text-soft); + font-size: 0.9rem; + max-width: 640px; +} + +.history-list, +.list-rows, +.timeline-list, +.trace-events { + display: grid; + gap: 12px; +} + +.history-entry, +.list-row, +.timeline-item, +.trace-event { + padding: 18px 20px; + border-radius: 18px; + border: 1px solid rgba(42, 52, 66, 0.08); + background: rgba(255, 255, 255, 0.62); +} + +.history-entry { + display: grid; + gap: 14px; +} + +.selected-thread-panel, +.thread-summary__topline { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 14px; + padding: 18px 20px; + border-radius: 18px; + background: rgba(255, 255, 255, 0.68); + border: 1px solid rgba(42, 52, 66, 0.08); +} + +.selected-thread-panel__copy { + display: grid; + gap: 8px; + min-width: 0; +} + +.history-entry__topline, +.list-row__topline, +.timeline-item__topline, +.trace-event__topline, +.detail-summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.history-entry__label, +.list-row__eyebrow, +.detail-summary__label { + color: var(--text-muted); + font-size: 0.8rem; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.history-entry p, +.list-row p, +.timeline-item p, +.trace-event p { + margin: 0; + color: var(--text-soft); + line-height: 1.6; + overflow-wrap: anywhere; +} + +.history-entry__trace, +.cluster { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.history-entry__state-row, +.approval-action-bar__buttons { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; +} + +.split-layout { + display: grid; + gap: 24px; + grid-template-columns: minmax(340px, 0.94fr) minmax(0, 1.2fr); +} + +.list-panel__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; +} + +.list-panel__header h2, +.trace-panel h2 { + margin: 0; + font-size: 1.05rem; + letter-spacing: -0.02em; +} + +.list-panel__header p, +.trace-panel__detail > p { + margin: 0; + color: var(--text-soft); + line-height: 1.6; +} + +.list-row { + display: grid; + gap: 14px; + transition: + transform 140ms ease, + border-color 140ms ease, + background-color 140ms ease, + box-shadow 140ms ease; +} + +.list-row:hover, +.list-row:focus-visible { + transform: translateY(-1px); + border-color: var(--border-strong); + box-shadow: 0 12px 24px rgba(32, 43, 56, 0.04); +} + +.list-row.is-selected { + border-color: rgba(39, 75, 99, 0.2); + background: var(--accent-soft); +} + +.list-row[aria-current="page"] { + box-shadow: inset 0 0 0 1px rgba(39, 75, 99, 0.08); +} + +.list-row__title { + margin: 0; + font-size: 0.98rem; + font-weight: 600; + letter-spacing: -0.02em; + line-height: 1.25; + overflow-wrap: anywhere; +} + +.list-row__meta, +.attribute-list, +.evidence-list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.meta-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 10px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.68); + border: 1px solid rgba(42, 52, 66, 0.08); + color: var(--text-soft); + font-size: 0.82rem; + line-height: 1.35; + max-width: 100%; + overflow-wrap: anywhere; +} + +.history-list--scrollable { + max-height: 760px; + overflow: auto; + padding-right: 6px; +} + +.inline-link { + color: var(--accent); + text-decoration: underline; + text-decoration-color: rgba(39, 75, 99, 0.28); + text-underline-offset: 0.16em; +} + +.detail-grid { + display: grid; + gap: 20px; +} + +.thread-workflow-panel { + gap: 18px; +} + +.thread-workflow-panel__summary { + display: grid; + gap: 14px; + padding: 18px 20px; + border-radius: 20px; + background: rgba(255, 255, 255, 0.68); + border: 1px solid rgba(42, 52, 66, 0.08); +} + +.thread-workflow-panel__chips { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.thread-workflow-panel__chips .subtle-chip { + max-width: 100%; + white-space: normal; +} + +.thread-workflow-panel__stack { + display: grid; + gap: 16px; +} + +.thread-workflow-panel__trace-links { + display: grid; + gap: 10px; + padding: 14px 16px; + border-radius: 16px; + background: rgba(248, 244, 238, 0.82); + border: 1px solid rgba(42, 52, 66, 0.08); +} + +.thread-workflow-panel .detail-grid, +.thread-workflow-panel .detail-group, +.thread-workflow-panel .approval-action-bar, +.thread-workflow-panel .execution-summary { + gap: 14px; +} + +.thread-workflow-panel .key-value-grid { + grid-template-columns: 1fr; +} + +.thread-workflow-panel .approval-action-bar__buttons { + align-items: stretch; +} + +.thread-workflow-panel .approval-action-bar__buttons > .button, +.thread-workflow-panel .approval-action-bar__buttons > .button-secondary { + width: 100%; +} + +.thread-workflow-panel .execution-summary__code { + max-height: 220px; +} + +.task-summary--embedded .detail-group { + padding: 16px; +} + +.task-step-list { + gap: 18px; +} + +.task-step-list--embedded .timeline-list { + max-height: 560px; + overflow: auto; + padding-right: 6px; +} + +.task-step-list--embedded .timeline-item { + padding: 16px; + border-radius: 16px; +} + +.thread-trace-panel { + gap: 16px; +} + +.thread-trace-panel__summary-row { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; +} + +.thread-trace-panel__options { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.trace-panel--embedded { + gap: 14px; +} + +.thread-trace-panel__trace-title { + margin: 0; + font-size: 1rem; + letter-spacing: -0.01em; + line-height: 1.3; + overflow-wrap: anywhere; +} + +.thread-trace-panel__footer { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.thread-review-grid { + display: grid; + gap: 16px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.detail-group { + display: grid; + gap: 12px; + padding: 18px; + border-radius: 18px; + background: rgba(255, 255, 255, 0.5); + border: 1px solid rgba(42, 52, 66, 0.08); +} + +.detail-group--muted { + background: rgba(248, 244, 238, 0.78); +} + +.detail-group h3 { + margin: 0; + font-size: 0.95rem; + letter-spacing: -0.01em; + line-height: 1.25; +} + +.memory-quality-gate { + gap: 14px; +} + +.memory-quality-gate__topline { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.memory-quality-gate__topline .detail-stack { + gap: 8px; +} + +.memory-quality-gate__copy { + display: grid; + gap: 8px; +} + +.memory-quality-gate__copy p { + margin: 0; + color: var(--text-soft); + line-height: 1.6; +} + +.workflow-memory-writeback__value-field textarea { + min-height: 140px; +} + +.workflow-memory-writeback__toggle { + display: flex; + gap: 10px; + align-items: flex-start; + color: var(--text-soft); + line-height: 1.5; +} + +.workflow-memory-writeback__toggle input { + margin-top: 3px; +} + +.workflow-memory-writeback__evidence { + display: grid; + gap: 10px; + padding: 14px 16px; + border-radius: 16px; + background: rgba(248, 245, 240, 0.92); + border: 1px solid rgba(42, 52, 66, 0.08); +} + +.attribute-item, +.evidence-chip { + display: inline-flex; + align-items: center; + padding: 9px 11px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.7); + border: 1px solid rgba(42, 52, 66, 0.08); + color: var(--text-soft); + font-size: 0.84rem; + line-height: 1.45; + overflow-wrap: anywhere; + word-break: break-word; + white-space: normal; + max-width: 100%; +} + +.conversation-stack { + display: grid; + gap: 12px; +} + +.conversation-block { + display: grid; + gap: 10px; + padding: 16px 18px; + border-radius: 18px; + background: rgba(255, 255, 255, 0.72); + border: 1px solid rgba(42, 52, 66, 0.08); +} + +.conversation-block--accent { + background: rgba(245, 248, 252, 0.86); + border-color: rgba(54, 93, 124, 0.12); +} + +.transcript-summary { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.transcript-stream { + display: grid; + gap: 14px; +} + +.transcript-entry { + display: grid; + gap: 14px; + padding: 20px 22px; + border-radius: 22px; + border: 1px solid rgba(42, 52, 66, 0.08); + background: rgba(255, 255, 255, 0.7); +} + +.transcript-entry--assistant { + background: linear-gradient(180deg, rgba(245, 248, 252, 0.9), rgba(255, 255, 255, 0.82)); + border-color: rgba(54, 93, 124, 0.12); +} + +.transcript-entry__topline, +.transcript-entry__heading, +.transcript-entry__footer { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + justify-content: space-between; +} + +.transcript-entry__heading { + justify-content: flex-start; +} + +.transcript-entry__role { + display: inline-flex; + align-items: center; + padding: 7px 11px; + border-radius: 999px; + border: 1px solid transparent; + font-size: 0.78rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.transcript-entry__role--user { + color: var(--accent); + background: rgba(39, 75, 99, 0.08); + border-color: rgba(39, 75, 99, 0.12); +} + +.transcript-entry__role--assistant { + color: var(--info); + background: rgba(54, 93, 124, 0.08); + border-color: rgba(54, 93, 124, 0.12); +} + +.transcript-entry__content { + margin: 0; + color: var(--text); + font-size: 0.98rem; + line-height: 1.75; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.response-copy { + margin: 0; + color: var(--text); + line-height: 1.65; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.reason-list { + margin: 0; + padding-left: 1.2rem; + color: var(--text-soft); + display: grid; + gap: 10px; + line-height: 1.6; +} + +.timeline-item { + display: grid; + gap: 14px; + position: relative; + padding-left: 16px; +} + +.timeline-item::before { + content: ""; + position: absolute; + left: 0; + top: 6px; + bottom: 6px; + width: 3px; + border-radius: 999px; + background: rgba(39, 75, 99, 0.18); +} + +.timeline-item__summary { + display: grid; + gap: 12px; +} + +.timeline-item__meta { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.approval-action-bar, +.approval-action-bar__summary { + display: grid; + gap: 14px; +} + +.approval-action-bar { + padding: 18px; + border-radius: 18px; + background: rgba(247, 244, 239, 0.84); + border: 1px solid rgba(42, 52, 66, 0.08); +} + +.approval-action-bar__buttons { + align-items: center; +} + +.detail-summary__label { + overflow-wrap: anywhere; +} + +.thread-summary__id { + margin: 0; + color: var(--text-soft); + line-height: 1.65; + overflow-wrap: anywhere; +} + +.execution-summary { + display: grid; + gap: 16px; + padding: 18px; + border-radius: 18px; + background: rgba(255, 255, 255, 0.72); + border: 1px solid rgba(42, 52, 66, 0.08); +} + +.execution-summary--empty, +.execution-summary--unavailable { + background: rgba(255, 255, 255, 0.56); +} + +.execution-summary__topline { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.execution-summary__title { + margin: 0; + font-size: 1rem; + font-weight: 600; + letter-spacing: -0.02em; + line-height: 1.25; + overflow-wrap: anywhere; +} + +.execution-summary__label { + margin: 0; + color: var(--text-muted); + font-size: 0.76rem; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.execution-summary__note { + display: grid; + gap: 10px; + padding: 14px 16px; + border-radius: 16px; + background: rgba(248, 245, 240, 0.92); + border: 1px solid rgba(42, 52, 66, 0.08); +} + +.execution-summary__note--danger { + background: rgba(141, 68, 64, 0.05); + border-color: rgba(141, 68, 64, 0.12); +} + +.execution-summary__code { + margin: 0; + padding: 14px; + overflow: auto; + border-radius: 14px; + background: rgba(23, 31, 41, 0.94); + color: rgba(246, 248, 251, 0.94); + font-size: 0.81rem; + line-height: 1.55; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.loading-card { + min-height: 100%; +} + +.loading-placeholder { + border-radius: 16px; + background: + linear-gradient( + 90deg, + rgba(255, 255, 255, 0.56) 0%, + rgba(255, 255, 255, 0.86) 50%, + rgba(255, 255, 255, 0.56) 100% + ); + background-size: 220% 100%; + animation: loading-sheen 1.4s ease-in-out infinite; + border: 1px solid rgba(42, 52, 66, 0.08); +} + +.loading-placeholder--card { + min-height: 108px; +} + +.loading-placeholder--line { + height: 18px; +} + +.loading-placeholder--wide { + width: 82%; +} + +.loading-placeholder--button { + width: 180px; + height: 44px; +} + +.trace-events { + margin: 0; + padding: 0; + list-style: none; +} + +.trace-event h4 { + margin: 0; + font-size: 0.95rem; + letter-spacing: -0.01em; +} + +.trace-summary { + display: grid; + gap: 14px; +} + +.artifact-chunk__text { + margin: 0; + padding: 14px 16px; + border-radius: 14px; + border: 1px solid rgba(42, 52, 66, 0.08); + background: rgba(255, 255, 255, 0.78); + color: var(--text); + font-size: 0.9rem; + line-height: 1.65; + white-space: pre-wrap; + overflow-wrap: anywhere; + max-height: 240px; + overflow: auto; +} + +.responsive-note { + color: var(--text-muted); + font-size: 0.82rem; +} + +@media (max-width: 1120px) { + .shell { + grid-template-columns: 1fr; + } + + .shell-sidebar { + display: none; + } + + .content-grid--wide, + .dashboard-grid--detail, + .split-layout, + .chat-layout, + .memory-layout, + .memory-followup-grid, + .entity-layout, + .artifact-layout, + .artifact-review-grid, + .gmail-layout, + .gmail-action-grid, + .calendar-layout, + .calendar-action-grid, + .chat-workspace, + .metric-grid, + .route-grid { + grid-template-columns: 1fr; + } + + .task-step-list--embedded .timeline-list { + max-height: none; + overflow: visible; + padding-right: 0; + } +} + +@media (min-width: 1121px) { + .shell-nav--mobile { + display: none; + } +} + +@media (max-width: 740px) { + .shell { + padding: 14px; + gap: 16px; + } + + .shell-topbar, + .section-card, + .composer-card { + padding: 20px; + border-radius: 24px; + } + + .mode-toggle { + grid-template-columns: 1fr; + gap: 12px; + } + + .shell-topbar__row, + .page-header, + .composer-actions, + .selected-thread-panel, + .thread-summary__topline, + .list-panel__header, + .history-entry__topline, + .list-row__topline, + .timeline-item__topline, + .trace-event__topline, + .transcript-entry__topline, + .transcript-entry__footer, + .detail-summary, + .nav-card__topline, + .execution-summary__topline { + flex-direction: column; + align-items: flex-start; + } + + .memory-quality-gate__topline { + flex-direction: column; + align-items: flex-start; + } + + .shell-nav--mobile { + grid-auto-flow: column; + grid-auto-columns: minmax(150px, 1fr); + overflow-x: auto; + padding-bottom: 2px; + } + + .key-value-grid { + grid-template-columns: 1fr; + } + + .thread-review-grid { + grid-template-columns: 1fr; + } + + .form-field-group--two-up { + grid-template-columns: 1fr; + } + + .detail-group, + .approval-action-bar, + .execution-summary { + padding: 16px; + } + + .transcript-entry { + padding: 18px; + border-radius: 18px; + } + + .button, + .button-secondary { + width: 100%; + } + + .history-list--scrollable { + max-height: none; + padding-right: 0; + } +} + +@keyframes loading-sheen { + 0% { + background-position: 100% 50%; + } + + 100% { + background-position: 0% 50%; + } +} diff --git a/apps/web/app/gmail/loading.tsx b/apps/web/app/gmail/loading.tsx new file mode 100644 index 0000000..f6dd7d4 --- /dev/null +++ b/apps/web/app/gmail/loading.tsx @@ -0,0 +1,78 @@ +import { PageHeader } from "../../components/page-header"; +import { SectionCard } from "../../components/section-card"; +import { StatusBadge } from "../../components/status-badge"; + +export default function Loading() { + return ( + <div className="page-stack" aria-busy="true"> + <PageHeader + eyebrow="Gmail" + title="Gmail account review workspace" + description="Loading connected account list, selected account detail, connect controls, and single-message ingestion controls." + meta={ + <div className="header-meta"> + <span className="subtle-chip">Loading route state</span> + </div> + } + /> + + <div className="gmail-layout"> + <SectionCard + eyebrow="Account list" + title="Loading connected accounts" + description="Gmail account rows are loading from the current source." + className="loading-card" + > + <div className="detail-stack"> + <StatusBadge status="loading" label="Loading" /> + <div className="loading-placeholder loading-placeholder--card" /> + <div className="loading-placeholder loading-placeholder--card" /> + </div> + </SectionCard> + + <SectionCard + eyebrow="Selected account" + title="Loading selected account" + description="Account metadata and scope summary are loading." + className="loading-card" + > + <div className="detail-stack"> + <StatusBadge status="loading" label="Loading" /> + <div className="loading-placeholder loading-placeholder--line loading-placeholder--wide" /> + <div className="loading-placeholder loading-placeholder--line" /> + <div className="loading-placeholder loading-placeholder--card" /> + </div> + </SectionCard> + </div> + + <div className="gmail-action-grid"> + <SectionCard + eyebrow="Connect account" + title="Loading connect controls" + description="Bounded connect-account fields are loading." + className="loading-card" + > + <div className="detail-stack"> + <StatusBadge status="loading" label="Loading" /> + <div className="loading-placeholder loading-placeholder--line loading-placeholder--wide" /> + <div className="loading-placeholder loading-placeholder--card" /> + </div> + </SectionCard> + + <SectionCard + eyebrow="Ingest message" + title="Loading ingestion controls" + description="Provider-message and task-workspace controls are loading." + className="loading-card" + > + <div className="detail-stack"> + <StatusBadge status="loading" label="Loading" /> + <div className="loading-placeholder loading-placeholder--line loading-placeholder--wide" /> + <div className="loading-placeholder loading-placeholder--line" /> + <div className="loading-placeholder loading-placeholder--button" /> + </div> + </SectionCard> + </div> + </div> + ); +} diff --git a/apps/web/app/gmail/page.tsx b/apps/web/app/gmail/page.tsx new file mode 100644 index 0000000..bc599d5 --- /dev/null +++ b/apps/web/app/gmail/page.tsx @@ -0,0 +1,187 @@ +import { GmailAccountConnectForm } from "../../components/gmail-account-connect-form"; +import { GmailAccountDetail } from "../../components/gmail-account-detail"; +import { GmailAccountList } from "../../components/gmail-account-list"; +import { GmailMessageIngestForm } from "../../components/gmail-message-ingest-form"; +import { PageHeader } from "../../components/page-header"; +import type { ApiSource, GmailAccountRecord } from "../../lib/api"; +import { + combinePageModes, + getApiConfig, + getGmailAccountDetail, + hasLiveApiConfig, + listGmailAccounts, + listTaskWorkspaces, + pageModeLabel, +} from "../../lib/api"; +import { + getFixtureGmailAccount, + gmailAccountFixtures, + gmailAccountListSummaryFixture, + taskWorkspaceFixtures, + taskWorkspaceListSummaryFixture, +} from "../../lib/fixtures"; + +type SearchParams = Promise<Record<string, string | string[] | undefined>>; + +function normalizeParam(value: string | string[] | undefined) { + if (Array.isArray(value)) { + return normalizeParam(value[0]); + } + + return value?.trim() ?? ""; +} + +function resolveSelectedAccountId(requestedAccountId: string, items: GmailAccountRecord[]) { + if (!items.length) { + return ""; + } + + const availableIds = new Set(items.map((item) => item.id)); + if (requestedAccountId && availableIds.has(requestedAccountId)) { + return requestedAccountId; + } + + return items[0]?.id ?? ""; +} + +export default async function GmailPage({ + searchParams, +}: { + searchParams?: SearchParams; +}) { + const params = (searchParams ? await searchParams : {}) as Record< + string, + string | string[] | undefined + >; + const requestedAccountId = normalizeParam(params.account); + const apiConfig = getApiConfig(); + const liveModeReady = hasLiveApiConfig(apiConfig); + + let accounts = gmailAccountFixtures; + let accountListSummary = gmailAccountListSummaryFixture; + let accountListSource: ApiSource = "fixture"; + let accountListUnavailableReason: string | undefined; + + if (liveModeReady) { + try { + const payload = await listGmailAccounts(apiConfig.apiBaseUrl, apiConfig.userId); + accounts = payload.items; + accountListSummary = payload.summary; + accountListSource = "live"; + } catch (error) { + accountListUnavailableReason = + error instanceof Error ? error.message : "Gmail account list could not be loaded."; + } + } + + const selectedAccountId = resolveSelectedAccountId(requestedAccountId, accounts); + const selectedFromList = accounts.find((item) => item.id === selectedAccountId) ?? null; + + let selectedAccount = selectedFromList; + let selectedAccountSource: ApiSource | "unavailable" | null = selectedAccount + ? accountListSource + : null; + let selectedAccountUnavailableReason: string | undefined; + + if (selectedFromList && liveModeReady && accountListSource === "live") { + try { + const payload = await getGmailAccountDetail( + apiConfig.apiBaseUrl, + selectedFromList.id, + apiConfig.userId, + ); + selectedAccount = payload.account; + selectedAccountSource = "live"; + } catch (error) { + const fixtureAccount = getFixtureGmailAccount(selectedFromList.id); + if (fixtureAccount) { + selectedAccount = fixtureAccount; + selectedAccountSource = "fixture"; + } else { + selectedAccountSource = "unavailable"; + } + selectedAccountUnavailableReason = + error instanceof Error ? error.message : "Selected Gmail account detail could not be loaded."; + } + } + + let taskWorkspaces = taskWorkspaceFixtures; + let taskWorkspaceSummary = taskWorkspaceListSummaryFixture; + let taskWorkspaceSource: ApiSource | "unavailable" = "fixture"; + let taskWorkspaceUnavailableReason: string | undefined; + + if (liveModeReady) { + try { + const payload = await listTaskWorkspaces(apiConfig.apiBaseUrl, apiConfig.userId); + taskWorkspaces = payload.items; + taskWorkspaceSummary = payload.summary; + taskWorkspaceSource = "live"; + } catch (error) { + taskWorkspaceUnavailableReason = + error instanceof Error ? error.message : "Task workspace list could not be loaded."; + if (!taskWorkspaceFixtures.length) { + taskWorkspaceSource = "unavailable"; + } + } + } + + const pageMode = combinePageModes( + accountListSource, + selectedAccountSource === "unavailable" ? null : selectedAccountSource, + taskWorkspaceSource === "unavailable" ? null : taskWorkspaceSource, + ); + + return ( + <div className="page-stack"> + <PageHeader + eyebrow="Gmail" + title="Gmail account review workspace" + description="Review connected accounts first, inspect one selected account second, then run explicit connect or single-message ingestion actions." + meta={ + <div className="header-meta"> + <span className="subtle-chip">{pageModeLabel(pageMode)}</span> + <span className="subtle-chip">{accounts.length} visible accounts</span> + <span className="subtle-chip">{taskWorkspaceSummary.total_count} task workspaces</span> + {selectedAccount ? ( + <span className="subtle-chip">Selected: {selectedAccount.email_address}</span> + ) : null} + </div> + } + /> + + <div className="gmail-layout"> + <GmailAccountList + accounts={accounts} + selectedAccountId={selectedAccount?.id} + summary={accountListSummary} + source={accountListSource} + unavailableReason={accountListUnavailableReason} + /> + <GmailAccountDetail + account={selectedAccount} + source={selectedAccountSource} + unavailableReason={selectedAccountUnavailableReason} + /> + </div> + + <div className="gmail-action-grid"> + <GmailAccountConnectForm + apiBaseUrl={apiConfig.apiBaseUrl} + userId={apiConfig.userId} + /> + <GmailMessageIngestForm + account={selectedAccount} + accountSource={selectedAccountSource} + taskWorkspaces={taskWorkspaces} + taskWorkspaceSource={taskWorkspaceSource} + apiBaseUrl={apiConfig.apiBaseUrl} + userId={apiConfig.userId} + /> + </div> + + {taskWorkspaceUnavailableReason ? ( + <p className="responsive-note">Live task workspace list read failed: {taskWorkspaceUnavailableReason}</p> + ) : null} + </div> + ); +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx new file mode 100644 index 0000000..e0ca3f1 --- /dev/null +++ b/apps/web/app/layout.tsx @@ -0,0 +1,24 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; + +import { AppShell } from "../components/app-shell"; + +import "./globals.css"; + +export const metadata: Metadata = { + title: "AliceBot Operator Shell", + description: + "Governed operator interface for requests, approvals, tasks, artifacts, Gmail, Calendar, memories, entities, and explainability.", +}; + +export default function RootLayout({ + children, +}: Readonly<{ children: ReactNode }>) { + return ( + <html lang="en"> + <body> + <AppShell>{children}</AppShell> + </body> + </html> + ); +} diff --git a/apps/web/app/memories/loading.tsx b/apps/web/app/memories/loading.tsx new file mode 100644 index 0000000..21eafd9 --- /dev/null +++ b/apps/web/app/memories/loading.tsx @@ -0,0 +1,92 @@ +import { PageHeader } from "../../components/page-header"; +import { SectionCard } from "../../components/section-card"; +import { StatusBadge } from "../../components/status-badge"; + +export default function Loading() { + return ( + <div className="page-stack" aria-busy="true"> + <PageHeader + eyebrow="Memories" + title="Memory review workspace" + description="Loading memory evaluation summary, active list state, selected detail, revisions, and labels." + meta={ + <div className="header-meta"> + <span className="subtle-chip">Loading route state</span> + </div> + } + /> + + <SectionCard + eyebrow="Memory summary" + title="Loading evaluation posture" + description="Evaluation summary and review-queue state are loading from the current source." + className="loading-card" + > + <div className="detail-stack"> + <StatusBadge status="loading" label="Loading" /> + <div className="loading-placeholder loading-placeholder--line loading-placeholder--wide" /> + <div className="loading-placeholder loading-placeholder--card" /> + </div> + </SectionCard> + + <div className="memory-layout"> + <SectionCard + eyebrow="Memory list" + title="Loading memories" + description="The active memory list is loading." + className="loading-card" + > + <div className="detail-stack"> + <StatusBadge status="loading" label="Loading" /> + <div className="loading-placeholder loading-placeholder--card" /> + <div className="loading-placeholder loading-placeholder--card" /> + <div className="loading-placeholder loading-placeholder--card" /> + </div> + </SectionCard> + + <SectionCard + eyebrow="Selected memory" + title="Loading selected memory" + description="Value, source events, and timestamps are loading." + className="loading-card" + > + <div className="detail-stack"> + <StatusBadge status="loading" label="Loading" /> + <div className="loading-placeholder loading-placeholder--line loading-placeholder--wide" /> + <div className="loading-placeholder loading-placeholder--line" /> + <div className="loading-placeholder loading-placeholder--card" /> + </div> + </SectionCard> + </div> + + <div className="memory-followup-grid"> + <SectionCard + eyebrow="Revision history" + title="Loading revisions" + description="Ordered revision history is loading." + className="loading-card" + > + <div className="detail-stack"> + <StatusBadge status="loading" label="Loading" /> + <div className="loading-placeholder loading-placeholder--card" /> + <div className="loading-placeholder loading-placeholder--card" /> + </div> + </SectionCard> + + <SectionCard + eyebrow="Review labels" + title="Loading labels" + description="Current labels and submission status are loading." + className="loading-card" + > + <div className="detail-stack"> + <StatusBadge status="loading" label="Loading" /> + <div className="loading-placeholder loading-placeholder--line loading-placeholder--wide" /> + <div className="loading-placeholder loading-placeholder--line" /> + <div className="loading-placeholder loading-placeholder--button" /> + </div> + </SectionCard> + </div> + </div> + ); +} diff --git a/apps/web/app/memories/page.test.tsx b/apps/web/app/memories/page.test.tsx new file mode 100644 index 0000000..aa38287 --- /dev/null +++ b/apps/web/app/memories/page.test.tsx @@ -0,0 +1,515 @@ +import React from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import MemoriesPage from "./page"; + +const { + getApiConfigMock, + getMemoryDetailMock, + getMemoryEvaluationSummaryMock, + getMemoryTrustDashboardMock, + getMemoryRevisionsMock, + getOpenLoopDetailMock, + hasLiveApiConfigMock, + listMemoriesMock, + listMemoryLabelsMock, + listOpenLoopsMock, + listMemoryReviewQueueMock, +} = vi.hoisted(() => ({ + getApiConfigMock: vi.fn(), + getMemoryDetailMock: vi.fn(), + getMemoryEvaluationSummaryMock: vi.fn(), + getMemoryTrustDashboardMock: vi.fn(), + getMemoryRevisionsMock: vi.fn(), + getOpenLoopDetailMock: vi.fn(), + hasLiveApiConfigMock: vi.fn(), + listMemoriesMock: vi.fn(), + listMemoryLabelsMock: vi.fn(), + listOpenLoopsMock: vi.fn(), + listMemoryReviewQueueMock: vi.fn(), +})); + +vi.mock("next/link", () => ({ + default: ({ + href, + children, + className, + "aria-current": ariaCurrent, + }: { + href: string; + children: React.ReactNode; + className?: string; + "aria-current"?: string; + }) => ( + <a href={href} className={className} aria-current={ariaCurrent}> + {children} + </a> + ), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + refresh: vi.fn(), + }), +})); + +vi.mock("../../lib/api", async () => { + const actual = await vi.importActual("../../lib/api"); + return { + ...actual, + getApiConfig: getApiConfigMock, + getMemoryDetail: getMemoryDetailMock, + getMemoryEvaluationSummary: getMemoryEvaluationSummaryMock, + getMemoryTrustDashboard: getMemoryTrustDashboardMock, + getMemoryRevisions: getMemoryRevisionsMock, + getOpenLoopDetail: getOpenLoopDetailMock, + hasLiveApiConfig: hasLiveApiConfigMock, + listMemories: listMemoriesMock, + listMemoryLabels: listMemoryLabelsMock, + listOpenLoops: listOpenLoopsMock, + listMemoryReviewQueue: listMemoryReviewQueueMock, + }; +}); + +describe("MemoriesPage", () => { + beforeEach(() => { + getApiConfigMock.mockReset(); + getMemoryDetailMock.mockReset(); + getMemoryEvaluationSummaryMock.mockReset(); + getMemoryTrustDashboardMock.mockReset(); + getMemoryRevisionsMock.mockReset(); + getOpenLoopDetailMock.mockReset(); + hasLiveApiConfigMock.mockReset(); + listMemoriesMock.mockReset(); + listMemoryLabelsMock.mockReset(); + listOpenLoopsMock.mockReset(); + listMemoryReviewQueueMock.mockReset(); + + getApiConfigMock.mockReturnValue({ + apiBaseUrl: "", + userId: "", + defaultThreadId: "", + defaultToolId: "", + }); + hasLiveApiConfigMock.mockReturnValue(false); + }); + + afterEach(() => { + cleanup(); + }); + + it("uses fixture-backed memory workspace state when live API config is absent", async () => { + render(await MemoriesPage({ searchParams: Promise.resolve({}) })); + + expect(screen.getByText("Fixture-backed")).toBeInTheDocument(); + expect(screen.getByText("Summary Fixture")).toBeInTheDocument(); + expect(screen.getByText("Canonical quality posture")).toBeInTheDocument(); + expect(screen.getByText(/Fixture dashboard/)).toBeInTheDocument(); + expect(screen.getByText("Queue Fixture")).toBeInTheDocument(); + expect(screen.getAllByText("Insufficient sample").length).toBeGreaterThan(0); + expect(screen.getByText("Fixture list")).toBeInTheDocument(); + expect(screen.getByText("Fixture detail")).toBeInTheDocument(); + expect( + screen.getByText( + "Label submission is unavailable until live API configuration and live memory detail are present.", + ), + ).toBeInTheDocument(); + expect(listMemoriesMock).not.toHaveBeenCalled(); + }); + + it("renders live-backed memory summary, detail, revisions, and labels when live reads succeed", async () => { + getApiConfigMock.mockReturnValue({ + apiBaseUrl: "https://api.example.com", + userId: "user-1", + defaultThreadId: "thread-1", + defaultToolId: "tool-1", + }); + hasLiveApiConfigMock.mockReturnValue(true); + listMemoriesMock.mockResolvedValue({ + items: [ + { + id: "memory-live-1", + memory_key: "user.preference.live", + value: { merchant: "Live Merchant" }, + status: "active", + source_event_ids: ["event-live-1"], + created_at: "2026-03-18T10:00:00Z", + updated_at: "2026-03-18T10:05:00Z", + deleted_at: null, + }, + ], + summary: { + status: "active", + limit: 20, + returned_count: 1, + total_count: 1, + has_more: false, + order: ["updated_at_desc", "created_at_desc", "id_desc"], + }, + }); + listMemoryReviewQueueMock.mockResolvedValue({ + items: [], + summary: { + memory_status: "active", + review_state: "unlabeled", + priority_mode: "recent_first", + available_priority_modes: [ + "oldest_first", + "recent_first", + "high_risk_first", + "stale_truth_first", + ], + limit: 20, + returned_count: 0, + total_count: 0, + has_more: false, + order: ["updated_at_desc", "created_at_desc", "id_desc"], + }, + }); + getMemoryEvaluationSummaryMock.mockResolvedValue({ + summary: { + total_memory_count: 10, + active_memory_count: 10, + deleted_memory_count: 0, + labeled_memory_count: 10, + unlabeled_memory_count: 0, + total_label_row_count: 10, + label_row_counts_by_value: { + correct: 8, + incorrect: 2, + outdated: 0, + insufficient_evidence: 0, + }, + label_value_order: ["correct", "incorrect", "outdated", "insufficient_evidence"], + quality_gate: { + status: "healthy", + precision: 0.8, + precision_target: 0.8, + adjudicated_sample_count: 10, + minimum_adjudicated_sample: 10, + remaining_to_minimum_sample: 0, + unlabeled_memory_count: 0, + high_risk_memory_count: 0, + stale_truth_count: 0, + superseded_active_conflict_count: 0, + counts: { + active_memory_count: 10, + labeled_active_memory_count: 10, + adjudicated_correct_count: 8, + adjudicated_incorrect_count: 2, + outdated_label_count: 0, + insufficient_evidence_label_count: 0, + }, + }, + }, + }); + getMemoryTrustDashboardMock.mockResolvedValue({ + dashboard: { + quality_gate: { + status: "needs_review", + precision: 0.8, + precision_target: 0.8, + adjudicated_sample_count: 10, + minimum_adjudicated_sample: 10, + remaining_to_minimum_sample: 0, + unlabeled_memory_count: 2, + high_risk_memory_count: 1, + stale_truth_count: 1, + superseded_active_conflict_count: 0, + counts: { + active_memory_count: 12, + labeled_active_memory_count: 10, + adjudicated_correct_count: 8, + adjudicated_incorrect_count: 2, + outdated_label_count: 0, + insufficient_evidence_label_count: 0, + }, + }, + queue_posture: { + priority_mode: "recent_first", + total_count: 2, + high_risk_count: 1, + stale_truth_count: 1, + priority_reason_counts: { recent_first: 2 }, + order: ["updated_at_desc", "created_at_desc", "id_desc"], + aging: { + anchor_updated_at: "2026-03-23T09:00:00Z", + newest_updated_at: "2026-03-23T09:00:00Z", + oldest_updated_at: "2026-03-22T09:00:00Z", + backlog_span_hours: 24, + fresh_within_24h_count: 2, + aging_24h_to_72h_count: 0, + stale_over_72h_count: 0, + }, + }, + retrieval_quality: { + fixture_count: 3, + evaluated_fixture_count: 3, + passing_fixture_count: 3, + precision_at_k_mean: 1, + precision_at_1_mean: 1, + precision_target: 0.8, + status: "pass", + fixture_order: ["fixture_id_asc"], + result_order: ["precision_at_k_desc", "fixture_id_asc"], + }, + correction_freshness: { + total_open_loop_count: 1, + stale_open_loop_count: 0, + correction_recurrence_count: 0, + freshness_drift_count: 0, + }, + recommended_review: { + priority_mode: "high_risk_first", + action: "review_high_risk_queue", + reason: "High-risk unlabeled memories are present; triage those before lower-risk backlog.", + }, + sources: [ + "memories", + "memory_review_labels", + "continuity_recall", + "continuity_correction_events", + "retrieval_evaluation_fixtures", + ], + }, + }); + listOpenLoopsMock.mockResolvedValue({ + items: [ + { + id: "loop-live-1", + memory_id: "memory-live-1", + title: "Confirm merchant details", + status: "open", + opened_at: "2026-03-23T09:00:00Z", + due_at: "2026-03-25T09:00:00Z", + resolved_at: null, + resolution_note: null, + created_at: "2026-03-23T09:00:00Z", + updated_at: "2026-03-23T09:00:00Z", + }, + ], + summary: { + status: "open", + limit: 20, + returned_count: 1, + total_count: 1, + has_more: false, + order: ["opened_at_desc", "created_at_desc", "id_desc"], + }, + }); + getOpenLoopDetailMock.mockResolvedValue({ + open_loop: { + id: "loop-live-1", + memory_id: "memory-live-1", + title: "Confirm merchant details", + status: "open", + opened_at: "2026-03-23T09:00:00Z", + due_at: "2026-03-25T09:00:00Z", + resolved_at: null, + resolution_note: null, + created_at: "2026-03-23T09:00:00Z", + updated_at: "2026-03-23T09:00:00Z", + }, + }); + getMemoryDetailMock.mockResolvedValue({ + memory: { + id: "memory-live-1", + memory_key: "user.preference.live", + value: { merchant: "Live Merchant", note: "Verified" }, + status: "active", + source_event_ids: ["event-live-1"], + memory_type: "decision", + confidence: 0.93, + salience: 0.81, + confirmation_status: "confirmed", + valid_from: "2026-03-01T00:00:00Z", + valid_to: "2026-12-31T00:00:00Z", + last_confirmed_at: "2026-03-20T00:00:00Z", + created_at: "2026-03-18T10:00:00Z", + updated_at: "2026-03-18T10:05:00Z", + deleted_at: null, + }, + }); + getMemoryRevisionsMock.mockResolvedValue({ + items: [ + { + id: "revision-live-1", + memory_id: "memory-live-1", + sequence_no: 1, + action: "ADD", + memory_key: "user.preference.live", + previous_value: null, + new_value: { merchant: "Live Merchant" }, + source_event_ids: ["event-live-1"], + created_at: "2026-03-18T10:00:00Z", + }, + ], + summary: { + memory_id: "memory-live-1", + limit: 20, + returned_count: 1, + total_count: 1, + has_more: false, + order: ["sequence_no_asc"], + }, + }); + listMemoryLabelsMock.mockResolvedValue({ + items: [ + { + id: "label-live-1", + memory_id: "memory-live-1", + reviewer_user_id: "user-1", + label: "correct", + note: "Confirmed", + created_at: "2026-03-18T10:06:00Z", + }, + ], + summary: { + memory_id: "memory-live-1", + total_count: 1, + counts_by_label: { + correct: 1, + incorrect: 0, + outdated: 0, + insufficient_evidence: 0, + }, + order: ["correct", "incorrect", "outdated", "insufficient_evidence"], + }, + }); + + render( + await MemoriesPage({ + searchParams: Promise.resolve({ + memory: "memory-live-1", + }), + }), + ); + + expect(screen.getByText("Live API")).toBeInTheDocument(); + expect(screen.getByText("Summary Live")).toBeInTheDocument(); + expect(screen.getByText(/Live dashboard/)).toBeInTheDocument(); + expect(screen.getByText("review_high_risk_queue")).toBeInTheDocument(); + expect(screen.getByText("Queue Live")).toBeInTheDocument(); + expect(screen.getByText("Healthy")).toBeInTheDocument(); + expect(screen.getByText("Live list")).toBeInTheDocument(); + expect(screen.getByText("Live detail")).toBeInTheDocument(); + expect(screen.getByText("Live revisions")).toBeInTheDocument(); + expect(screen.getByText("Live labels")).toBeInTheDocument(); + expect(screen.getByText("Typed metadata")).toBeInTheDocument(); + expect(screen.getByText("Open-loop backbone")).toBeInTheDocument(); + expect(screen.getByText("Confirm merchant details")).toBeInTheDocument(); + expect(screen.getByText("decision")).toBeInTheDocument(); + expect(screen.getByText("confirmed")).toBeInTheDocument(); + expect(screen.getByText("0.93")).toBeInTheDocument(); + expect(screen.getByText("0.81")).toBeInTheDocument(); + expect(screen.getByText("2026-03-01T00:00:00Z")).toBeInTheDocument(); + expect(screen.getByText("2026-12-31T00:00:00Z")).toBeInTheDocument(); + expect(screen.getByText("2026-03-20T00:00:00Z")).toBeInTheDocument(); + + expect(listMemoriesMock).toHaveBeenCalledWith("https://api.example.com", "user-1", { + status: "active", + }); + expect(getMemoryDetailMock).toHaveBeenCalledWith( + "https://api.example.com", + "memory-live-1", + "user-1", + ); + expect(listOpenLoopsMock).toHaveBeenCalledWith("https://api.example.com", "user-1", { + status: "open", + limit: 20, + }); + expect(listMemoryReviewQueueMock).toHaveBeenCalledWith("https://api.example.com", "user-1", { + priorityMode: "recent_first", + }); + expect(getMemoryTrustDashboardMock).toHaveBeenCalledWith("https://api.example.com", "user-1"); + }); + + it("keeps fallback state explicit when live reads partially fail and shows unavailable revision/label panels", async () => { + getApiConfigMock.mockReturnValue({ + apiBaseUrl: "https://api.example.com", + userId: "user-1", + defaultThreadId: "thread-1", + defaultToolId: "tool-1", + }); + hasLiveApiConfigMock.mockReturnValue(true); + listMemoriesMock.mockResolvedValue({ + items: [ + { + id: "memory-live-missing", + memory_key: "user.preference.live.missing", + value: { merchant: "Unknown" }, + status: "active", + source_event_ids: ["event-live-missing"], + created_at: "2026-03-18T10:00:00Z", + updated_at: "2026-03-18T10:05:00Z", + deleted_at: null, + }, + ], + summary: { + status: "active", + limit: 20, + returned_count: 1, + total_count: 1, + has_more: false, + order: ["updated_at_desc", "created_at_desc", "id_desc"], + }, + }); + listMemoryReviewQueueMock.mockRejectedValue(new Error("queue down")); + getMemoryEvaluationSummaryMock.mockRejectedValue(new Error("summary down")); + getMemoryTrustDashboardMock.mockRejectedValue(new Error("trust dashboard down")); + listOpenLoopsMock.mockRejectedValue(new Error("open loops down")); + getMemoryDetailMock.mockRejectedValue(new Error("detail down")); + getMemoryRevisionsMock.mockRejectedValue(new Error("revisions down")); + listMemoryLabelsMock.mockRejectedValue(new Error("labels down")); + + render( + await MemoriesPage({ + searchParams: Promise.resolve({ + memory: "memory-live-missing", + }), + }), + ); + + expect(screen.getByText("Summary: summary down")).toBeInTheDocument(); + expect(screen.getByText(/trust dashboard down/)).toBeInTheDocument(); + expect(screen.getByText("Queue: queue down")).toBeInTheDocument(); + expect(screen.getByText(/open loops down/)).toBeInTheDocument(); + expect(screen.getAllByText("Insufficient sample").length).toBeGreaterThan(0); + expect(screen.getByText("Detail read")).toBeInTheDocument(); + expect(screen.getByText("detail down")).toBeInTheDocument(); + expect(screen.getByText("Revisions unavailable")).toBeInTheDocument(); + expect(screen.getByText("revisions down")).toBeInTheDocument(); + expect(screen.getByText("Labels unavailable")).toBeInTheDocument(); + expect(screen.getByText("labels down")).toBeInTheDocument(); + }); + + it("shows queue submit-and-next action only when queue mode has a deterministic next item", async () => { + render( + await MemoriesPage({ + searchParams: Promise.resolve({ + filter: "queue", + }), + }), + ); + + expect(screen.getByRole("button", { name: "Submit review label" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Submit and next in queue" })).toBeInTheDocument(); + }); + + it("hides queue submit-and-next action when selected queue item is last in current order", async () => { + render( + await MemoriesPage({ + searchParams: Promise.resolve({ + filter: "queue", + memory: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa3", + }), + }), + ); + + expect(screen.getByRole("button", { name: "Submit review label" })).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Submit and next in queue" }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/memories/page.tsx b/apps/web/app/memories/page.tsx new file mode 100644 index 0000000..5ac1a97 --- /dev/null +++ b/apps/web/app/memories/page.tsx @@ -0,0 +1,780 @@ +import { MemoryDetail } from "../../components/memory-detail"; +import { MemoryLabelForm } from "../../components/memory-label-form"; +import { MemoryLabelList } from "../../components/memory-label-list"; +import { MemoryList } from "../../components/memory-list"; +import { MemoryRevisionList } from "../../components/memory-revision-list"; +import { MemorySummary } from "../../components/memory-summary"; +import { PageHeader } from "../../components/page-header"; +import type { + ApiSource, + MemoryReviewLabelSummary, + MemoryReviewQueuePriorityMode, + MemoryReviewRecord, + MemoryTrustDashboardSummary, + MemoryRevisionReviewListSummary, + OpenLoopListSummary, + OpenLoopRecord, +} from "../../lib/api"; +import { + combinePageModes, + getOpenLoopDetail, + getApiConfig, + getMemoryDetail, + getMemoryEvaluationSummary, + getMemoryTrustDashboard, + getMemoryRevisions, + hasLiveApiConfig, + listOpenLoops, + listMemories, + listMemoryLabels, + listMemoryReviewQueue, + pageModeLabel, +} from "../../lib/api"; +import { + getFixtureMemory, + getFixtureMemoryLabelSummary, + getFixtureMemoryLabels, + getFixtureMemoryRevisionSummary, + getFixtureMemoryRevisions, + memoryEvaluationSummaryFixture, + memoryFixtures, + memoryLabelFixtures, + memoryReviewListSummaryFixture, + memoryReviewQueueFixtures, + memoryReviewQueueSummaryFixture, +} from "../../lib/fixtures"; + +type SearchParams = Promise<Record<string, string | string[] | undefined>>; + +function normalizeParam(value: string | string[] | undefined) { + if (Array.isArray(value)) { + return normalizeParam(value[0]); + } + + return value?.trim() ?? ""; +} + +function normalizeFilter(value: string | string[] | undefined): "active" | "queue" { + const normalized = normalizeParam(value).toLowerCase(); + return normalized === "queue" ? "queue" : "active"; +} + +function normalizeQueuePriorityMode( + value: string | string[] | undefined, +): MemoryReviewQueuePriorityMode { + const normalized = normalizeParam(value).toLowerCase(); + if ( + normalized === "oldest_first" || + normalized === "recent_first" || + normalized === "high_risk_first" || + normalized === "stale_truth_first" + ) { + return normalized; + } + return "recent_first"; +} + +function resolveSelectedMemoryId(requestedMemoryId: string, items: MemoryReviewRecord[]) { + if (!items.length) { + return ""; + } + + const availableIds = new Set(items.map((item) => item.id)); + if (requestedMemoryId && availableIds.has(requestedMemoryId)) { + return requestedMemoryId; + } + + return items[0]?.id ?? ""; +} + +function queueItemAsMemory(item: { + id: string; + memory_key: string; + value: unknown; + status: "active"; + source_event_ids: string[]; + memory_type: MemoryReviewRecord["memory_type"]; + confidence: MemoryReviewRecord["confidence"]; + salience: MemoryReviewRecord["salience"]; + confirmation_status: MemoryReviewRecord["confirmation_status"]; + valid_from: MemoryReviewRecord["valid_from"]; + valid_to: MemoryReviewRecord["valid_to"]; + last_confirmed_at: MemoryReviewRecord["last_confirmed_at"]; + created_at: string; + updated_at: string; +}): MemoryReviewRecord { + return { + ...item, + deleted_at: null, + }; +} + +function formatTypedValue(value: string | null | undefined) { + if (value == null) { + return "Not set"; + } + return value; +} + +function formatTypedScore(value: number | null | undefined) { + if (value == null) { + return "Not set"; + } + return value.toFixed(2); +} + +function formatTypedTimestamp(value: string | null | undefined) { + return value ?? "Not set"; +} + +function formatPercent(value: number | null | undefined) { + if (value == null) { + return "Not available"; + } + return `${(value * 100).toFixed(1)}%`; +} + +function formatHours(value: number) { + return `${value.toFixed(1)}h`; +} + +const trustDashboardFixture: MemoryTrustDashboardSummary = { + quality_gate: { + status: "insufficient_sample", + precision: null, + precision_target: 0.8, + adjudicated_sample_count: 2, + minimum_adjudicated_sample: 10, + remaining_to_minimum_sample: 8, + unlabeled_memory_count: 2, + high_risk_memory_count: 2, + stale_truth_count: 1, + superseded_active_conflict_count: 0, + counts: { + active_memory_count: 4, + labeled_active_memory_count: 2, + adjudicated_correct_count: 2, + adjudicated_incorrect_count: 0, + outdated_label_count: 0, + insufficient_evidence_label_count: 0, + }, + }, + queue_posture: { + priority_mode: "recent_first", + total_count: 2, + high_risk_count: 2, + stale_truth_count: 1, + priority_reason_counts: { + recent_first: 2, + }, + order: ["updated_at_desc", "created_at_desc", "id_desc"], + aging: { + anchor_updated_at: "2026-03-23T09:00:00Z", + newest_updated_at: "2026-03-23T09:00:00Z", + oldest_updated_at: "2026-03-20T09:00:00Z", + backlog_span_hours: 72, + fresh_within_24h_count: 1, + aging_24h_to_72h_count: 1, + stale_over_72h_count: 0, + }, + }, + retrieval_quality: { + fixture_count: 3, + evaluated_fixture_count: 3, + passing_fixture_count: 3, + precision_at_k_mean: 1, + precision_at_1_mean: 1, + precision_target: 0.8, + status: "pass", + fixture_order: ["fixture_id_asc"], + result_order: ["precision_at_k_desc", "fixture_id_asc"], + }, + correction_freshness: { + total_open_loop_count: 2, + stale_open_loop_count: 0, + correction_recurrence_count: 0, + freshness_drift_count: 0, + }, + recommended_review: { + priority_mode: "recent_first", + action: "adjudicate_minimum_sample", + reason: "Adjudicated sample remains below minimum threshold.", + }, + sources: [ + "memories", + "memory_review_labels", + "continuity_recall", + "continuity_correction_events", + "retrieval_evaluation_fixtures", + ], +}; + +const openLoopFixtures: OpenLoopRecord[] = [ + { + id: "loop-fixture-1", + memory_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2", + title: "Confirm magnesium package size before reorder", + status: "open", + opened_at: "2026-03-20T09:00:00Z", + due_at: "2026-03-24T09:00:00Z", + resolved_at: null, + resolution_note: null, + created_at: "2026-03-20T09:00:00Z", + updated_at: "2026-03-20T09:00:00Z", + }, + { + id: "loop-fixture-2", + memory_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1", + title: "Verify merchant preference after next approval", + status: "open", + opened_at: "2026-03-19T09:00:00Z", + due_at: null, + resolved_at: null, + resolution_note: null, + created_at: "2026-03-19T09:00:00Z", + updated_at: "2026-03-19T09:00:00Z", + }, +]; + +const openLoopSummaryFixture: OpenLoopListSummary = { + status: "open", + limit: 20, + returned_count: openLoopFixtures.length, + total_count: openLoopFixtures.length, + has_more: false, + order: ["opened_at_desc", "created_at_desc", "id_desc"], +}; + +function resolveSelectedOpenLoopId(requestedOpenLoopId: string, items: OpenLoopRecord[]) { + if (!items.length) { + return ""; + } + + const availableIds = new Set(items.map((item) => item.id)); + if (requestedOpenLoopId && availableIds.has(requestedOpenLoopId)) { + return requestedOpenLoopId; + } + + return items[0]?.id ?? ""; +} + +export default async function MemoriesPage({ + searchParams, +}: { + searchParams?: SearchParams; +}) { + const params = (searchParams ? await searchParams : {}) as Record< + string, + string | string[] | undefined + >; + const requestedMemoryId = normalizeParam(params.memory); + const requestedOpenLoopId = normalizeParam(params.open_loop); + const activeFilter = normalizeFilter(params.filter); + const queuePriorityMode = normalizeQueuePriorityMode(params.priority_mode); + const apiConfig = getApiConfig(); + const liveModeReady = hasLiveApiConfig(apiConfig); + + let memories = memoryFixtures; + let memoryListSummary = memoryReviewListSummaryFixture; + let memoryListSource: ApiSource = "fixture"; + let memoryListUnavailableReason: string | undefined; + + let reviewQueue = memoryReviewQueueFixtures; + let reviewQueueSummary = memoryReviewQueueSummaryFixture; + let reviewQueueSource: ApiSource = "fixture"; + let reviewQueueUnavailableReason: string | undefined; + + let evaluationSummary = memoryEvaluationSummaryFixture; + let evaluationSummarySource: ApiSource = "fixture"; + let evaluationSummaryUnavailableReason: string | undefined; + + let trustDashboard = trustDashboardFixture; + let trustDashboardSource: ApiSource = "fixture"; + let trustDashboardUnavailableReason: string | undefined; + + let openLoops = openLoopFixtures; + let openLoopSummary = openLoopSummaryFixture; + let openLoopSource: ApiSource = "fixture"; + let openLoopUnavailableReason: string | undefined; + + if (liveModeReady) { + const [memoryResult, queueResult, summaryResult, trustDashboardResult, openLoopResult] = + await Promise.allSettled([ + listMemories(apiConfig.apiBaseUrl, apiConfig.userId, { status: "active" }), + listMemoryReviewQueue(apiConfig.apiBaseUrl, apiConfig.userId, { + priorityMode: queuePriorityMode, + }), + getMemoryEvaluationSummary(apiConfig.apiBaseUrl, apiConfig.userId), + getMemoryTrustDashboard(apiConfig.apiBaseUrl, apiConfig.userId), + listOpenLoops(apiConfig.apiBaseUrl, apiConfig.userId, { status: "open", limit: 20 }), + ]); + + if (memoryResult.status === "fulfilled") { + memories = memoryResult.value.items; + memoryListSummary = memoryResult.value.summary; + memoryListSource = "live"; + } else { + memoryListUnavailableReason = + memoryResult.reason instanceof Error + ? memoryResult.reason.message + : "Memory list could not be loaded."; + } + + if (queueResult.status === "fulfilled") { + reviewQueue = queueResult.value.items; + reviewQueueSummary = queueResult.value.summary; + reviewQueueSource = "live"; + } else { + reviewQueueUnavailableReason = + queueResult.reason instanceof Error + ? queueResult.reason.message + : "Memory review queue could not be loaded."; + } + + if (summaryResult.status === "fulfilled") { + evaluationSummary = summaryResult.value.summary; + evaluationSummarySource = "live"; + } else { + evaluationSummaryUnavailableReason = + summaryResult.reason instanceof Error + ? summaryResult.reason.message + : "Memory evaluation summary could not be loaded."; + } + + if (trustDashboardResult.status === "fulfilled") { + trustDashboard = trustDashboardResult.value.dashboard; + trustDashboardSource = "live"; + } else { + trustDashboardUnavailableReason = + trustDashboardResult.reason instanceof Error + ? trustDashboardResult.reason.message + : "Memory trust dashboard could not be loaded."; + } + + if (openLoopResult.status === "fulfilled") { + openLoops = openLoopResult.value.items; + openLoopSummary = openLoopResult.value.summary; + openLoopSource = "live"; + } else { + openLoopUnavailableReason = + openLoopResult.reason instanceof Error + ? openLoopResult.reason.message + : "Open-loop list could not be loaded."; + } + } + + const visibleMemories = + activeFilter === "queue" ? reviewQueue.map((item) => queueItemAsMemory(item)) : memories; + const selectedMemoryId = resolveSelectedMemoryId(requestedMemoryId, visibleMemories); + const selectedFromVisibleList = visibleMemories.find((item) => item.id === selectedMemoryId) ?? null; + const selectedListSource = activeFilter === "queue" ? reviewQueueSource : memoryListSource; + const selectedQueueIndex = + activeFilter === "queue" && selectedMemoryId + ? visibleMemories.findIndex((item) => item.id === selectedMemoryId) + : -1; + const nextQueueMemoryId = + selectedQueueIndex >= 0 ? visibleMemories[selectedQueueIndex + 1]?.id ?? null : null; + + let selectedMemory = selectedFromVisibleList; + let selectedMemorySource: ApiSource | null = selectedMemory ? selectedListSource : null; + let selectedMemoryUnavailableReason: string | undefined; + + if (selectedFromVisibleList && liveModeReady && selectedListSource === "live") { + try { + const payload = await getMemoryDetail( + apiConfig.apiBaseUrl, + selectedFromVisibleList.id, + apiConfig.userId, + ); + selectedMemory = payload.memory; + selectedMemorySource = "live"; + } catch (error) { + const fixtureMemory = getFixtureMemory(selectedFromVisibleList.id); + if (fixtureMemory) { + selectedMemory = fixtureMemory; + selectedMemorySource = "fixture"; + } + selectedMemoryUnavailableReason = + error instanceof Error ? error.message : "Selected memory detail could not be loaded."; + } + } + + const selectedOpenLoopId = resolveSelectedOpenLoopId(requestedOpenLoopId, openLoops); + const selectedOpenLoopFromList = openLoops.find((item) => item.id === selectedOpenLoopId) ?? null; + let selectedOpenLoop = selectedOpenLoopFromList; + let selectedOpenLoopSource: ApiSource | null = selectedOpenLoop ? openLoopSource : null; + let selectedOpenLoopUnavailableReason: string | undefined; + + if (selectedOpenLoopFromList && liveModeReady && openLoopSource === "live") { + try { + const payload = await getOpenLoopDetail( + apiConfig.apiBaseUrl, + selectedOpenLoopFromList.id, + apiConfig.userId, + ); + selectedOpenLoop = payload.open_loop; + selectedOpenLoopSource = "live"; + } catch (error) { + const fixtureOpenLoop = openLoopFixtures.find((item) => item.id === selectedOpenLoopFromList.id); + if (fixtureOpenLoop) { + selectedOpenLoop = fixtureOpenLoop; + selectedOpenLoopSource = "fixture"; + } else { + selectedOpenLoop = null; + selectedOpenLoopSource = null; + } + selectedOpenLoopUnavailableReason = + error instanceof Error ? error.message : "Selected open loop could not be loaded."; + } + } + + let revisions = selectedMemory ? getFixtureMemoryRevisions(selectedMemory.id) : []; + let revisionSummary: MemoryRevisionReviewListSummary | null = selectedMemory + ? getFixtureMemoryRevisionSummary(selectedMemory.id) + : null; + let revisionSource: ApiSource | "unavailable" | null = selectedMemory ? "fixture" : null; + let revisionUnavailableReason: string | undefined; + + if (selectedMemory && liveModeReady && selectedMemorySource === "live") { + try { + const payload = await getMemoryRevisions(apiConfig.apiBaseUrl, selectedMemory.id, apiConfig.userId); + revisions = payload.items; + revisionSummary = payload.summary; + revisionSource = "live"; + } catch (error) { + const fixtureRevisions = getFixtureMemoryRevisions(selectedMemory.id); + if (fixtureRevisions.length > 0) { + revisions = fixtureRevisions; + revisionSummary = getFixtureMemoryRevisionSummary(selectedMemory.id); + revisionSource = "fixture"; + } else { + revisions = []; + revisionSummary = null; + revisionSource = "unavailable"; + } + revisionUnavailableReason = + error instanceof Error ? error.message : "Revision history could not be loaded."; + } + } + + let labels = selectedMemory ? getFixtureMemoryLabels(selectedMemory.id) : []; + let labelSummary: MemoryReviewLabelSummary | null = selectedMemory + ? getFixtureMemoryLabelSummary(selectedMemory.id) + : null; + let labelSource: ApiSource | "unavailable" | null = selectedMemory ? "fixture" : null; + let labelUnavailableReason: string | undefined; + + if (selectedMemory && liveModeReady && selectedMemorySource === "live") { + try { + const payload = await listMemoryLabels(apiConfig.apiBaseUrl, selectedMemory.id, apiConfig.userId); + labels = payload.items; + labelSummary = payload.summary; + labelSource = "live"; + } catch (error) { + const fixtureLabels = memoryLabelFixtures[selectedMemory.id]; + if (fixtureLabels) { + labels = fixtureLabels; + labelSummary = getFixtureMemoryLabelSummary(selectedMemory.id); + labelSource = "fixture"; + } else { + labels = []; + labelSummary = getFixtureMemoryLabelSummary(selectedMemory.id); + labelSource = "unavailable"; + } + labelUnavailableReason = + error instanceof Error ? error.message : "Memory labels could not be loaded."; + } + } + + const pageMode = combinePageModes( + memoryListSource, + reviewQueueSource, + evaluationSummarySource, + trustDashboardSource, + openLoopSource, + selectedMemorySource, + selectedOpenLoopSource, + revisionSource === "unavailable" ? null : revisionSource, + labelSource === "unavailable" ? null : labelSource, + ); + + return ( + <div className="page-stack"> + <PageHeader + eyebrow="Memories" + title="Memory review workspace" + description="Review memory evaluation posture first, inspect one selected memory second, and apply labels only after value and revisions are clear." + meta={ + <div className="header-meta"> + <span className="subtle-chip">{pageModeLabel(pageMode)}</span> + <span className="subtle-chip"> + {activeFilter === "queue" ? "Queue filter active" : "Active list filter"} + </span> + {activeFilter === "queue" ? ( + <span className="subtle-chip">Priority: {queuePriorityMode}</span> + ) : null} + <span className="subtle-chip">{visibleMemories.length} visible memories</span> + <span className="subtle-chip">{openLoops.length} open loops</span> + </div> + } + /> + + <MemorySummary + summary={evaluationSummary} + summarySource={evaluationSummarySource} + summaryUnavailableReason={evaluationSummaryUnavailableReason} + queueSummary={reviewQueueSummary} + queueSource={reviewQueueSource} + queueUnavailableReason={reviewQueueUnavailableReason} + activeFilter={activeFilter} + /> + + <div className="section-card"> + <header className="section-card__header"> + <div> + <p className="section-card__eyebrow">Trust dashboard</p> + <h2 className="section-card__title">Canonical quality posture</h2> + </div> + <p className="section-card__description"> + One deterministic view for gate posture, queue aging, retrieval quality, correction + recurrence, and recommended next review action. + </p> + </header> + <div className="stack"> + <p className="muted-copy"> + Source: {trustDashboardSource === "live" ? "Live dashboard" : "Fixture dashboard"} + {trustDashboardUnavailableReason ? ` · ${trustDashboardUnavailableReason}` : ""} + </p> + <dl className="key-value-grid key-value-grid--compact"> + <div> + <dt>Gate status</dt> + <dd>{trustDashboard.quality_gate.status}</dd> + </div> + <div> + <dt>Precision</dt> + <dd>{formatPercent(trustDashboard.quality_gate.precision)}</dd> + </div> + <div> + <dt>Queue total</dt> + <dd>{trustDashboard.queue_posture.total_count}</dd> + </div> + <div> + <dt>Queue high risk</dt> + <dd>{trustDashboard.queue_posture.high_risk_count}</dd> + </div> + <div> + <dt>Queue stale truth</dt> + <dd>{trustDashboard.queue_posture.stale_truth_count}</dd> + </div> + <div> + <dt>Queue span</dt> + <dd>{formatHours(trustDashboard.queue_posture.aging.backlog_span_hours)}</dd> + </div> + <div> + <dt>Retrieval status</dt> + <dd>{trustDashboard.retrieval_quality.status}</dd> + </div> + <div> + <dt>Retrieval precision mean</dt> + <dd>{formatPercent(trustDashboard.retrieval_quality.precision_at_k_mean)}</dd> + </div> + <div> + <dt>Correction recurrence</dt> + <dd>{trustDashboard.correction_freshness.correction_recurrence_count}</dd> + </div> + <div> + <dt>Freshness drift</dt> + <dd>{trustDashboard.correction_freshness.freshness_drift_count}</dd> + </div> + <div> + <dt>Recommended mode</dt> + <dd>{trustDashboard.recommended_review.priority_mode}</dd> + </div> + <div> + <dt>Recommended action</dt> + <dd>{trustDashboard.recommended_review.action}</dd> + </div> + </dl> + <p className="muted-copy">{trustDashboard.recommended_review.reason}</p> + </div> + </div> + + <div className="section-card"> + <header className="section-card__header"> + <div> + <p className="section-card__eyebrow">Open-loop backbone</p> + <h2 className="section-card__title">Unresolved commitment review</h2> + </div> + <p className="section-card__description"> + Review unresolved loops with deterministic ordering and inspect one selected loop in detail. + </p> + </header> + + <div className="stack"> + <p className="muted-copy"> + Source: {openLoopSource === "live" ? "Live list" : "Fixture list"} + {openLoopUnavailableReason ? ` · ${openLoopUnavailableReason}` : ""} + </p> + <p className="muted-copy"> + {openLoopSummary.returned_count} open loops shown of {openLoopSummary.total_count} total + </p> + {openLoops.length ? ( + <ul className="stack"> + {openLoops.map((openLoop) => { + const selected = selectedOpenLoop?.id === openLoop.id; + const hrefParts = [ + `/memories?open_loop=${encodeURIComponent(openLoop.id)}`, + selectedMemory?.id + ? `memory=${encodeURIComponent(selectedMemory.id)}` + : null, + activeFilter === "queue" ? "filter=queue" : null, + activeFilter === "queue" + ? `priority_mode=${encodeURIComponent(queuePriorityMode)}` + : null, + ].filter(Boolean); + const href = hrefParts.length > 1 ? `${hrefParts[0]}&${hrefParts.slice(1).join("&")}` : hrefParts[0]; + return ( + <li key={openLoop.id}> + <a href={href} aria-current={selected ? "page" : undefined}> + {openLoop.title} + </a> + </li> + ); + })} + </ul> + ) : ( + <p className="muted-copy">No open loops are currently available.</p> + )} + + {selectedOpenLoop ? ( + <dl className="key-value-grid key-value-grid--compact"> + <div> + <dt>Status</dt> + <dd>{selectedOpenLoop.status}</dd> + </div> + <div> + <dt>Memory link</dt> + <dd className="mono">{selectedOpenLoop.memory_id ?? "Not linked"}</dd> + </div> + <div> + <dt>Opened at</dt> + <dd className="mono">{formatTypedTimestamp(selectedOpenLoop.opened_at)}</dd> + </div> + <div> + <dt>Due at</dt> + <dd className="mono">{formatTypedTimestamp(selectedOpenLoop.due_at)}</dd> + </div> + <div> + <dt>Resolved at</dt> + <dd className="mono">{formatTypedTimestamp(selectedOpenLoop.resolved_at)}</dd> + </div> + <div> + <dt>Resolution note</dt> + <dd>{selectedOpenLoop.resolution_note ?? "Not set"}</dd> + </div> + </dl> + ) : ( + <p className="muted-copy"> + {selectedOpenLoopUnavailableReason ?? "Select an open loop to inspect its detail fields."} + </p> + )} + </div> + </div> + + <div className="memory-layout"> + <MemoryList + memories={visibleMemories} + selectedMemoryId={selectedMemory?.id} + summary={activeFilter === "queue" ? null : memoryListSummary} + source={selectedListSource} + filter={activeFilter} + priorityMode={activeFilter === "queue" ? queuePriorityMode : undefined} + availablePriorityModes={ + activeFilter === "queue" ? reviewQueueSummary.available_priority_modes : undefined + } + unavailableReason={activeFilter === "queue" ? reviewQueueUnavailableReason : memoryListUnavailableReason} + /> + <MemoryDetail + memory={selectedMemory} + source={selectedMemorySource} + unavailableReason={selectedMemoryUnavailableReason} + /> + </div> + + <div className="section-card"> + <header className="section-card__header"> + <div> + <p className="section-card__eyebrow">Typed metadata</p> + <h2 className="section-card__title">Memory classification and confidence</h2> + </div> + <p className="section-card__description"> + Typed metadata remains visible in the review workspace with explicit safe fallbacks. + </p> + </header> + {selectedMemory ? ( + <dl className="key-value-grid key-value-grid--compact"> + <div> + <dt>Type</dt> + <dd>{formatTypedValue(selectedMemory.memory_type)}</dd> + </div> + <div> + <dt>Confirmation</dt> + <dd>{formatTypedValue(selectedMemory.confirmation_status)}</dd> + </div> + <div> + <dt>Confidence</dt> + <dd>{formatTypedScore(selectedMemory.confidence)}</dd> + </div> + <div> + <dt>Salience</dt> + <dd>{formatTypedScore(selectedMemory.salience)}</dd> + </div> + <div> + <dt>Valid from</dt> + <dd className="mono">{formatTypedTimestamp(selectedMemory.valid_from)}</dd> + </div> + <div> + <dt>Valid to</dt> + <dd className="mono">{formatTypedTimestamp(selectedMemory.valid_to)}</dd> + </div> + <div> + <dt>Last confirmed</dt> + <dd className="mono">{formatTypedTimestamp(selectedMemory.last_confirmed_at)}</dd> + </div> + </dl> + ) : ( + <p className="muted-copy">Select a memory to inspect typed metadata fields.</p> + )} + </div> + + <div className="memory-followup-grid"> + <MemoryRevisionList + memoryId={selectedMemory?.id ?? null} + revisions={revisions} + summary={revisionSummary} + source={revisionSource} + unavailableReason={revisionUnavailableReason} + /> + + <div className="stack"> + <MemoryLabelList + memoryId={selectedMemory?.id ?? null} + labels={labels} + summary={labelSummary} + source={labelSource} + unavailableReason={labelUnavailableReason} + /> + <MemoryLabelForm + memoryId={selectedMemory?.id ?? null} + source={selectedMemorySource} + apiBaseUrl={apiConfig.apiBaseUrl} + userId={apiConfig.userId} + activeFilter={activeFilter} + nextQueueMemoryId={nextQueueMemoryId} + queuePriorityMode={activeFilter === "queue" ? queuePriorityMode : undefined} + /> + </div> + </div> + </div> + ); +} diff --git a/apps/web/app/onboarding/page.test.tsx b/apps/web/app/onboarding/page.test.tsx new file mode 100644 index 0000000..e95426f --- /dev/null +++ b/apps/web/app/onboarding/page.test.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import OnboardingPage from "./page"; + +describe("OnboardingPage", () => { + afterEach(() => { + cleanup(); + }); + + it("renders hosted onboarding scope and guards Telegram claims", () => { + render(<OnboardingPage />); + + expect(screen.getByText("Hosted Onboarding")).toBeInTheDocument(); + expect(screen.getByText("Magic-link Identity")).toBeInTheDocument(); + expect(screen.getByText(/not available in P10-S1/i)).toBeInTheDocument(); + expect(screen.getByText(/readiness only/i)).toBeInTheDocument(); + expect(screen.getByText("Onboarding Failure Visibility")).toBeInTheDocument(); + expect(screen.getByText(/inspect hosted admin incidents/i)).toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/onboarding/page.tsx b/apps/web/app/onboarding/page.tsx new file mode 100644 index 0000000..49ee1d8 --- /dev/null +++ b/apps/web/app/onboarding/page.tsx @@ -0,0 +1,15 @@ +import { HostedOnboardingPanel } from "../../components/hosted-onboarding-panel"; +import { PageHeader } from "../../components/page-header"; + +export default function OnboardingPage() { + return ( + <div className="page-stack"> + <PageHeader + eyebrow="Phase 10 Sprint 1" + title="Hosted Onboarding" + description="Bootstrap hosted identity, workspace setup, and deterministic device trust without expanding into Telegram flows." + /> + <HostedOnboardingPanel /> + </div> + ); +} diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx new file mode 100644 index 0000000..0649e53 --- /dev/null +++ b/apps/web/app/page.tsx @@ -0,0 +1,201 @@ +import Link from "next/link"; + +import { PageHeader } from "../components/page-header"; +import { SectionCard } from "../components/section-card"; +import { StatusBadge } from "../components/status-badge"; + +const summaryCards = [ + { + value: "13", + label: "Operator views", + detail: + "Home, hosted onboarding/settings/admin, request composition, approvals, task inspection, artifact review, Gmail review, Calendar review, memory review, entity review, and explainability are all exposed in one bounded shell.", + }, + { + value: "8", + label: "Governance seams", + detail: + "Requests, approvals, tasks, artifact review, Gmail account/ingestion seams, Calendar account/ingestion seams, memory review, and entity review stay visible instead of being hidden behind a consumer chat wrapper.", + }, + { + value: "2", + label: "Data modes", + detail: "Pages can read live backend seams when configured and degrade to explicit fixtures when no API contract is present.", + }, + { + value: "100%", + label: "Scoped surface", + detail: "The shell stays within the sprint packet: no auth expansion, no connector breadth, and no backend contract changes.", + }, +]; + +const routeCards = [ + { + href: "/onboarding", + title: "Hosted Onboarding", + description: + "Sign in by magic link, create/bootstrap a workspace, and confirm readiness for later Telegram linkage.", + status: "active", + }, + { + href: "/settings", + title: "Hosted Settings", + description: + "Persist Telegram notification posture, quiet hours, daily brief delivery state, and scheduler job visibility.", + status: "active", + }, + { + href: "/admin", + title: "Hosted Admin", + description: + "Inspect hosted workspace posture, delivery receipts, incidents, rollout flags, analytics, and rate-limit evidence.", + status: "active", + }, + { + href: "/chat", + title: "Governed Requests", + description: "Compose bounded operator requests, review response history, and keep compilation and response traces visible.", + status: "active", + }, + { + href: "/approvals", + title: "Approval Inbox", + description: "Review pending approvals with tool, scope, routing, and rationale all contained in a stable inspector layout.", + status: "pending_approval", + }, + { + href: "/tasks", + title: "Task Inspection", + description: "Inspect task lifecycle state, related governed requests, and ordered task-step progress without leaving the shell.", + status: "approved", + }, + { + href: "/artifacts", + title: "Artifact Review", + description: "Inspect persisted artifacts, selected detail, linked task workspace metadata, and ordered chunk evidence.", + status: "ingested", + }, + { + href: "/gmail", + title: "Gmail Review", + description: + "Review connected Gmail accounts, inspect one selected account, and explicitly ingest one selected message into a task workspace.", + status: "active", + }, + { + href: "/calendar", + title: "Calendar Review", + description: + "Review connected Calendar accounts, inspect one selected account, and explicitly ingest one selected event into a task workspace.", + status: "active", + }, + { + href: "/memories", + title: "Memory Review", + description: "Inspect active memory records, revisions, and review labels through the shipped memory-review seam.", + status: "requires_review", + }, + { + href: "/entities", + title: "Entity Review", + description: "Inspect tracked entities, selected entity detail, and related edges through the shipped entity-review seam.", + status: "active", + }, + { + href: "/traces", + title: "Explain-Why Review", + description: "Trace context compilation and governed actions through a calm evidence-first review surface.", + status: "executed", + }, +]; + +const shellNotes = [ + "Stable navigation with obvious current location and restrained emphasis.", + "Cards and lists sized for readable density rather than dashboard clutter.", + "Responsive stacking that protects text containment on tablet and mobile widths.", + "Clear OSS-versus-hosted boundary: Alice Core remains local-first while Alice Connect beta operations stay explicit.", +]; + +export default function HomePage() { + return ( + <div className="page-stack"> + <PageHeader + eyebrow="AliceBot" + title="Operator shell for governed work" + description="The first web surface is intentionally narrow: it exposes existing backend seams with calm hierarchy, strong containment, and clear review paths." + meta={ + <div className="header-meta"> + <span className="subtle-chip">Sprint 6V shell</span> + <span className="subtle-chip">Design-system aligned</span> + </div> + } + /> + + <section className="metric-grid" aria-label="Shell summary"> + {summaryCards.map((card) => ( + <SectionCard key={card.label} className="section-card--metric"> + <div className="metric-value">{card.value}</div> + <div className="metric-label">{card.label}</div> + <p className="metric-detail">{card.detail}</p> + </SectionCard> + ))} + </section> + + <div className="content-grid content-grid--wide"> + <SectionCard + eyebrow="Core views" + title="Primary operator surfaces" + description="Each route is deliberately narrow, with one clear purpose and predictable visual rhythm." + > + <div className="route-grid"> + {routeCards.map((route) => ( + <Link key={route.href} href={route.href} className="nav-card"> + <div className="nav-card__topline"> + <h3>{route.title}</h3> + <StatusBadge status={route.status} /> + </div> + <p>{route.description}</p> + <span className="nav-card__cta">Open view</span> + </Link> + ))} + </div> + </SectionCard> + + <div className="stack"> + <SectionCard + eyebrow="UI priorities" + title="What this shell optimizes for" + description="The interface favors trust, clarity, and reviewability before throughput." + > + <ul className="bullet-list"> + {shellNotes.map((note) => ( + <li key={note}>{note}</li> + ))} + </ul> + </SectionCard> + + <SectionCard + eyebrow="System posture" + title="Governed by default" + description="The landing view frames the product around visible control points rather than hidden automation." + > + <dl className="key-value-grid"> + <div> + <dt>Request path</dt> + <dd>Explicitly labeled as governed and reviewable.</dd> + </div> + <div> + <dt>Consequential actions</dt> + <dd>Held behind approval and execution review states.</dd> + </div> + <div> + <dt>Explainability</dt> + <dd>Trace review sits beside operational work, not in a debug-only corner.</dd> + </div> + </dl> + </SectionCard> + </div> + </div> + </div> + ); +} diff --git a/apps/web/app/settings/page.test.tsx b/apps/web/app/settings/page.test.tsx new file mode 100644 index 0000000..5f753f1 --- /dev/null +++ b/apps/web/app/settings/page.test.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import SettingsPage from "./page"; + +describe("SettingsPage", () => { + afterEach(() => { + cleanup(); + }); + + it("renders telegram channel settings and preserves continuity boundary claims", () => { + render(<SettingsPage />); + + expect(screen.getByRole("heading", { level: 1, name: "Hosted Settings" })).toBeInTheDocument(); + expect(screen.getByText("Telegram Channel Settings")).toBeInTheDocument(); + expect( + screen.getByText("Issue a deterministic link challenge bound to the active hosted workspace."), + ).toBeInTheDocument(); + expect(screen.getByText(/does not claim beta admin dashboards/i)).toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/settings/page.tsx b/apps/web/app/settings/page.tsx new file mode 100644 index 0000000..d7f7ae7 --- /dev/null +++ b/apps/web/app/settings/page.tsx @@ -0,0 +1,15 @@ +import { PageHeader } from "../../components/page-header"; +import { HostedSettingsPanel } from "../../components/hosted-settings-panel"; + +export default function SettingsPage() { + return ( + <div className="page-stack"> + <PageHeader + eyebrow="Phase 10 Sprint 4" + title="Hosted Settings" + description="Manage Telegram link/unlink, notification preferences, daily brief delivery, open-loop prompts, and scheduler posture." + /> + <HostedSettingsPanel /> + </div> + ); +} diff --git a/apps/web/app/tasks/loading.tsx b/apps/web/app/tasks/loading.tsx new file mode 100644 index 0000000..a872d17 --- /dev/null +++ b/apps/web/app/tasks/loading.tsx @@ -0,0 +1,65 @@ +import { PageHeader } from "../../components/page-header"; +import { SectionCard } from "../../components/section-card"; +import { StatusBadge } from "../../components/status-badge"; + +export default function Loading() { + return ( + <div className="page-stack" aria-busy="true"> + <PageHeader + eyebrow="Tasks" + title="Task lifecycle inspection" + description="Loading live task records and the selected task-step timeline." + meta={ + <div className="header-meta"> + <span className="subtle-chip">Loading route state</span> + </div> + } + /> + + <div className="dashboard-grid dashboard-grid--detail"> + <SectionCard + eyebrow="Task list" + title="Loading tasks" + description="The governed task list is being read from the current backing source." + className="loading-card" + > + <div className="detail-stack"> + <StatusBadge status="loading" label="Loading" /> + <div className="loading-placeholder loading-placeholder--card" /> + <div className="loading-placeholder loading-placeholder--card" /> + <div className="loading-placeholder loading-placeholder--card" /> + </div> + </SectionCard> + + <div className="stack"> + <SectionCard + eyebrow="Selected task" + title="Loading selected task" + description="Task summary state appears here as soon as the selected task detail resolves." + className="loading-card" + > + <div className="detail-stack"> + <StatusBadge status="loading" label="Loading" /> + <div className="loading-placeholder loading-placeholder--line loading-placeholder--wide" /> + <div className="loading-placeholder loading-placeholder--line" /> + <div className="loading-placeholder loading-placeholder--button" /> + </div> + </SectionCard> + + <SectionCard + eyebrow="Task steps" + title="Loading task timeline" + description="Ordered task-step detail is loading from the selected task." + className="loading-card" + > + <div className="detail-stack"> + <StatusBadge status="loading" label="Loading" /> + <div className="loading-placeholder loading-placeholder--card" /> + <div className="loading-placeholder loading-placeholder--card" /> + </div> + </SectionCard> + </div> + </div> + </div> + ); +} diff --git a/apps/web/app/tasks/page.test.tsx b/apps/web/app/tasks/page.test.tsx new file mode 100644 index 0000000..72da318 --- /dev/null +++ b/apps/web/app/tasks/page.test.tsx @@ -0,0 +1,197 @@ +import React from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import TasksPage from "./page"; +import { taskFixtures } from "../../lib/fixtures"; + +const { + getApiConfigMock, + getTaskDetailMock, + getTaskStepsMock, + getToolExecutionMock, + hasLiveApiConfigMock, + listTaskRunsMock, + listTasksMock, +} = vi.hoisted(() => ({ + getApiConfigMock: vi.fn(), + getTaskDetailMock: vi.fn(), + getTaskStepsMock: vi.fn(), + getToolExecutionMock: vi.fn(), + hasLiveApiConfigMock: vi.fn(), + listTaskRunsMock: vi.fn(), + listTasksMock: vi.fn(), +})); + +vi.mock("next/link", () => ({ + default: ({ + href, + children, + className, + "aria-current": ariaCurrent, + }: { + href: string; + children: React.ReactNode; + className?: string; + "aria-current"?: string; + }) => ( + <a href={href} className={className} aria-current={ariaCurrent}> + {children} + </a> + ), +})); + +vi.mock("../../lib/api", async () => { + const actual = await vi.importActual("../../lib/api"); + return { + ...actual, + getApiConfig: getApiConfigMock, + getTaskDetail: getTaskDetailMock, + getTaskSteps: getTaskStepsMock, + getToolExecution: getToolExecutionMock, + hasLiveApiConfig: hasLiveApiConfigMock, + listTaskRuns: listTaskRunsMock, + listTasks: listTasksMock, + }; +}); + +describe("TasksPage", () => { + beforeEach(() => { + getApiConfigMock.mockReset(); + getTaskDetailMock.mockReset(); + getTaskStepsMock.mockReset(); + getToolExecutionMock.mockReset(); + hasLiveApiConfigMock.mockReset(); + listTaskRunsMock.mockReset(); + listTasksMock.mockReset(); + + getApiConfigMock.mockReturnValue({ + apiBaseUrl: "", + userId: "", + defaultThreadId: "", + defaultToolId: "", + }); + hasLiveApiConfigMock.mockReturnValue(false); + }); + + afterEach(() => { + cleanup(); + }); + + it("shows fixture task-run review when live API config is absent", async () => { + render(await TasksPage({ searchParams: Promise.resolve({}) })); + + expect(screen.getByText("Fixture-backed")).toBeInTheDocument(); + expect(screen.getByText("Durable run review")).toBeInTheDocument(); + expect(screen.getByText("Fixture run state")).toBeInTheDocument(); + expect(screen.getByText("Run fixture-run-33333333-3333-4333-8333-333333333333")).toBeInTheDocument(); + expect(listTaskRunsMock).not.toHaveBeenCalled(); + }); + + it("shows live task-run rows when live task-run read succeeds", async () => { + const selected = taskFixtures[0]; + if (!selected) { + throw new Error("Expected task fixtures to contain at least one task."); + } + + getApiConfigMock.mockReturnValue({ + apiBaseUrl: "https://api.example.com", + userId: "user-1", + defaultThreadId: "thread-1", + defaultToolId: "tool-1", + }); + hasLiveApiConfigMock.mockReturnValue(true); + + listTasksMock.mockResolvedValue({ + items: [selected], + summary: { total_count: 1, order: ["created_at_asc", "id_asc"] }, + }); + getTaskDetailMock.mockResolvedValue({ task: selected }); + getTaskStepsMock.mockResolvedValue({ + items: [], + summary: { + task_id: selected.id, + total_count: 0, + latest_sequence_no: null, + latest_status: null, + next_sequence_no: 1, + append_allowed: false, + order: ["sequence_no_asc", "created_at_asc", "id_asc"], + }, + }); + listTaskRunsMock.mockResolvedValue({ + items: [ + { + id: "run-live-1", + task_id: selected.id, + status: "running", + checkpoint: { cursor: 1, target_steps: 3, wait_for_signal: false }, + tick_count: 1, + step_count: 1, + max_ticks: 3, + retry_count: 0, + retry_cap: 3, + retry_posture: "none", + failure_class: null, + stop_reason: null, + last_transitioned_at: selected.updated_at, + created_at: selected.created_at, + updated_at: selected.updated_at, + }, + ], + summary: { + task_id: selected.id, + total_count: 1, + order: ["created_at_asc", "id_asc"], + }, + }); + + render(await TasksPage({ searchParams: Promise.resolve({ task: selected.id }) })); + + expect(screen.getByText("Live run state")).toBeInTheDocument(); + expect(screen.getByText("Run run-live-1")).toBeInTheDocument(); + expect(listTaskRunsMock).toHaveBeenCalledWith("https://api.example.com", selected.id, "user-1"); + }); + + it("shows unavailable run state when live task-run read fails", async () => { + const selected = taskFixtures[0]; + if (!selected) { + throw new Error("Expected task fixtures to contain at least one task."); + } + + getApiConfigMock.mockReturnValue({ + apiBaseUrl: "https://api.example.com", + userId: "user-1", + defaultThreadId: "thread-1", + defaultToolId: "tool-1", + }); + hasLiveApiConfigMock.mockReturnValue(true); + + listTasksMock.mockResolvedValue({ + items: [selected], + summary: { total_count: 1, order: ["created_at_asc", "id_asc"] }, + }); + getTaskDetailMock.mockResolvedValue({ task: selected }); + getTaskStepsMock.mockResolvedValue({ + items: [], + summary: { + task_id: selected.id, + total_count: 0, + latest_sequence_no: null, + latest_status: null, + next_sequence_no: 1, + append_allowed: false, + order: ["sequence_no_asc", "created_at_asc", "id_asc"], + }, + }); + listTaskRunsMock.mockRejectedValue(new Error("run backend unavailable")); + + render(await TasksPage({ searchParams: Promise.resolve({ task: selected.id }) })); + + expect(screen.getByText("Run review unavailable")).toBeInTheDocument(); + expect(screen.getByText("Unavailable")).toBeInTheDocument(); + expect( + screen.getByText("The task-run records could not be read from the configured backend."), + ).toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/tasks/page.tsx b/apps/web/app/tasks/page.tsx new file mode 100644 index 0000000..17cc55b --- /dev/null +++ b/apps/web/app/tasks/page.tsx @@ -0,0 +1,302 @@ +import { PageHeader } from "../../components/page-header"; +import { TaskList } from "../../components/task-list"; +import { TaskRunList } from "../../components/task-run-list"; +import { TaskStepList } from "../../components/task-step-list"; +import { TaskSummary } from "../../components/task-summary"; +import { + combinePageModes, + getApiConfig, + getTaskDetail, + listTaskRuns, + getTaskSteps, + getToolExecution, + hasLiveApiConfig, + listTasks, + pageModeLabel, + type ApiSource, + type TaskItem, + type TaskRunItem, +} from "../../lib/api"; +import { + getFixtureExecution, + getFixtureTask, + getFixtureTaskStepSummary, + getFixtureTaskSteps, + taskFixtures, +} from "../../lib/fixtures"; + +type SearchParams = Promise<Record<string, string | string[] | undefined>>; + +function buildFixtureTaskRuns(task: TaskItem): TaskRunItem[] { + if (task.status === "executed") { + return [ + { + id: `fixture-run-${task.id}`, + task_id: task.id, + status: "done", + checkpoint: { + cursor: 2, + target_steps: 2, + wait_for_signal: false, + }, + tick_count: 2, + step_count: 2, + max_ticks: 3, + retry_count: 0, + retry_cap: 3, + retry_posture: "terminal", + failure_class: null, + stop_reason: "done", + last_transitioned_at: task.updated_at, + created_at: task.created_at, + updated_at: task.updated_at, + }, + ]; + } + + if (task.status === "denied") { + return [ + { + id: `fixture-run-${task.id}`, + task_id: task.id, + status: "cancelled", + checkpoint: { + cursor: 0, + target_steps: 1, + wait_for_signal: false, + }, + tick_count: 0, + step_count: 0, + max_ticks: 1, + retry_count: 0, + retry_cap: 1, + retry_posture: "terminal", + failure_class: null, + stop_reason: "cancelled", + last_transitioned_at: task.updated_at, + created_at: task.created_at, + updated_at: task.updated_at, + }, + ]; + } + + if (task.status === "blocked") { + return [ + { + id: `fixture-run-${task.id}`, + task_id: task.id, + status: "failed", + checkpoint: { + cursor: 1, + target_steps: 2, + wait_for_signal: false, + }, + tick_count: 1, + step_count: 1, + max_ticks: 1, + retry_count: 0, + retry_cap: 1, + retry_posture: "terminal", + failure_class: "budget", + stop_reason: "budget_exhausted", + last_transitioned_at: task.updated_at, + created_at: task.created_at, + updated_at: task.updated_at, + }, + ]; + } + + if (task.status === "pending_approval") { + return [ + { + id: `fixture-run-${task.id}`, + task_id: task.id, + status: "waiting_approval", + checkpoint: { + cursor: 1, + target_steps: 2, + wait_for_signal: true, + waiting_approval_id: task.latest_approval_id, + }, + tick_count: 1, + step_count: 1, + max_ticks: 3, + retry_count: 0, + retry_cap: 3, + retry_posture: "awaiting_approval", + failure_class: null, + stop_reason: "waiting_approval", + last_transitioned_at: task.updated_at, + created_at: task.created_at, + updated_at: task.updated_at, + }, + ]; + } + + return [ + { + id: `fixture-run-${task.id}`, + task_id: task.id, + status: "queued", + checkpoint: { + cursor: 0, + target_steps: 2, + wait_for_signal: false, + }, + tick_count: 0, + step_count: 0, + max_ticks: 2, + retry_count: 0, + retry_cap: 2, + retry_posture: "none", + failure_class: null, + stop_reason: null, + last_transitioned_at: task.updated_at, + created_at: task.created_at, + updated_at: task.updated_at, + }, + ]; +} + +export default async function TasksPage({ + searchParams, +}: { + searchParams?: SearchParams; +}) { + const params = (searchParams ? await searchParams : {}) as Record< + string, + string | string[] | undefined + >; + const requestedTaskId = typeof params.task === "string" ? params.task : undefined; + const apiConfig = getApiConfig(); + const liveModeReady = hasLiveApiConfig(apiConfig); + + let items = taskFixtures; + let listSource: ApiSource = "fixture"; + if (liveModeReady) { + try { + const payload = await listTasks(apiConfig.apiBaseUrl, apiConfig.userId); + items = payload.items; + listSource = "live"; + } catch { + items = taskFixtures; + listSource = "fixture"; + } + } + + const selectedFromList = items.find((item) => item.id === requestedTaskId) ?? items[0] ?? null; + let selectedTask = selectedFromList; + let taskSource: ApiSource = selectedTask ? listSource : "fixture"; + + if (selectedFromList && liveModeReady && listSource === "live") { + try { + const payload = await getTaskDetail(apiConfig.apiBaseUrl, selectedFromList.id, apiConfig.userId); + selectedTask = payload.task; + taskSource = "live"; + } catch { + selectedTask = getFixtureTask(selectedFromList.id) ?? selectedFromList; + taskSource = selectedTask === selectedFromList ? "live" : "fixture"; + } + } + + let steps = selectedTask ? getFixtureTaskSteps(selectedTask.id) : []; + let stepSummary = selectedTask ? getFixtureTaskStepSummary(selectedTask.id) : null; + let stepSource: ApiSource = selectedTask ? "fixture" : listSource; + + if (selectedTask && liveModeReady && taskSource === "live") { + try { + const payload = await getTaskSteps(apiConfig.apiBaseUrl, selectedTask.id, apiConfig.userId); + steps = payload.items; + stepSummary = payload.summary; + stepSource = "live"; + } catch { + steps = getFixtureTaskSteps(selectedTask.id); + stepSummary = getFixtureTaskStepSummary(selectedTask.id); + stepSource = "fixture"; + } + } + + let execution = selectedTask?.latest_execution_id ? getFixtureExecution(selectedTask.latest_execution_id) : null; + let executionSource: ApiSource | null = execution ? "fixture" : null; + let executionUnavailableMessage: string | null = null; + + if (selectedTask?.latest_execution_id && liveModeReady && taskSource === "live") { + try { + const payload = await getToolExecution( + apiConfig.apiBaseUrl, + selectedTask.latest_execution_id, + apiConfig.userId, + ); + execution = payload.execution; + executionSource = "live"; + } catch { + execution = getFixtureExecution(selectedTask.latest_execution_id); + executionSource = execution ? "fixture" : null; + executionUnavailableMessage = execution + ? null + : "The latest execution record could not be read from the configured backend."; + } + } + + let taskRuns = selectedTask ? buildFixtureTaskRuns(selectedTask) : []; + let taskRunSource: ApiSource | "unavailable" = selectedTask ? "fixture" : "unavailable"; + let taskRunUnavailableMessage: string | null = null; + + if (selectedTask && liveModeReady && taskSource === "live") { + try { + const payload = await listTaskRuns(apiConfig.apiBaseUrl, selectedTask.id, apiConfig.userId); + taskRuns = payload.items; + taskRunSource = "live"; + } catch { + taskRuns = []; + taskRunSource = "unavailable"; + taskRunUnavailableMessage = "The task-run records could not be read from the configured backend."; + } + } + + const pageMode = combinePageModes( + listSource, + selectedTask ? taskSource : null, + selectedTask ? stepSource : null, + execution ? executionSource : null, + taskRunSource === "unavailable" ? null : taskRunSource, + ); + + return ( + <div className="page-stack"> + <PageHeader + eyebrow="Tasks" + title="Task lifecycle inspection" + description="Tasks and task steps stay legible in a split review layout so approval linkage, execution state, and downstream outcome remain visible without overflow or guesswork." + meta={ + <div className="header-meta"> + <span className="subtle-chip">{pageModeLabel(pageMode)}</span> + <span className="subtle-chip">{items.length} tasks</span> + </div> + } + /> + + <div className="dashboard-grid dashboard-grid--detail"> + <TaskList tasks={items} selectedId={selectedTask?.id} /> + + <div className="stack"> + <TaskSummary + task={selectedTask} + taskSource={taskSource} + stepSource={stepSource} + execution={execution} + executionSource={executionSource} + executionUnavailableMessage={executionUnavailableMessage} + /> + <TaskRunList + task={selectedTask} + runs={taskRuns} + source={taskRunSource} + unavailableMessage={taskRunUnavailableMessage} + /> + <TaskStepList steps={steps} summary={stepSummary} source={stepSource} /> + </div> + </div> + </div> + ); +} diff --git a/apps/web/app/traces/loading.tsx b/apps/web/app/traces/loading.tsx new file mode 100644 index 0000000..ebafbdf --- /dev/null +++ b/apps/web/app/traces/loading.tsx @@ -0,0 +1,51 @@ +import { PageHeader } from "../../components/page-header"; +import { SectionCard } from "../../components/section-card"; +import { StatusBadge } from "../../components/status-badge"; + +export default function Loading() { + return ( + <div className="page-stack" aria-busy="true"> + <PageHeader + eyebrow="Explainability" + title="Trace and explain-why review" + description="Loading live trace summaries, the selected trace detail, and ordered event review." + meta={ + <div className="header-meta"> + <span className="subtle-chip">Loading route state</span> + </div> + } + /> + + <div className="split-layout"> + <SectionCard + eyebrow="Trace list" + title="Loading traces" + description="The explain-why list is being read from the current backing source." + className="loading-card" + > + <div className="detail-stack"> + <StatusBadge status="loading" label="Loading" /> + <div className="loading-placeholder loading-placeholder--card" /> + <div className="loading-placeholder loading-placeholder--card" /> + <div className="loading-placeholder loading-placeholder--card" /> + </div> + </SectionCard> + + <SectionCard + eyebrow="Trace detail" + title="Loading selected trace" + description="The detail panel waits for summary, metadata, and ordered event review to resolve." + className="loading-card" + > + <div className="detail-stack"> + <StatusBadge status="loading" label="Loading" /> + <div className="loading-placeholder loading-placeholder--line loading-placeholder--wide" /> + <div className="loading-placeholder loading-placeholder--line" /> + <div className="loading-placeholder loading-placeholder--line" /> + <div className="loading-placeholder loading-placeholder--card" /> + </div> + </SectionCard> + </div> + </div> + ); +} diff --git a/apps/web/app/traces/page.test.tsx b/apps/web/app/traces/page.test.tsx new file mode 100644 index 0000000..9295676 --- /dev/null +++ b/apps/web/app/traces/page.test.tsx @@ -0,0 +1,76 @@ +import React from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import TracesPage from "./page"; + +const { + getApiConfigMock, + getTraceDetailMock, + getTraceEventsMock, + hasLiveApiConfigMock, + listTracesMock, +} = vi.hoisted(() => ({ + getApiConfigMock: vi.fn(), + getTraceDetailMock: vi.fn(), + getTraceEventsMock: vi.fn(), + hasLiveApiConfigMock: vi.fn(), + listTracesMock: vi.fn(), +})); + +vi.mock("../../lib/api", async () => { + const actual = await vi.importActual("../../lib/api"); + return { + ...actual, + getApiConfig: getApiConfigMock, + getTraceDetail: getTraceDetailMock, + getTraceEvents: getTraceEventsMock, + hasLiveApiConfig: hasLiveApiConfigMock, + listTraces: listTracesMock, + }; +}); + +describe("TracesPage", () => { + beforeEach(() => { + getApiConfigMock.mockReset(); + getTraceDetailMock.mockReset(); + getTraceEventsMock.mockReset(); + hasLiveApiConfigMock.mockReset(); + listTracesMock.mockReset(); + + getApiConfigMock.mockReturnValue({ + apiBaseUrl: "", + userId: "", + defaultThreadId: "", + defaultToolId: "", + }); + hasLiveApiConfigMock.mockReturnValue(false); + }); + + afterEach(() => { + cleanup(); + }); + + it("shows fixture mode when live api config is absent", async () => { + render(await TracesPage({ searchParams: Promise.resolve({}) })); + + expect(screen.getByText("Fixture-backed")).toBeInTheDocument(); + expect(screen.getByText("Trace and explain-why review")).toBeInTheDocument(); + }); + + it("shows api unavailable chip when live trace list fails", async () => { + getApiConfigMock.mockReturnValue({ + apiBaseUrl: "https://api.example.com", + userId: "user-1", + defaultThreadId: "thread-1", + defaultToolId: "tool-1", + }); + hasLiveApiConfigMock.mockReturnValue(true); + listTracesMock.mockRejectedValue(new Error("trace backend unavailable")); + + render(await TracesPage({ searchParams: Promise.resolve({}) })); + + expect(screen.getByText("Live API")).toBeInTheDocument(); + expect(screen.getAllByText("Trace API unavailable").length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/apps/web/app/traces/page.tsx b/apps/web/app/traces/page.tsx new file mode 100644 index 0000000..e2eee77 --- /dev/null +++ b/apps/web/app/traces/page.tsx @@ -0,0 +1,275 @@ +import { PageHeader } from "../../components/page-header"; +import { SectionCard } from "../../components/section-card"; +import { TraceList, type TraceEventItem, type TraceItem } from "../../components/trace-list"; +import { + getApiConfig, + getTraceDetail, + getTraceEvents, + hasLiveApiConfig, + listTraces, + pageModeLabel, + type TraceReviewEventItem, + type TraceReviewItem, + type TraceReviewSummaryItem, +} from "../../lib/api"; +import { getFixtureTrace, traceFixtures } from "../../lib/fixtures"; + +type SearchParams = Promise<Record<string, string | string[] | undefined>>; + +function formatKind(kind: string) { + return kind + .split(/[._]/) + .filter(Boolean) + .map((part) => part[0]?.toUpperCase() + part.slice(1)) + .join(" "); +} + +function formatStatus(status: string) { + return status.replaceAll("_", " "); +} + +function shortId(value: string) { + return value.length > 12 ? `${value.slice(0, 8)}...${value.slice(-4)}` : value; +} + +function stringifyValue(value: unknown): string { + if (typeof value === "string") { + return value; + } + + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + + if (value === null) { + return "null"; + } + + if (Array.isArray(value)) { + return value.map((item) => stringifyValue(item)).join(", "); + } + + if (typeof value === "object") { + return JSON.stringify(value); + } + + return "unknown"; +} + +function buildTraceSummary(trace: TraceReviewSummaryItem | TraceReviewItem) { + const eventLabel = trace.trace_event_count === 1 ? "ordered event" : "ordered events"; + return `${formatKind(trace.kind)} recorded ${trace.trace_event_count} ${eventLabel} for thread ${shortId(trace.thread_id)} and ended in ${formatStatus(trace.status)} status.`; +} + +function buildBaseTraceItem(trace: TraceReviewSummaryItem): TraceItem { + return { + id: trace.id, + kind: trace.kind, + status: trace.status, + title: `${formatKind(trace.kind)} review`, + summary: buildTraceSummary(trace), + eventCount: trace.trace_event_count, + createdAt: trace.created_at, + source: trace.compiler_version, + scope: `Thread ${shortId(trace.thread_id)}`, + related: { + threadId: trace.thread_id, + compilerVersion: trace.compiler_version, + }, + metadata: [ + `Trace: ${trace.id}`, + `Thread: ${trace.thread_id}`, + `Compiler: ${trace.compiler_version}`, + `Status: ${formatStatus(trace.status)}`, + ], + evidence: [], + events: [], + detailSource: "live", + eventSource: "live", + }; +} + +function buildEventFacts(event: TraceReviewEventItem) { + const payload = event.payload; + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return [`Sequence ${event.sequence_no}`, `Payload: ${stringifyValue(payload)}`]; + } + + const entries = Object.entries(payload as Record<string, unknown>).slice(0, 4); + return [ + `Sequence ${event.sequence_no}`, + ...entries.map(([key, value]) => `${key}: ${stringifyValue(value)}`), + ]; +} + +function buildEventDetail(event: TraceReviewEventItem) { + const payload = event.payload; + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return `This event captured payload value ${stringifyValue(payload)}.`; + } + + const keys = Object.keys(payload as Record<string, unknown>); + if (keys.length === 0) { + return "This event completed without additional payload fields."; + } + + return `This event captured ${keys.length} payload field${keys.length === 1 ? "" : "s"} for operator review.`; +} + +function buildEventTitle(event: TraceReviewEventItem) { + return `${formatKind(event.kind)} event`; +} + +function buildLiveTraceItem( + trace: TraceReviewItem, + events: TraceReviewEventItem[], + options?: { + detailUnavailable?: boolean; + eventsUnavailable?: boolean; + }, +): TraceItem { + const metadata = [ + `Trace: ${trace.id}`, + `Thread: ${trace.thread_id}`, + `Compiler: ${trace.compiler_version}`, + `Status: ${formatStatus(trace.status)}`, + ...Object.entries(trace.limits).map(([key, value]) => `Limit ${key}: ${stringifyValue(value)}`), + ]; + + const evidence = events.length + ? [ + `${events.length} ordered event${events.length === 1 ? "" : "s"} loaded from the shipped trace review API.`, + ] + : ["No ordered events were returned for this trace."]; + + return { + ...buildBaseTraceItem(trace), + metadata, + evidence, + events: events.map<TraceEventItem>((event) => ({ + id: event.id, + kind: event.kind, + title: buildEventTitle(event), + detail: buildEventDetail(event), + facts: buildEventFacts(event), + })), + detailUnavailable: options?.detailUnavailable, + eventsUnavailable: options?.eventsUnavailable, + }; +} + +export default async function TracesPage({ + searchParams, +}: { + searchParams?: SearchParams; +}) { + const params = (searchParams ? await searchParams : {}) as Record< + string, + string | string[] | undefined + >; + const requestedId = typeof params.trace === "string" ? params.trace : undefined; + const apiConfig = getApiConfig(); + const liveModeReady = hasLiveApiConfig(apiConfig); + + let traces = traceFixtures; + let apiUnavailable = false; + + if (liveModeReady) { + try { + const payload = await listTraces(apiConfig.apiBaseUrl, apiConfig.userId); + const selectedSummary = payload.items.find((item) => item.id === requestedId) ?? payload.items[0] ?? null; + const mapped = payload.items.map((item) => buildBaseTraceItem(item)); + + if (selectedSummary) { + const selectedIndex = mapped.findIndex((item) => item.id === selectedSummary.id); + let selectedTrace = mapped[selectedIndex]; + + try { + const detailPayload = await getTraceDetail( + apiConfig.apiBaseUrl, + selectedSummary.id, + apiConfig.userId, + ); + + try { + const eventPayload = await getTraceEvents( + apiConfig.apiBaseUrl, + selectedSummary.id, + apiConfig.userId, + ); + + selectedTrace = buildLiveTraceItem(detailPayload.trace, eventPayload.items); + } catch { + selectedTrace = buildLiveTraceItem(detailPayload.trace, [], { + eventsUnavailable: true, + }); + } + } catch { + selectedTrace = { + ...selectedTrace, + metadata: [ + `Trace: ${selectedSummary.id}`, + `Thread: ${selectedSummary.thread_id}`, + `Compiler: ${selectedSummary.compiler_version}`, + `Status: ${formatStatus(selectedSummary.status)}`, + ], + evidence: [ + "The selected summary came from the live trace list, but full trace detail could not be read.", + ], + detailUnavailable: true, + eventsUnavailable: true, + }; + } + + if (selectedIndex >= 0) { + mapped[selectedIndex] = selectedTrace; + } + } + + traces = mapped; + } catch { + traces = []; + apiUnavailable = true; + } + } else { + const selectedFixture = requestedId ? getFixtureTrace(requestedId) : null; + if (selectedFixture) { + traces = [selectedFixture, ...traceFixtures.filter((item) => item.id !== selectedFixture.id)]; + } + } + + const selectedId = requestedId ?? traces[0]?.id; + const pageMode = liveModeReady ? "live" : "fixture"; + + return ( + <div className="page-stack"> + <PageHeader + eyebrow="Explainability" + title="Trace and explain-why review" + description="Trace review keeps explanation calm and bounded: live summaries first, key metadata second, and ordered events last." + meta={ + <div className="header-meta"> + <span className="subtle-chip">{pageModeLabel(pageMode)}</span> + <span className="subtle-chip"> + {apiUnavailable ? "Trace API unavailable" : `${traces.length} entries`} + </span> + </div> + } + /> + + <TraceList traces={traces} selectedId={selectedId} apiUnavailable={apiUnavailable} /> + + <SectionCard + eyebrow="Review guidance" + title="What operators should verify" + description="The explain-why view is designed to stay readable before action." + > + <ul className="bullet-list"> + <li>Whether the summary matches the trace kind, status, and ordered event count returned by the backend.</li> + <li>Whether key metadata keeps thread, compiler, and limit context visible without turning into a debugger dump.</li> + <li>Whether the ordered events explain the outcome clearly enough without requiring broader trace filtering or mutation scope.</li> + </ul> + </SectionCard> + </div> + ); +} diff --git a/apps/web/components/app-shell.tsx b/apps/web/components/app-shell.tsx new file mode 100644 index 0000000..c5e3e8c --- /dev/null +++ b/apps/web/components/app-shell.tsx @@ -0,0 +1,166 @@ +"use client"; + +import type { ReactNode } from "react"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +const navigation = [ + { + href: "/", + label: "Overview", + caption: "Shell landing and governed surface summary", + }, + { + href: "/onboarding", + label: "Onboarding", + caption: "Hosted identity and workspace bootstrap", + }, + { + href: "/settings", + label: "Settings", + caption: "Hosted preferences and device visibility", + }, + { + href: "/admin", + label: "Admin", + caption: "Hosted workspace, delivery, rollout, and incident visibility", + }, + { + href: "/chat", + label: "Requests", + caption: "Compose bounded operator requests", + }, + { + href: "/approvals", + label: "Approvals", + caption: "Review approval queue and inspector", + }, + { + href: "/tasks", + label: "Tasks", + caption: "Inspect lifecycle state and task steps", + }, + { + href: "/artifacts", + label: "Artifacts", + caption: "Review task artifacts, workspaces, and chunks", + }, + { + href: "/gmail", + label: "Gmail", + caption: "Review connected accounts and ingest one selected message", + }, + { + href: "/calendar", + label: "Calendar", + caption: "Review connected accounts and ingest one selected event", + }, + { + href: "/memories", + label: "Memories", + caption: "Review memory detail, revisions, and labels", + }, + { + href: "/chief-of-staff", + label: "Chief-of-Staff", + caption: "Deterministic priorities, rationale, and next action", + }, + { + href: "/entities", + label: "Entities", + caption: "Review entity detail and related edges", + }, + { + href: "/traces", + label: "Traces", + caption: "Explain-why and governed action review", + }, +]; + +function isActive(pathname: string, href: string) { + if (href === "/") { + return pathname === "/"; + } + + return pathname.startsWith(href); +} + +export function AppShell({ children }: { children: ReactNode }) { + const pathname = usePathname(); + + return ( + <div className="shell-chrome"> + <div className="shell"> + <aside className="shell-sidebar" aria-label="Primary navigation"> + <div className="brand-copy"> + <span className="brand-mark" aria-hidden="true"> + AB + </span> + <p className="eyebrow">AliceBot</p> + <h1 className="brand-title">Operator shell</h1> + <p className="brand-description"> + Calm, governed views for hosted onboarding/settings plus requests, approvals, tasks, + hosted admin operations, artifacts, Gmail, Calendar, memories, chief-of-staff + priorities, entities, and explainability. + </p> + </div> + + <nav className="shell-nav"> + {navigation.map((item) => ( + <Link + key={item.href} + href={item.href} + className={`shell-nav__item${isActive(pathname, item.href) ? " is-active" : ""}`} + > + <span className="shell-nav__title">{item.label}</span> + <span className="shell-nav__caption">{item.caption}</span> + </Link> + ))} + </nav> + + <div className="shell-note"> + <p className="shell-note__title">Current posture</p> + <p className="muted-copy"> + This shell stays narrow on purpose. It exposes existing backend seams without adding + new product scope or hiding governance state. + </p> + </div> + </aside> + + <div className="shell-column"> + <header className="shell-topbar"> + <div className="shell-topbar__row"> + <div className="brand-copy"> + <p className="eyebrow">MVP Web Shell</p> + <h2 className="shell-topbar__title">Governed operator interface</h2> + </div> + + <div className="topbar-status" aria-label="Shell status"> + <span className="subtle-chip">Single-user v1</span> + <span className="subtle-chip">Hosted bootstrap seams + operator shell</span> + </div> + </div> + + <nav className="shell-nav shell-nav--mobile" aria-label="Mobile navigation"> + {navigation.map((item) => ( + <Link + key={item.href} + href={item.href} + className={`shell-nav__item${isActive(pathname, item.href) ? " is-active" : ""}`} + > + <span className="shell-nav__title">{item.label}</span> + <span className="shell-nav__caption">{item.caption}</span> + </Link> + ))} + </nav> + </header> + + <main className="shell-main"> + <div className="content-frame">{children}</div> + </main> + </div> + </div> + </div> + ); +} diff --git a/apps/web/components/approval-actions.test.tsx b/apps/web/components/approval-actions.test.tsx new file mode 100644 index 0000000..c6df05f --- /dev/null +++ b/apps/web/components/approval-actions.test.tsx @@ -0,0 +1,273 @@ +import React from "react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ApprovalActions } from "./approval-actions"; + +const { refreshMock, resolveApprovalMock, executeApprovalMock } = vi.hoisted(() => ({ + refreshMock: vi.fn(), + resolveApprovalMock: vi.fn(), + executeApprovalMock: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + refresh: refreshMock, + }), +})); + +vi.mock("../lib/api", async () => { + const actual = await vi.importActual("../lib/api"); + return { + ...actual, + resolveApproval: resolveApprovalMock, + executeApproval: executeApprovalMock, + }; +}); + +const pendingApproval = { + id: "approval-1", + thread_id: "thread-1", + task_step_id: "step-1", + status: "pending", + request: { + thread_id: "thread-1", + tool_id: "tool-1", + action: "place_order", + scope: "supplements", + domain_hint: "ecommerce", + risk_hint: "purchase", + attributes: { + quantity: "1", + }, + }, + tool: { + id: "tool-1", + tool_key: "merchant_proxy", + name: "Merchant Proxy", + description: "Proxy", + version: "0.1.0", + metadata_version: "tool_metadata_v0", + active: true, + tags: [], + action_hints: [], + scope_hints: [], + domain_hints: [], + risk_hints: [], + metadata: {}, + created_at: "2026-03-17T00:00:00Z", + }, + routing: { + decision: "require_approval", + reasons: [], + trace: { + trace_id: "trace-1", + trace_event_count: 3, + }, + }, + created_at: "2026-03-17T00:00:00Z", + resolution: null, +}; + +function ApprovalActionsHarness({ + initialApproval = pendingApproval, + initialHasExecution = false, +}: { + initialApproval?: typeof pendingApproval; + initialHasExecution?: boolean; +}) { + const [approval, setApproval] = React.useState(initialApproval); + const [hasExecution, setHasExecution] = React.useState(initialHasExecution); + + return ( + <ApprovalActions + approval={approval} + hasExecution={hasExecution} + apiBaseUrl="https://api.example.com" + userId="user-1" + onResolved={setApproval} + onExecuted={(payload) => { + setApproval(payload.approval); + setHasExecution(true); + }} + /> + ); +} + +describe("ApprovalActions", () => { + beforeEach(() => { + refreshMock.mockReset(); + resolveApprovalMock.mockReset(); + executeApprovalMock.mockReset(); + }); + + afterEach(() => { + cleanup(); + }); + + it("disables live actions in fixture mode", () => { + render( + <ApprovalActions + approval={pendingApproval} + hasExecution={false} + onResolved={vi.fn()} + onExecuted={vi.fn()} + />, + ); + + expect(screen.getByRole("button", { name: "Approve" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Reject" })).toBeDisabled(); + expect(screen.getByText(/disabled in fixture mode/i)).toBeInTheDocument(); + }); + + it("preserves approval success feedback after the parent rerenders with the resolved approval", async () => { + resolveApprovalMock.mockResolvedValue({ + approval: { + ...pendingApproval, + status: "approved", + resolution: { + resolved_at: "2026-03-17T01:00:00Z", + resolved_by_user_id: "user-1", + }, + }, + trace: { + trace_id: "trace-2", + trace_event_count: 4, + }, + }); + + render(<ApprovalActionsHarness />); + + fireEvent.click(screen.getByRole("button", { name: "Approve" })); + + await waitFor(() => { + expect(resolveApprovalMock).toHaveBeenCalledWith( + "https://api.example.com", + "approval-1", + "approve", + "user-1", + ); + }); + + await waitFor(() => { + expect(screen.getByText(/Approval resolved as approved/i)).toBeInTheDocument(); + }); + + expect(refreshMock).toHaveBeenCalledTimes(1); + expect(screen.getByText("Resolution saved")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Execute approved request" })).toBeInTheDocument(); + }); + + it("preserves execution success feedback after the parent rerenders with linked execution state", async () => { + executeApprovalMock.mockResolvedValue({ + request: { + approval_id: "approval-1", + task_step_id: "step-1", + }, + approval: { + ...pendingApproval, + status: "approved", + resolution: { + resolved_at: "2026-03-17T01:00:00Z", + resolved_by_user_id: "user-1", + }, + }, + tool: pendingApproval.tool, + result: { + handler_key: "proxy.echo", + status: "completed", + output: { ok: true }, + reason: null, + }, + events: { + request_event_id: "event-1", + request_sequence_no: 1, + result_event_id: "event-2", + result_sequence_no: 2, + }, + trace: { + trace_id: "trace-2", + trace_event_count: 4, + }, + }); + + const approvedApproval = { + ...pendingApproval, + status: "approved" as const, + resolution: { + resolved_at: "2026-03-17T01:00:00Z", + resolved_by_user_id: "user-1", + }, + }; + + render(<ApprovalActionsHarness initialApproval={approvedApproval} />); + + fireEvent.click(screen.getByRole("button", { name: "Execute approved request" })); + + await waitFor(() => { + expect(executeApprovalMock).toHaveBeenCalledWith( + "https://api.example.com", + "approval-1", + "user-1", + ); + }); + + await waitFor(() => { + expect(screen.getByText(/Execution completed/i)).toBeInTheDocument(); + }); + + expect(refreshMock).toHaveBeenCalledTimes(1); + expect(screen.getByText("Execution saved")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Executed" })).toBeDisabled(); + }); + + it("shows an explicit blocked execution badge after a blocked execute response", async () => { + executeApprovalMock.mockResolvedValue({ + request: { + approval_id: "approval-1", + task_step_id: "step-1", + }, + approval: { + ...pendingApproval, + status: "approved", + resolution: { + resolved_at: "2026-03-17T01:00:00Z", + resolved_by_user_id: "user-1", + }, + }, + tool: pendingApproval.tool, + result: { + handler_key: null, + status: "blocked", + output: null, + reason: "tool budget exceeded", + }, + events: null, + trace: { + trace_id: "trace-3", + trace_event_count: 4, + }, + }); + + render( + <ApprovalActionsHarness + initialApproval={{ + ...pendingApproval, + status: "approved", + resolution: { + resolved_at: "2026-03-17T01:00:00Z", + resolved_by_user_id: "user-1", + }, + }} + />, + ); + + fireEvent.click(screen.getByRole("button", { name: "Execute approved request" })); + + await waitFor(() => { + expect(screen.getByText("Execution blocked")).toBeInTheDocument(); + }); + + expect(screen.getByText(/recorded as blocked/i)).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/approval-actions.tsx b/apps/web/components/approval-actions.tsx new file mode 100644 index 0000000..d433301 --- /dev/null +++ b/apps/web/components/approval-actions.tsx @@ -0,0 +1,249 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +import { useRouter } from "next/navigation"; + +import type { ApprovalExecutionResponse, ApprovalItem } from "../lib/api"; +import { executeApproval, resolveApproval } from "../lib/api"; +import { StatusBadge } from "./status-badge"; + +type ApprovalActionsProps = { + approval: ApprovalItem; + hasExecution: boolean; + apiBaseUrl?: string; + userId?: string; + onResolved: (approval: ApprovalItem) => void; + onExecuted: (payload: ApprovalExecutionResponse) => void; +}; + +type FeedbackState = { + tone: "info" | "success" | "danger"; + kind: "availability" | "resolution" | "execution"; + message: string; + badgeStatus?: string; + badgeLabel?: string; +}; + +function actionAvailabilityMessage( + liveModeReady: boolean, + approvalStatus: ApprovalItem["status"], + hasExecution: boolean, +) { + if (!liveModeReady) { + if (hasExecution) { + return "Fixture mode is read-only. Review the recorded execution detail below."; + } + + return approvalStatus === "pending" + ? "Approve and reject controls are disabled in fixture mode." + : "Execution controls are unavailable until live API configuration is present."; + } + + if (approvalStatus === "pending") { + return "Choose approve or reject to resolve the approval through the shipped backend seam."; + } + + if (approvalStatus === "approved" && !hasExecution) { + return "This approval is resolved and eligible for execution. Run it only when the reviewed request is ready to proceed."; + } + + if (approvalStatus === "approved" && hasExecution) { + return "A linked execution record already exists. The action bar is now read-only."; + } + + return "This approval is not in an executable state. The action bar is read-only."; +} + +export function ApprovalActions({ + approval, + hasExecution, + apiBaseUrl, + userId, + onResolved, + onExecuted, +}: ApprovalActionsProps) { + const router = useRouter(); + const lastResetContextRef = useRef<{ approvalId: string; liveModeReady: boolean } | null>(null); + const [feedback, setFeedback] = useState<FeedbackState>({ + tone: "info", + kind: "availability", + message: actionAvailabilityMessage(Boolean(apiBaseUrl && userId), approval.status, hasExecution), + }); + const [pendingAction, setPendingAction] = useState<"approve" | "reject" | "execute" | null>(null); + + const liveModeReady = Boolean(apiBaseUrl && userId); + const actionBusy = pendingAction !== null; + const canResolve = liveModeReady && approval.status === "pending" && !actionBusy; + const canExecute = liveModeReady && approval.status === "approved" && !hasExecution && !actionBusy; + + useEffect(() => { + const lastResetContext = lastResetContextRef.current; + const shouldReset = + lastResetContext == null || + lastResetContext.approvalId !== approval.id || + lastResetContext.liveModeReady !== liveModeReady; + + if (shouldReset) { + setFeedback({ + tone: "info", + kind: "availability", + message: actionAvailabilityMessage(liveModeReady, approval.status, hasExecution), + }); + setPendingAction(null); + lastResetContextRef.current = { + approvalId: approval.id, + liveModeReady, + }; + } + }, [approval.id, approval.status, hasExecution, liveModeReady]); + + async function handleResolve(action: "approve" | "reject") { + if (!apiBaseUrl || !userId) { + return; + } + + setPendingAction(action); + setFeedback({ + tone: "info", + kind: "resolution", + message: action === "approve" ? "Submitting approval resolution..." : "Submitting rejection resolution...", + }); + + try { + const payload = await resolveApproval(apiBaseUrl, approval.id, action, userId); + onResolved(payload.approval); + setFeedback({ + tone: "success", + kind: "resolution", + message: + action === "approve" + ? "Approval resolved as approved. The inbox and downstream task view have been refreshed." + : "Approval resolved as rejected. The inbox and downstream task view have been refreshed.", + }); + router.refresh(); + } catch (error) { + const message = error instanceof Error ? error.message : "Resolution failed"; + setFeedback({ + tone: "danger", + kind: "resolution", + message, + badgeStatus: "rejected", + badgeLabel: "Resolution failed", + }); + } finally { + setPendingAction(null); + } + } + + async function handleExecute() { + if (!apiBaseUrl || !userId) { + return; + } + + setPendingAction("execute"); + setFeedback({ + tone: "info", + kind: "execution", + message: "Submitting approved execution...", + }); + + try { + const payload = + approval.task_run_id == null + ? await executeApproval(apiBaseUrl, approval.id, userId) + : await executeApproval(apiBaseUrl, approval.id, userId, approval.task_run_id); + onExecuted(payload); + setFeedback({ + tone: payload.result.status === "blocked" ? "danger" : "success", + kind: "execution", + message: + payload.result.status === "blocked" + ? "Execution was recorded as blocked. Review the execution summary for the blocking reason." + : "Execution completed and the review panels have been refreshed.", + badgeStatus: payload.result.status === "blocked" ? "blocked" : "completed", + badgeLabel: payload.result.status === "blocked" ? "Execution blocked" : "Execution saved", + }); + router.refresh(); + } catch (error) { + const message = error instanceof Error ? error.message : "Execution failed"; + setFeedback({ + tone: "danger", + kind: "execution", + message, + badgeStatus: "failed", + badgeLabel: "Execution failed", + }); + } finally { + setPendingAction(null); + } + } + + const badgeStatus = pendingAction + ? pendingAction === "execute" + ? "executing" + : "submitting" + : feedback.badgeStatus ?? + (feedback.tone === "danger" + ? feedback.kind === "execution" + ? "failed" + : "rejected" + : feedback.tone); + + const badgeLabel = pendingAction + ? pendingAction === "approve" + ? "Submitting approve" + : pendingAction === "reject" + ? "Submitting reject" + : "Executing" + : feedback.badgeLabel ?? + (feedback.tone === "success" + ? feedback.kind === "execution" + ? "Execution saved" + : "Resolution saved" + : feedback.tone === "danger" + ? feedback.kind === "execution" + ? "Execution failed" + : "Resolution failed" + : liveModeReady + ? "Ready" + : "Fixture mode"); + + return ( + <div className="approval-action-bar"> + <div className="approval-action-bar__summary"> + <StatusBadge status={badgeStatus} label={badgeLabel} /> + <p className="muted-copy">{feedback.message}</p> + </div> + + <div className="approval-action-bar__buttons"> + {approval.status === "pending" ? ( + <> + <button + type="button" + className="button" + onClick={() => handleResolve("approve")} + disabled={!canResolve} + > + {pendingAction === "approve" ? "Approving..." : "Approve"} + </button> + <button + type="button" + className="button-secondary button-secondary--danger" + onClick={() => handleResolve("reject")} + disabled={!canResolve} + > + {pendingAction === "reject" ? "Rejecting..." : "Reject"} + </button> + </> + ) : null} + + {approval.status === "approved" ? ( + <button type="button" className="button" onClick={handleExecute} disabled={!canExecute}> + {pendingAction === "execute" ? "Executing..." : hasExecution ? "Executed" : "Execute approved request"} + </button> + ) : null} + </div> + </div> + ); +} diff --git a/apps/web/components/approval-detail.test.tsx b/apps/web/components/approval-detail.test.tsx new file mode 100644 index 0000000..7a8fdf0 --- /dev/null +++ b/apps/web/components/approval-detail.test.tsx @@ -0,0 +1,233 @@ +import React from "react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ApprovalDetail } from "./approval-detail"; + +const { admitMemoryMock, refreshMock } = vi.hoisted(() => ({ + admitMemoryMock: vi.fn(), + refreshMock: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + refresh: refreshMock, + }), +})); + +vi.mock("../lib/api", async () => { + const actual = await vi.importActual("../lib/api"); + return { + ...actual, + admitMemory: admitMemoryMock, + }; +}); + +const approval = { + id: "approval-1", + thread_id: "thread-1", + task_step_id: "step-1", + status: "approved", + request: { + thread_id: "thread-1", + tool_id: "tool-1", + action: "place_order", + scope: "supplements", + domain_hint: "ecommerce", + risk_hint: "purchase", + attributes: { + item: "Magnesium Bisglycinate", + merchant: "Thorne", + }, + }, + tool: { + id: "tool-1", + tool_key: "proxy.echo", + name: "Merchant Proxy", + description: "Proxy", + version: "0.1.0", + metadata_version: "tool_metadata_v0", + active: true, + tags: [], + action_hints: [], + scope_hints: [], + domain_hints: [], + risk_hints: [], + metadata: {}, + created_at: "2026-03-19T00:00:00Z", + }, + routing: { + decision: "require_approval", + reasons: [ + { + code: "policy_effect_require_approval", + source: "policy", + message: "Purchases require explicit approval.", + tool_id: "tool-1", + policy_id: "policy-1", + consent_key: null, + }, + ], + trace: { + trace_id: "trace-approval-1", + trace_event_count: 3, + }, + }, + created_at: "2026-03-19T00:00:00Z", + resolution: { + resolved_at: "2026-03-19T00:01:00Z", + resolved_by_user_id: "user-1", + }, +}; + +const execution = { + id: "execution-1", + approval_id: "approval-1", + task_step_id: "step-1", + thread_id: "thread-1", + tool_id: "tool-1", + trace_id: "trace-execution-1", + request_event_id: "event-request-1", + result_event_id: "event-result-1", + status: "completed", + handler_key: "proxy.echo", + request: approval.request, + tool: approval.tool, + result: { + handler_key: "proxy.echo", + status: "completed", + output: { + mode: "no_side_effect", + item: "Magnesium Bisglycinate", + }, + reason: null, + }, + executed_at: "2026-03-19T00:03:00Z", +}; + +describe("ApprovalDetail", () => { + beforeEach(() => { + admitMemoryMock.mockReset(); + refreshMock.mockReset(); + }); + + afterEach(() => { + cleanup(); + }); + + it("submits explicit memory write-back from execution-linked evidence", async () => { + admitMemoryMock.mockResolvedValue({ + decision: "ADD", + reason: "memory_created", + memory: { + id: "memory-1", + user_id: "user-1", + memory_key: "user.preference.supplement.magnesium", + value: { + merchant: "Thorne", + }, + status: "active", + source_event_ids: ["event-result-1", "event-request-1"], + created_at: "2026-03-19T00:04:00Z", + updated_at: "2026-03-19T00:04:00Z", + deleted_at: null, + }, + revision: { + id: "revision-1", + user_id: "user-1", + memory_id: "memory-1", + sequence_no: 1, + action: "ADD", + memory_key: "user.preference.supplement.magnesium", + previous_value: null, + new_value: { + merchant: "Thorne", + }, + source_event_ids: ["event-result-1", "event-request-1"], + candidate: { + memory_key: "user.preference.supplement.magnesium", + value: { + merchant: "Thorne", + }, + source_event_ids: ["event-result-1", "event-request-1"], + delete_requested: false, + }, + created_at: "2026-03-19T00:04:00Z", + }, + }); + + render( + <ApprovalDetail + initialApproval={approval} + detailSource="live" + initialExecution={execution} + executionSource="live" + apiBaseUrl="https://api.example.com" + userId="user-1" + />, + ); + + fireEvent.change(screen.getByLabelText("Memory key"), { + target: { value: "user.preference.supplement.magnesium" }, + }); + fireEvent.change(screen.getByLabelText("Memory value (JSON)"), { + target: { value: '{"merchant":"Thorne"}' }, + }); + fireEvent.click(screen.getByRole("button", { name: "Submit memory write-back" })); + + await waitFor(() => { + expect(admitMemoryMock).toHaveBeenCalledWith("https://api.example.com", { + user_id: "user-1", + memory_key: "user.preference.supplement.magnesium", + value: { + merchant: "Thorne", + }, + source_event_ids: ["event-result-1", "event-request-1"], + delete_requested: false, + }); + }); + + expect(refreshMock).toHaveBeenCalledTimes(1); + expect(await screen.findByText("ADD persisted at revision 1.")).toBeInTheDocument(); + }); + + it("shows validation feedback for invalid JSON before submitting", () => { + render( + <ApprovalDetail + initialApproval={approval} + detailSource="live" + initialExecution={execution} + executionSource="live" + apiBaseUrl="https://api.example.com" + userId="user-1" + />, + ); + + fireEvent.change(screen.getByLabelText("Memory key"), { + target: { value: "user.preference.supplement.magnesium" }, + }); + fireEvent.change(screen.getByLabelText("Memory value (JSON)"), { + target: { value: "not-json" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Submit memory write-back" })); + + expect(admitMemoryMock).not.toHaveBeenCalled(); + expect(screen.getByText("Memory value must be valid JSON.")).toBeInTheDocument(); + }); + + it("keeps write-back read-only in fixture workflow mode", () => { + render( + <ApprovalDetail + initialApproval={approval} + detailSource="fixture" + initialExecution={execution} + executionSource="fixture" + />, + ); + + expect(screen.getByRole("button", { name: "Submit memory write-back" })).toBeDisabled(); + expect( + screen.getByText("Memory write-back is disabled until live API configuration is present."), + ).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/approval-detail.tsx b/apps/web/components/approval-detail.tsx new file mode 100644 index 0000000..e97da1b --- /dev/null +++ b/apps/web/components/approval-detail.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import type { ApprovalExecutionResponse, ApprovalItem, ApiSource, ToolExecutionItem } from "../lib/api"; +import { EmptyState } from "./empty-state"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; +import { ApprovalActions } from "./approval-actions"; +import { ExecutionSummary } from "./execution-summary"; +import { WorkflowMemoryWritebackForm } from "./workflow-memory-writeback-form"; + +function formatDate(value: string) { + return new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(value)); +} + +function formatAttributeValue(value: unknown) { + if (value == null) { + return "None"; + } + + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + return String(value); + } + + return JSON.stringify(value); +} + +type ApprovalDetailProps = { + initialApproval: ApprovalItem | null; + detailSource: ApiSource; + initialExecution: ToolExecutionItem | null; + executionSource?: ApiSource | null; + executionUnavailableMessage?: string | null; + apiBaseUrl?: string; + userId?: string; + chrome?: "card" | "embedded"; +}; + +export function ApprovalDetail({ + initialApproval, + detailSource, + initialExecution, + executionSource, + executionUnavailableMessage, + apiBaseUrl, + userId, + chrome = "card", +}: ApprovalDetailProps) { + const [approval, setApproval] = useState(initialApproval); + const [execution, setExecution] = useState(initialExecution); + const [executionPreview, setExecutionPreview] = useState<ApprovalExecutionResponse | null>(null); + + useEffect(() => { + setApproval(initialApproval); + setExecution(initialExecution); + setExecutionPreview(null); + }, [initialApproval, initialExecution]); + + if (!approval) { + return ( + <SectionCard + eyebrow="Approval detail" + title="No approval selected" + description="Choose an approval from the inbox to inspect its governing request and resolution state." + className={chrome === "embedded" ? "section-card--embedded" : undefined} + > + <EmptyState + title="Approval inspector is idle" + description="No approval detail is available in the current route state." + /> + </SectionCard> + ); + } + + return ( + <SectionCard + eyebrow="Approval detail" + title={approval.tool.name} + description="Request detail, routing rationale, resolution, and execution review stay composed inside one bounded inspector." + className={chrome === "embedded" ? "section-card--embedded" : undefined} + > + <div className="detail-grid"> + <div className="detail-summary"> + <StatusBadge status={approval.status} /> + <span className="detail-summary__label"> + {approval.request.action} / {approval.request.scope} + </span> + </div> + + <dl className="key-value-grid"> + <div> + <dt>Thread</dt> + <dd className="mono">{approval.thread_id}</dd> + </div> + <div> + <dt>Task run</dt> + <dd className="mono">{approval.task_run_id ?? "Unlinked"}</dd> + </div> + <div> + <dt>Task step</dt> + <dd className="mono">{approval.task_step_id ?? "Unlinked"}</dd> + </div> + <div> + <dt>Routing decision</dt> + <dd>{approval.routing.decision}</dd> + </div> + <div> + <dt>Detail source</dt> + <dd>{detailSource === "live" ? "Live approval detail" : "Fixture detail fallback"}</dd> + </div> + <div> + <dt>Routing trace</dt> + <dd className="mono"> + {approval.routing.trace.trace_id} · {approval.routing.trace.trace_event_count} events + </dd> + </div> + <div> + <dt>Created</dt> + <dd>{formatDate(approval.created_at)}</dd> + </div> + </dl> + + <div className="detail-group"> + <h3>Request attributes</h3> + <div className="attribute-list"> + {Object.entries(approval.request.attributes).map(([key, value]) => ( + <span key={key} className="attribute-item"> + {key}: {formatAttributeValue(value)} + </span> + ))} + </div> + </div> + + <div className="detail-group"> + <h3>Routing rationale</h3> + <ul className="reason-list"> + {approval.routing.reasons.map((reason) => ( + <li key={`${reason.code}-${reason.message}`}>{reason.message}</li> + ))} + </ul> + </div> + + <div className="detail-group"> + <h3>Resolution</h3> + <p className="muted-copy"> + {approval.resolution + ? `Resolved ${formatDate(approval.resolution.resolved_at)} by ${approval.resolution.resolved_by_user_id}.` + : "Still awaiting explicit operator resolution."} + </p> + </div> + + <div className="detail-group"> + <h3>Approval action bar</h3> + <ApprovalActions + approval={approval} + hasExecution={Boolean(execution || executionPreview)} + apiBaseUrl={apiBaseUrl} + userId={userId} + onResolved={setApproval} + onExecuted={(payload) => { + setApproval(payload.approval); + setExecutionPreview(payload); + }} + /> + </div> + + <div className="detail-group"> + <h3>Execution review</h3> + <ExecutionSummary + execution={execution} + preview={executionPreview} + source={executionSource} + unavailableMessage={executionPreview ? null : executionUnavailableMessage} + emptyTitle="Approval is ready but not executed" + emptyDescription="Once an approved request is executed, the resulting record and output snapshot will appear here." + /> + </div> + + <div className="detail-group"> + <h3>Post-execution memory write-back</h3> + <WorkflowMemoryWritebackForm + execution={execution} + preview={executionPreview} + source={executionSource ?? null} + apiBaseUrl={apiBaseUrl} + userId={userId} + /> + </div> + </div> + </SectionCard> + ); +} diff --git a/apps/web/components/approval-list.tsx b/apps/web/components/approval-list.tsx new file mode 100644 index 0000000..9f57221 --- /dev/null +++ b/apps/web/components/approval-list.tsx @@ -0,0 +1,79 @@ +import Link from "next/link"; + +import type { ApprovalItem } from "../lib/api"; +import { EmptyState } from "./empty-state"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +function formatDate(value: string) { + return new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(value)); +} + +export function ApprovalList({ + items, + selectedId, +}: { + items: ApprovalItem[]; + selectedId?: string; +}) { + if (items.length === 0) { + return ( + <SectionCard + eyebrow="Approval inbox" + title="No approvals in view" + description="When approvals are created, they will appear here with the governing rationale attached." + > + <EmptyState + title="Approval inbox is empty" + description="There are no approval records to review in the current mode." + actionHref="/chat" + actionLabel="Open requests" + /> + </SectionCard> + ); + } + + return ( + <SectionCard + eyebrow="Approval inbox" + title="Requests awaiting review" + description="The inbox favors scanability first: tool, action, scope, and state are visible without opening every row." + > + <div className="list-panel"> + <div className="list-panel__header"> + <p>{items.length} total approvals</p> + </div> + <div className="list-rows"> + {items.map((item) => ( + <Link + key={item.id} + href={`/approvals?approval=${item.id}`} + className={`list-row${item.id === selectedId ? " is-selected" : ""}`} + > + <div className="list-row__topline"> + <div className="detail-stack"> + <span className="list-row__eyebrow">{formatDate(item.created_at)}</span> + <h3 className="list-row__title">{item.tool.name}</h3> + </div> + <StatusBadge status={item.status} /> + </div> + <p> + {item.request.action} / {item.request.scope} + </p> + <div className="list-row__meta"> + <span className="meta-pill">Thread {item.thread_id}</span> + {item.task_step_id ? <span className="meta-pill">Step linked</span> : null} + {item.request.risk_hint ? <span className="meta-pill">Risk {item.request.risk_hint}</span> : null} + </div> + </Link> + ))} + </div> + </div> + </SectionCard> + ); +} diff --git a/apps/web/components/artifact-chunk-list.test.tsx b/apps/web/components/artifact-chunk-list.test.tsx new file mode 100644 index 0000000..8cc1221 --- /dev/null +++ b/apps/web/components/artifact-chunk-list.test.tsx @@ -0,0 +1,68 @@ +import React from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import { ArtifactChunkList } from "./artifact-chunk-list"; + +describe("ArtifactChunkList", () => { + afterEach(() => { + cleanup(); + }); + + it("renders idle state when no artifact is selected", () => { + render( + <ArtifactChunkList + artifactId={null} + chunks={[]} + summary={null} + source={null} + />, + ); + + expect(screen.getByText("Chunk review is idle")).toBeInTheDocument(); + }); + + it("renders ordered chunk rows with sequence and evidence text", () => { + render( + <ArtifactChunkList + artifactId="artifact-1" + chunks={[ + { + id: "chunk-1", + task_artifact_id: "artifact-1", + sequence_no: 1, + char_start: 0, + char_end_exclusive: 12, + text: "alpha chunk", + created_at: "2026-03-18T00:00:00Z", + updated_at: "2026-03-18T00:00:00Z", + }, + { + id: "chunk-2", + task_artifact_id: "artifact-1", + sequence_no: 2, + char_start: 12, + char_end_exclusive: 26, + text: "beta chunk", + created_at: "2026-03-18T00:00:01Z", + updated_at: "2026-03-18T00:00:01Z", + }, + ]} + summary={{ + total_count: 2, + total_characters: 26, + media_type: "text/markdown", + chunking_rule: "artifact_ingestion_v0", + order: ["sequence_no_asc", "id_asc"], + }} + source="fixture" + />, + ); + + expect(screen.getByText("Chunk 1")).toBeInTheDocument(); + expect(screen.getByText("Chunk 2")).toBeInTheDocument(); + expect(screen.getByText("alpha chunk")).toBeInTheDocument(); + expect(screen.getByText("beta chunk")).toBeInTheDocument(); + expect(screen.getByText("Fixture chunks")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/artifact-chunk-list.tsx b/apps/web/components/artifact-chunk-list.tsx new file mode 100644 index 0000000..f7c169a --- /dev/null +++ b/apps/web/components/artifact-chunk-list.tsx @@ -0,0 +1,124 @@ +import type { ApiSource, TaskArtifactChunkListSummary, TaskArtifactChunkRecord } from "../lib/api"; +import { EmptyState } from "./empty-state"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +type ArtifactChunkListProps = { + artifactId: string | null; + chunks: TaskArtifactChunkRecord[]; + summary: TaskArtifactChunkListSummary | null; + source: ApiSource | "unavailable" | null; + unavailableReason?: string; +}; + +function chunkLength(chunk: TaskArtifactChunkRecord) { + return Math.max(0, chunk.char_end_exclusive - chunk.char_start); +} + +export function ArtifactChunkList({ + artifactId, + chunks, + summary, + source, + unavailableReason, +}: ArtifactChunkListProps) { + if (!artifactId) { + return ( + <SectionCard + eyebrow="Chunk review" + title="No artifact selected" + description="Select one artifact to inspect ordered persisted chunks and evidence text." + > + <EmptyState + title="Chunk review is idle" + description="Choose one artifact from the list to inspect ordered chunk rows." + /> + </SectionCard> + ); + } + + if (source === "unavailable") { + return ( + <SectionCard + eyebrow="Chunk review" + title="Chunk review unavailable" + description="The selected artifact loaded, but chunk rows are currently unavailable." + > + <div className="detail-stack"> + <StatusBadge status="unavailable" label="Chunks unavailable" /> + {unavailableReason ? ( + <div className="execution-summary__note execution-summary__note--danger"> + <p className="execution-summary__label">Chunk read</p> + <p>{unavailableReason}</p> + </div> + ) : null} + </div> + </SectionCard> + ); + } + + if (chunks.length === 0) { + return ( + <SectionCard + eyebrow="Chunk review" + title="No persisted chunks" + description="The selected artifact has no persisted chunk rows yet." + > + <EmptyState + title="Chunk list is empty" + description="Chunk rows will appear here after artifact ingestion persists chunked evidence." + /> + </SectionCard> + ); + } + + return ( + <SectionCard + eyebrow="Chunk review" + title="Ordered persisted chunks" + description="Read chunk rows in stored order with explicit character ranges and bounded evidence text." + > + <div className="list-panel"> + <div className="list-panel__header"> + <div className="cluster"> + <StatusBadge + status={source ?? "unavailable"} + label={ + source === "live" + ? "Live chunks" + : source === "fixture" + ? "Fixture chunks" + : "Chunks unavailable" + } + /> + {summary ? <span className="meta-pill">{summary.total_count} total</span> : null} + {summary ? <span className="meta-pill">{summary.total_characters} chars</span> : null} + {summary ? <span className="meta-pill">{summary.media_type}</span> : null} + </div> + </div> + + {unavailableReason ? <p className="responsive-note">Live chunk read failed: {unavailableReason}</p> : null} + + <div className="list-rows"> + {chunks.map((chunk) => ( + <article key={chunk.id} className="list-row" aria-label={`Chunk ${chunk.sequence_no}`}> + <div className="list-row__topline"> + <h3 className="list-row__title">Chunk {chunk.sequence_no}</h3> + <StatusBadge status="info" label={`${chunkLength(chunk)} chars`} /> + </div> + + <div className="list-row__meta"> + <span className="meta-pill mono">{chunk.id}</span> + <span className="meta-pill"> + {chunk.char_start} to {chunk.char_end_exclusive} + </span> + </div> + + <pre className="artifact-chunk__text">{chunk.text}</pre> + </article> + ))} + </div> + </div> + </SectionCard> + ); +} diff --git a/apps/web/components/artifact-detail.tsx b/apps/web/components/artifact-detail.tsx new file mode 100644 index 0000000..399d1d5 --- /dev/null +++ b/apps/web/components/artifact-detail.tsx @@ -0,0 +1,103 @@ +import type { ApiSource, TaskArtifactRecord } from "../lib/api"; +import { EmptyState } from "./empty-state"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +type ArtifactDetailProps = { + artifact: TaskArtifactRecord | null; + source: ApiSource | "unavailable" | null; + unavailableReason?: string; +}; + +function formatDate(value: string) { + return new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(value)); +} + +export function ArtifactDetail({ artifact, source, unavailableReason }: ArtifactDetailProps) { + if (!artifact) { + return ( + <SectionCard + eyebrow="Selected artifact" + title="No artifact selected" + description="Select one artifact from the list to inspect metadata, ingestion status, and rooted path context." + > + <EmptyState + title="Artifact inspector is idle" + description="Choose one artifact from the bounded list to review detail." + /> + </SectionCard> + ); + } + + return ( + <SectionCard + eyebrow="Selected artifact" + title={artifact.relative_path} + description="Artifact detail keeps registration metadata and ingestion status explicit before workspace and chunk review." + > + <div className="detail-grid"> + <div className="detail-summary"> + <StatusBadge status={artifact.status} /> + <StatusBadge status={artifact.ingestion_status} /> + <StatusBadge + status={source ?? "unavailable"} + label={ + source === "live" + ? "Live detail" + : source === "fixture" + ? "Fixture detail" + : "Detail unavailable" + } + /> + </div> + + {unavailableReason ? ( + <div className="execution-summary__note execution-summary__note--danger"> + <p className="execution-summary__label">Detail read</p> + <p>{unavailableReason}</p> + </div> + ) : null} + + <dl className="key-value-grid key-value-grid--compact"> + <div> + <dt>Artifact ID</dt> + <dd className="mono">{artifact.id}</dd> + </div> + <div> + <dt>Task ID</dt> + <dd className="mono">{artifact.task_id}</dd> + </div> + <div> + <dt>Workspace ID</dt> + <dd className="mono">{artifact.task_workspace_id}</dd> + </div> + <div> + <dt>Media type hint</dt> + <dd>{artifact.media_type_hint ?? "None"}</dd> + </div> + <div> + <dt>Created</dt> + <dd>{formatDate(artifact.created_at)}</dd> + </div> + <div> + <dt>Updated</dt> + <dd>{formatDate(artifact.updated_at)}</dd> + </div> + </dl> + + <div className="detail-group detail-group--muted"> + <h3>Rooted path summary</h3> + <p className="mono">{artifact.relative_path}</p> + <p className="muted-copy"> + This path is stored as a workspace-rooted relative path and remains read-only inside this review route. + </p> + </div> + </div> + </SectionCard> + ); +} diff --git a/apps/web/components/artifact-list.test.tsx b/apps/web/components/artifact-list.test.tsx new file mode 100644 index 0000000..80ff4a2 --- /dev/null +++ b/apps/web/components/artifact-list.test.tsx @@ -0,0 +1,84 @@ +import React from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { ArtifactList } from "./artifact-list"; + +vi.mock("next/link", () => ({ + default: ({ + href, + children, + className, + "aria-current": ariaCurrent, + }: { + href: string; + children: React.ReactNode; + className?: string; + "aria-current"?: string; + }) => ( + <a href={href} className={className} aria-current={ariaCurrent}> + {children} + </a> + ), +})); + +const baseArtifacts = [ + { + id: "artifact-1", + task_id: "task-1", + task_workspace_id: "workspace-1", + status: "registered" as const, + ingestion_status: "ingested" as const, + relative_path: "docs/a.md", + media_type_hint: "text/markdown", + created_at: "2026-03-18T10:00:00Z", + updated_at: "2026-03-18T10:10:00Z", + }, + { + id: "artifact-2", + task_id: "task-2", + task_workspace_id: "workspace-2", + status: "registered" as const, + ingestion_status: "pending" as const, + relative_path: "gmail/mail.eml", + media_type_hint: "message/rfc822", + created_at: "2026-03-18T11:00:00Z", + updated_at: "2026-03-18T11:05:00Z", + }, +]; + +describe("ArtifactList", () => { + afterEach(() => { + cleanup(); + }); + + it("renders artifact links that preserve selected artifact state", () => { + render( + <ArtifactList + artifacts={baseArtifacts} + selectedArtifactId="artifact-2" + summary={null} + source="live" + />, + ); + + expect(screen.getByRole("link", { name: /docs\/a.md/i })).toHaveAttribute( + "href", + "/artifacts?artifact=artifact-1", + ); + expect(screen.getByRole("link", { name: /gmail\/mail.eml/i })).toHaveAttribute( + "href", + "/artifacts?artifact=artifact-2", + ); + expect(screen.getByRole("link", { name: /gmail\/mail.eml/i })).toHaveAttribute( + "aria-current", + "page", + ); + }); + + it("renders empty state when artifact list is empty", () => { + render(<ArtifactList artifacts={[]} selectedArtifactId="" summary={null} source="fixture" />); + + expect(screen.getByText("No persisted artifacts")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/artifact-list.tsx b/apps/web/components/artifact-list.tsx new file mode 100644 index 0000000..c177c01 --- /dev/null +++ b/apps/web/components/artifact-list.tsx @@ -0,0 +1,103 @@ +import Link from "next/link"; + +import type { ApiSource, TaskArtifactListSummary, TaskArtifactRecord } from "../lib/api"; +import { EmptyState } from "./empty-state"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +type ArtifactListProps = { + artifacts: TaskArtifactRecord[]; + selectedArtifactId?: string; + summary: TaskArtifactListSummary | null; + source: ApiSource | "unavailable"; + unavailableReason?: string; +}; + +function formatDate(value: string) { + return new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(value)); +} + +function artifactHref(taskArtifactId: string) { + return `/artifacts?artifact=${encodeURIComponent(taskArtifactId)}`; +} + +export function ArtifactList({ + artifacts, + selectedArtifactId, + summary, + source, + unavailableReason, +}: ArtifactListProps) { + if (artifacts.length === 0) { + return ( + <SectionCard + eyebrow="Artifact list" + title="No artifacts available" + description="No task artifacts are currently available in this bounded review workspace." + > + <EmptyState + title="No persisted artifacts" + description="Artifacts will appear here once task workspaces register and persist reviewable files." + /> + </SectionCard> + ); + } + + return ( + <SectionCard + eyebrow="Artifact list" + title="Persisted task artifacts" + description="Select one artifact to inspect detail, linked workspace context, and ordered chunk evidence." + > + <div className="list-panel"> + <div className="list-panel__header"> + <div className="cluster"> + <StatusBadge + status={source} + label={ + source === "live" + ? "Live list" + : source === "fixture" + ? "Fixture list" + : "List unavailable" + } + /> + {summary ? <span className="meta-pill">{summary.total_count} total</span> : null} + </div> + </div> + + {unavailableReason ? <p className="responsive-note">Live list read failed: {unavailableReason}</p> : null} + + <div className="list-rows"> + {artifacts.map((artifact) => ( + <Link + key={artifact.id} + href={artifactHref(artifact.id)} + className={`list-row${artifact.id === selectedArtifactId ? " is-selected" : ""}`} + aria-current={artifact.id === selectedArtifactId ? "page" : undefined} + > + <div className="list-row__topline"> + <div className="detail-stack"> + <span className="list-row__eyebrow">{formatDate(artifact.updated_at)}</span> + <h3 className="list-row__title">{artifact.relative_path}</h3> + </div> + <StatusBadge status={artifact.ingestion_status} /> + </div> + + <div className="list-row__meta"> + <span className="meta-pill mono">{artifact.id}</span> + <span className="meta-pill">Task {artifact.task_id}</span> + <span className="meta-pill">Workspace {artifact.task_workspace_id}</span> + </div> + </Link> + ))} + </div> + </div> + </SectionCard> + ); +} diff --git a/apps/web/components/artifact-workspace-summary.tsx b/apps/web/components/artifact-workspace-summary.tsx new file mode 100644 index 0000000..264637a --- /dev/null +++ b/apps/web/components/artifact-workspace-summary.tsx @@ -0,0 +1,136 @@ +import type { ApiSource, TaskArtifactRecord, TaskWorkspaceRecord } from "../lib/api"; +import { EmptyState } from "./empty-state"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +type ArtifactWorkspaceSummaryProps = { + artifact: TaskArtifactRecord | null; + workspace: TaskWorkspaceRecord | null; + source: ApiSource | "unavailable" | null; + unavailableReason?: string; +}; + +function formatDate(value: string) { + return new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(value)); +} + +function formatRootedPath(workspacePath: string, relativePath: string) { + return `${workspacePath.replace(/\/$/, "")}/${relativePath.replace(/^\//, "")}`; +} + +export function ArtifactWorkspaceSummary({ + artifact, + workspace, + source, + unavailableReason, +}: ArtifactWorkspaceSummaryProps) { + if (!artifact) { + return ( + <SectionCard + eyebrow="Linked workspace" + title="No artifact selected" + description="Select one artifact to inspect the linked task workspace and rooted path context." + > + <EmptyState + title="Workspace summary is idle" + description="Choose one artifact from the list to review its linked task workspace." + /> + </SectionCard> + ); + } + + if (source === "unavailable") { + return ( + <SectionCard + eyebrow="Linked workspace" + title="Workspace summary unavailable" + description="The selected artifact loaded, but linked task workspace detail is currently unavailable." + > + <div className="detail-stack"> + <StatusBadge status="unavailable" label="Workspace unavailable" /> + {unavailableReason ? ( + <div className="execution-summary__note execution-summary__note--danger"> + <p className="execution-summary__label">Workspace read</p> + <p>{unavailableReason}</p> + </div> + ) : null} + </div> + </SectionCard> + ); + } + + if (!workspace) { + return ( + <SectionCard + eyebrow="Linked workspace" + title="No linked workspace" + description="No task workspace metadata is currently available for the selected artifact." + > + <EmptyState + title="Workspace metadata missing" + description="Workspace metadata will appear here once the selected artifact has a visible linked workspace." + /> + </SectionCard> + ); + } + + return ( + <SectionCard + eyebrow="Linked workspace" + title="Task workspace summary" + description="Review the linked task workspace identity and rooted local path before reading chunk evidence." + > + <div className="detail-grid"> + <div className="detail-summary"> + <StatusBadge status={workspace.status} /> + <StatusBadge + status={source ?? "unavailable"} + label={ + source === "live" + ? "Live workspace" + : source === "fixture" + ? "Fixture workspace" + : "Workspace unavailable" + } + /> + </div> + + {unavailableReason ? ( + <p className="responsive-note">Live workspace read failed: {unavailableReason}</p> + ) : null} + + <dl className="key-value-grid key-value-grid--compact"> + <div> + <dt>Workspace ID</dt> + <dd className="mono">{workspace.id}</dd> + </div> + <div> + <dt>Task ID</dt> + <dd className="mono">{workspace.task_id}</dd> + </div> + <div> + <dt>Workspace root</dt> + <dd className="mono">{workspace.local_path}</dd> + </div> + <div> + <dt>Rooted artifact path</dt> + <dd className="mono">{formatRootedPath(workspace.local_path, artifact.relative_path)}</dd> + </div> + <div> + <dt>Created</dt> + <dd>{formatDate(workspace.created_at)}</dd> + </div> + <div> + <dt>Updated</dt> + <dd>{formatDate(workspace.updated_at)}</dd> + </div> + </dl> + </div> + </SectionCard> + ); +} diff --git a/apps/web/components/calendar-account-connect-form.tsx b/apps/web/components/calendar-account-connect-form.tsx new file mode 100644 index 0000000..0c4a33a --- /dev/null +++ b/apps/web/components/calendar-account-connect-form.tsx @@ -0,0 +1,196 @@ +"use client"; + +import type { FormEvent } from "react"; +import { useState } from "react"; + +import { useRouter } from "next/navigation"; + +import { connectCalendarAccount } from "../lib/api"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +type CalendarAccountConnectFormProps = { + apiBaseUrl?: string; + userId?: string; +}; + +const CALENDAR_READONLY_SCOPE = "https://www.googleapis.com/auth/calendar.readonly" as const; + +export function CalendarAccountConnectForm({ + apiBaseUrl, + userId, +}: CalendarAccountConnectFormProps) { + const router = useRouter(); + const liveModeReady = Boolean(apiBaseUrl && userId); + + const [providerAccountId, setProviderAccountId] = useState(""); + const [emailAddress, setEmailAddress] = useState(""); + const [displayName, setDisplayName] = useState(""); + const [accessToken, setAccessToken] = useState(""); + + const [isSubmitting, setIsSubmitting] = useState(false); + const [statusTone, setStatusTone] = useState<"info" | "success" | "danger">("info"); + const [statusText, setStatusText] = useState( + liveModeReady + ? "Enter one account at a time, including the secret-bearing access token, then connect." + : "Calendar connect is unavailable until live API configuration is present.", + ); + + const canSubmit = liveModeReady && !isSubmitting; + + async function handleSubmit(event: FormEvent<HTMLFormElement>) { + event.preventDefault(); + + if (!apiBaseUrl || !userId) { + setStatusTone("info"); + setStatusText("Calendar connect is unavailable until live API configuration is present."); + return; + } + + setIsSubmitting(true); + setStatusTone("info"); + setStatusText("Connecting Calendar account..."); + + try { + const payload = await connectCalendarAccount(apiBaseUrl, { + user_id: userId, + provider_account_id: providerAccountId.trim(), + email_address: emailAddress.trim(), + display_name: displayName.trim() ? displayName.trim() : null, + scope: CALENDAR_READONLY_SCOPE, + access_token: accessToken.trim(), + }); + + setStatusTone("success"); + setStatusText(`Connected ${payload.account.email_address}.`); + setAccessToken(""); + router.push(`/calendar?account=${encodeURIComponent(payload.account.id)}`); + router.refresh(); + } catch (error) { + const detail = error instanceof Error ? error.message : "Connection failed"; + setStatusTone("danger"); + setStatusText(`Unable to connect account: ${detail}`); + } finally { + setIsSubmitting(false); + } + } + + return ( + <SectionCard + eyebrow="Connect account" + title="Add Calendar account" + description="Connection is explicit and bounded to the shipped read-only scope with one secret-bearing credential field." + > + <form className="detail-stack" onSubmit={handleSubmit}> + <div className="form-field-group form-field-group--two-up"> + <div className="form-field"> + <label htmlFor="calendar-provider-account-id">Provider account ID</label> + <input + id="calendar-provider-account-id" + name="calendar-provider-account-id" + value={providerAccountId} + onChange={(event) => setProviderAccountId(event.target.value)} + placeholder="acct-owner-001" + required + disabled={!liveModeReady || isSubmitting} + /> + </div> + <div className="form-field"> + <label htmlFor="calendar-email-address">Email address</label> + <input + id="calendar-email-address" + name="calendar-email-address" + value={emailAddress} + onChange={(event) => setEmailAddress(event.target.value)} + placeholder="owner@gmail.example" + required + disabled={!liveModeReady || isSubmitting} + /> + </div> + </div> + + <div className="form-field-group form-field-group--two-up"> + <div className="form-field"> + <label htmlFor="calendar-display-name">Display name (optional)</label> + <input + id="calendar-display-name" + name="calendar-display-name" + value={displayName} + onChange={(event) => setDisplayName(event.target.value)} + placeholder="Owner" + disabled={!liveModeReady || isSubmitting} + /> + </div> + <div className="form-field"> + <label htmlFor="calendar-scope">Scope</label> + <input + id="calendar-scope" + name="calendar-scope" + value={CALENDAR_READONLY_SCOPE} + readOnly + className="mono" + disabled + /> + </div> + </div> + + <div className="governance-banner"> + <strong>Credential handling</strong> + <span> + The access token is submitted only through the shipped connect endpoint and is not + surfaced in account metadata reads. + </span> + </div> + + <div className="form-field"> + <label htmlFor="calendar-access-token">Access token</label> + <input + id="calendar-access-token" + name="calendar-access-token" + type="password" + value={accessToken} + onChange={(event) => setAccessToken(event.target.value)} + placeholder="Enter Google Calendar OAuth access token" + autoComplete="off" + required + disabled={!liveModeReady || isSubmitting} + /> + </div> + + <div className="composer-actions"> + <div className="composer-status" aria-live="polite"> + <StatusBadge + status={ + isSubmitting + ? "submitting" + : statusTone === "success" + ? "success" + : statusTone === "danger" + ? "error" + : liveModeReady + ? "info" + : "unavailable" + } + label={ + isSubmitting + ? "Submitting" + : statusTone === "success" + ? "Connected" + : statusTone === "danger" + ? "Error" + : liveModeReady + ? "Ready" + : "Unavailable" + } + /> + <span>{statusText}</span> + </div> + + <button type="submit" className="button" disabled={!canSubmit}> + Connect Calendar account + </button> + </div> + </form> + </SectionCard> + ); +} diff --git a/apps/web/components/calendar-account-detail.tsx b/apps/web/components/calendar-account-detail.tsx new file mode 100644 index 0000000..9105814 --- /dev/null +++ b/apps/web/components/calendar-account-detail.tsx @@ -0,0 +1,125 @@ +import type { ApiSource, CalendarAccountRecord } from "../lib/api"; +import { EmptyState } from "./empty-state"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +type CalendarAccountDetailProps = { + account: CalendarAccountRecord | null; + source: ApiSource | "unavailable" | null; + unavailableReason?: string; +}; + +function formatDate(value: string) { + return new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(value)); +} + +export function CalendarAccountDetail({ + account, + source, + unavailableReason, +}: CalendarAccountDetailProps) { + if (source === "unavailable") { + return ( + <SectionCard + eyebrow="Selected account" + title="Account detail unavailable" + description="The account list loaded, but selected account detail is currently unavailable." + > + <div className="detail-stack"> + <StatusBadge status="unavailable" label="Detail unavailable" /> + {unavailableReason ? ( + <div className="execution-summary__note execution-summary__note--danger"> + <p className="execution-summary__label">Account detail read</p> + <p>{unavailableReason}</p> + </div> + ) : null} + </div> + </SectionCard> + ); + } + + if (!account) { + return ( + <SectionCard + eyebrow="Selected account" + title="No account selected" + description="Select one connected Calendar account to inspect metadata and scope summary." + > + <EmptyState + title="Account detail is idle" + description="Choose one account from the list to open the bounded detail panel." + /> + </SectionCard> + ); + } + + return ( + <SectionCard + eyebrow="Selected account" + title={account.email_address} + description="Account detail stays bounded to connector metadata and scope without expanding into event browsing or scheduling actions." + > + <div className="detail-grid"> + <div className="detail-summary"> + <StatusBadge status={account.provider} /> + <StatusBadge status={account.auth_kind} /> + <StatusBadge + status={source ?? "unavailable"} + label={ + source === "live" + ? "Live detail" + : source === "fixture" + ? "Fixture detail" + : "Detail unavailable" + } + /> + </div> + + {unavailableReason ? ( + <p className="responsive-note">Live account detail read failed: {unavailableReason}</p> + ) : null} + + <dl className="key-value-grid key-value-grid--compact"> + <div> + <dt>Account ID</dt> + <dd className="mono">{account.id}</dd> + </div> + <div> + <dt>Provider account ID</dt> + <dd className="mono">{account.provider_account_id}</dd> + </div> + <div> + <dt>Email address</dt> + <dd>{account.email_address}</dd> + </div> + <div> + <dt>Display name</dt> + <dd>{account.display_name ?? "None"}</dd> + </div> + <div> + <dt>Scope</dt> + <dd className="mono">{account.scope}</dd> + </div> + <div> + <dt>Updated</dt> + <dd>{formatDate(account.updated_at)}</dd> + </div> + </dl> + + <div className="detail-group detail-group--muted"> + <h3>Scope summary</h3> + <p className="muted-copy"> + This route uses the shipped read-only Calendar account seam and explicit single-event + ingestion seam only. It does not expand into event listing, search, sync, recurrence, + or write actions. + </p> + </div> + </div> + </SectionCard> + ); +} diff --git a/apps/web/components/calendar-account-list.test.tsx b/apps/web/components/calendar-account-list.test.tsx new file mode 100644 index 0000000..f8cc82c --- /dev/null +++ b/apps/web/components/calendar-account-list.test.tsx @@ -0,0 +1,84 @@ +import React from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { CalendarAccountList } from "./calendar-account-list"; + +vi.mock("next/link", () => ({ + default: ({ + href, + children, + className, + "aria-current": ariaCurrent, + }: { + href: string; + children: React.ReactNode; + className?: string; + "aria-current"?: string; + }) => ( + <a href={href} className={className} aria-current={ariaCurrent}> + {children} + </a> + ), +})); + +const baseAccounts = [ + { + id: "calendar-account-1", + provider: "google_calendar", + auth_kind: "oauth_access_token", + provider_account_id: "acct-owner-001", + email_address: "owner@gmail.example", + display_name: "Owner", + scope: "https://www.googleapis.com/auth/calendar.readonly" as const, + created_at: "2026-03-18T10:00:00Z", + updated_at: "2026-03-18T10:00:00Z", + }, + { + id: "calendar-account-2", + provider: "google_calendar", + auth_kind: "oauth_access_token", + provider_account_id: "acct-ops-002", + email_address: "ops@gmail.example", + display_name: "Ops", + scope: "https://www.googleapis.com/auth/calendar.readonly" as const, + created_at: "2026-03-18T11:00:00Z", + updated_at: "2026-03-18T11:00:00Z", + }, +]; + +describe("CalendarAccountList", () => { + afterEach(() => { + cleanup(); + }); + + it("renders account links that preserve selected account state", () => { + render( + <CalendarAccountList + accounts={baseAccounts} + selectedAccountId="calendar-account-2" + summary={null} + source="live" + />, + ); + + expect(screen.getByRole("link", { name: /owner@gmail.example/i })).toHaveAttribute( + "href", + "/calendar?account=calendar-account-1", + ); + expect(screen.getByRole("link", { name: /ops@gmail.example/i })).toHaveAttribute( + "href", + "/calendar?account=calendar-account-2", + ); + expect(screen.getByRole("link", { name: /ops@gmail.example/i })).toHaveAttribute( + "aria-current", + "page", + ); + }); + + it("renders empty state when no Calendar accounts are available", () => { + render(<CalendarAccountList accounts={[]} selectedAccountId="" summary={null} source="fixture" />); + + expect(screen.getByText("No connected accounts")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/calendar-account-list.tsx b/apps/web/components/calendar-account-list.tsx new file mode 100644 index 0000000..82ab545 --- /dev/null +++ b/apps/web/components/calendar-account-list.tsx @@ -0,0 +1,122 @@ +import Link from "next/link"; + +import type { ApiSource, CalendarAccountListSummary, CalendarAccountRecord } from "../lib/api"; +import { EmptyState } from "./empty-state"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +type CalendarAccountListProps = { + accounts: CalendarAccountRecord[]; + selectedAccountId?: string; + summary: CalendarAccountListSummary | null; + source: ApiSource | "unavailable"; + unavailableReason?: string; +}; + +function formatDate(value: string) { + return new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(value)); +} + +function calendarAccountHref(calendarAccountId: string) { + return `/calendar?account=${encodeURIComponent(calendarAccountId)}`; +} + +export function CalendarAccountList({ + accounts, + selectedAccountId, + summary, + source, + unavailableReason, +}: CalendarAccountListProps) { + if (source === "unavailable" && accounts.length === 0) { + return ( + <SectionCard + eyebrow="Account list" + title="Calendar account list unavailable" + description="Connected Calendar accounts could not be loaded in the current workspace state." + > + <div className="detail-stack"> + <StatusBadge status="unavailable" label="List unavailable" /> + {unavailableReason ? ( + <p className="responsive-note">Calendar account list read failed: {unavailableReason}</p> + ) : null} + </div> + </SectionCard> + ); + } + + if (accounts.length === 0) { + return ( + <SectionCard + eyebrow="Account list" + title="No Calendar accounts connected" + description="Connect one Calendar account to enable bounded account review and selected-event ingestion." + > + <EmptyState + title="No connected accounts" + description="Use the connect form to add one Calendar account through the shipped read-only connector seam." + /> + </SectionCard> + ); + } + + return ( + <SectionCard + eyebrow="Account list" + title="Connected Calendar accounts" + description="Select one account to inspect metadata, scope, and bounded ingestion controls." + > + <div className="list-panel"> + <div className="list-panel__header"> + <div className="cluster"> + <StatusBadge + status={source} + label={ + source === "live" + ? "Live list" + : source === "fixture" + ? "Fixture list" + : "List unavailable" + } + /> + {summary ? <span className="meta-pill">{summary.total_count} total</span> : null} + </div> + </div> + + {unavailableReason ? ( + <p className="responsive-note">Live account list read failed: {unavailableReason}</p> + ) : null} + + <div className="list-rows"> + {accounts.map((account) => ( + <Link + key={account.id} + href={calendarAccountHref(account.id)} + className={`list-row${account.id === selectedAccountId ? " is-selected" : ""}`} + aria-current={account.id === selectedAccountId ? "page" : undefined} + > + <div className="list-row__topline"> + <div className="detail-stack"> + <span className="list-row__eyebrow">{formatDate(account.updated_at)}</span> + <h3 className="list-row__title">{account.email_address}</h3> + </div> + <StatusBadge status={account.provider} /> + </div> + + <div className="list-row__meta"> + <span className="meta-pill mono">{account.id}</span> + <span className="meta-pill mono">{account.provider_account_id}</span> + <span className="meta-pill">{account.display_name ?? "No display name"}</span> + </div> + </Link> + ))} + </div> + </div> + </SectionCard> + ); +} diff --git a/apps/web/components/calendar-event-ingest-form.test.tsx b/apps/web/components/calendar-event-ingest-form.test.tsx new file mode 100644 index 0000000..cb2fcc8 --- /dev/null +++ b/apps/web/components/calendar-event-ingest-form.test.tsx @@ -0,0 +1,157 @@ +import React from "react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { CalendarEventIngestForm } from "./calendar-event-ingest-form"; + +const { ingestCalendarEventMock, refreshMock } = vi.hoisted(() => ({ + ingestCalendarEventMock: vi.fn(), + refreshMock: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + refresh: refreshMock, + }), +})); + +vi.mock("../lib/api", async () => { + const actual = await vi.importActual("../lib/api"); + return { + ...actual, + ingestCalendarEvent: ingestCalendarEventMock, + }; +}); + +const baseAccount = { + id: "calendar-account-1", + provider: "google_calendar", + auth_kind: "oauth_access_token", + provider_account_id: "acct-owner-001", + email_address: "owner@gmail.example", + display_name: "Owner", + scope: "https://www.googleapis.com/auth/calendar.readonly" as const, + created_at: "2026-03-18T10:00:00Z", + updated_at: "2026-03-18T10:00:00Z", +}; + +const baseWorkspaces = [ + { + id: "workspace-1", + task_id: "task-1", + status: "active" as const, + local_path: "/tmp/task-workspaces/task-1", + created_at: "2026-03-18T10:00:00Z", + updated_at: "2026-03-18T10:00:00Z", + }, +]; + +describe("CalendarEventIngestForm", () => { + beforeEach(() => { + ingestCalendarEventMock.mockReset(); + refreshMock.mockReset(); + }); + + afterEach(() => { + cleanup(); + }); + + it("submits selected-event ingestion through the shipped endpoint when live mode is available", async () => { + ingestCalendarEventMock.mockResolvedValue({ + account: baseAccount, + event: { + provider_event_id: "evt-001", + artifact_relative_path: "calendar/acct-owner-001/evt-001.txt", + media_type: "text/plain", + }, + artifact: { + id: "artifact-1", + task_id: "task-1", + task_workspace_id: "workspace-1", + status: "registered", + ingestion_status: "ingested", + relative_path: "calendar/acct-owner-001/evt-001.txt", + media_type_hint: "text/plain", + created_at: "2026-03-18T10:10:00Z", + updated_at: "2026-03-18T10:11:00Z", + }, + summary: { + total_count: 1, + total_characters: 256, + media_type: "text/plain", + chunking_rule: "normalized_utf8_text_fixed_window_1000_chars_v1", + order: ["sequence_no_asc", "id_asc"], + }, + }); + + render( + <CalendarEventIngestForm + account={baseAccount} + accountSource="live" + selectedProviderEventId="evt-001" + selectedEventSource="live" + taskWorkspaces={baseWorkspaces} + taskWorkspaceSource="live" + apiBaseUrl="https://api.example.com" + userId="user-1" + />, + ); + + fireEvent.click(screen.getByRole("button", { name: "Ingest selected event" })); + + await waitFor(() => { + expect(ingestCalendarEventMock).toHaveBeenCalledWith( + "https://api.example.com", + "calendar-account-1", + "evt-001", + { + user_id: "user-1", + task_workspace_id: "workspace-1", + }, + ); + }); + + expect(refreshMock).toHaveBeenCalled(); + expect(screen.getByText(/Ingestion completed\./i)).toBeInTheDocument(); + }); + + it("keeps ingestion disabled when live prerequisites are unavailable", () => { + render( + <CalendarEventIngestForm + account={baseAccount} + accountSource="fixture" + selectedProviderEventId="evt-001" + selectedEventSource="fixture" + taskWorkspaces={baseWorkspaces} + taskWorkspaceSource="fixture" + />, + ); + + expect(screen.getByRole("button", { name: "Ingest selected event" })).toBeDisabled(); + expect( + screen.getByText( + "Event ingestion is unavailable until live API configuration, live account detail, and live task workspace list are present.", + ), + ).toBeInTheDocument(); + expect(ingestCalendarEventMock).not.toHaveBeenCalled(); + }); + + it("keeps ingestion disabled when no discovered event is selected", () => { + render( + <CalendarEventIngestForm + account={baseAccount} + accountSource="live" + selectedProviderEventId="" + selectedEventSource="live" + taskWorkspaces={baseWorkspaces} + taskWorkspaceSource="live" + apiBaseUrl="https://api.example.com" + userId="user-1" + />, + ); + + expect(screen.getByRole("button", { name: "Ingest selected event" })).toBeDisabled(); + expect(screen.getByText("Select one discovered event before submitting ingestion.")).toBeInTheDocument(); + expect(ingestCalendarEventMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/components/calendar-event-ingest-form.tsx b/apps/web/components/calendar-event-ingest-form.tsx new file mode 100644 index 0000000..42d5fcc --- /dev/null +++ b/apps/web/components/calendar-event-ingest-form.tsx @@ -0,0 +1,312 @@ +"use client"; + +import type { FormEvent } from "react"; +import { useEffect, useMemo, useState } from "react"; + +import { useRouter } from "next/navigation"; + +import type { + ApiSource, + CalendarAccountRecord, + CalendarEventIngestionResponse, + TaskWorkspaceRecord, +} from "../lib/api"; +import { ingestCalendarEvent } from "../lib/api"; +import { EmptyState } from "./empty-state"; +import { CalendarIngestionSummary } from "./calendar-ingestion-summary"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +type CalendarEventIngestFormProps = { + account: CalendarAccountRecord | null; + accountSource: ApiSource | "unavailable" | null; + selectedProviderEventId: string; + selectedEventSource: ApiSource | "unavailable" | null; + taskWorkspaces: TaskWorkspaceRecord[]; + taskWorkspaceSource: ApiSource | "unavailable"; + apiBaseUrl?: string; + userId?: string; +}; + +export function CalendarEventIngestForm({ + account, + accountSource, + selectedProviderEventId, + selectedEventSource, + taskWorkspaces, + taskWorkspaceSource, + apiBaseUrl, + userId, +}: CalendarEventIngestFormProps) { + const router = useRouter(); + + const [taskWorkspaceId, setTaskWorkspaceId] = useState(taskWorkspaces[0]?.id ?? ""); + const [result, setResult] = useState<CalendarEventIngestionResponse | null>(null); + const [resultSource, setResultSource] = useState<ApiSource | "unavailable" | null>(null); + const [resultUnavailableReason, setResultUnavailableReason] = useState<string | undefined>(undefined); + const [isSubmitting, setIsSubmitting] = useState(false); + const [statusTone, setStatusTone] = useState<"info" | "success" | "danger">("info"); + const hasSelectedEvent = Boolean(selectedProviderEventId.trim()); + + const liveModeReady = useMemo( + () => + Boolean( + account && + accountSource === "live" && + apiBaseUrl && + userId && + taskWorkspaceSource === "live" && + taskWorkspaces.length > 0, + ), + [account, accountSource, apiBaseUrl, userId, taskWorkspaceSource, taskWorkspaces.length], + ); + + const [statusText, setStatusText] = useState( + !account + ? "Select a Calendar account to enable single-event ingestion." + : taskWorkspaces.length === 0 + ? "No task workspace is available for ingestion target selection." + : !hasSelectedEvent + ? "Select one discovered event before submitting ingestion." + : liveModeReady + ? "Select one task workspace to ingest the discovered event." + : "Event ingestion is unavailable until live API configuration, live account detail, and live task workspace list are present.", + ); + + useEffect(() => { + const hasWorkspace = taskWorkspaces.some((workspace) => workspace.id === taskWorkspaceId); + if (!hasWorkspace) { + setTaskWorkspaceId(taskWorkspaces[0]?.id ?? ""); + } + }, [taskWorkspaceId, taskWorkspaces]); + + useEffect(() => { + if (!account) { + setStatusTone("info"); + setStatusText("Select a Calendar account to enable single-event ingestion."); + return; + } + + if (taskWorkspaces.length === 0) { + setStatusTone("info"); + setStatusText("No task workspace is available for ingestion target selection."); + return; + } + + if (!hasSelectedEvent) { + setStatusTone("info"); + setStatusText("Select one discovered event before submitting ingestion."); + return; + } + + if (!liveModeReady) { + setStatusTone("info"); + setStatusText( + "Event ingestion is unavailable until live API configuration, live account detail, and live task workspace list are present.", + ); + return; + } + + setStatusTone("info"); + setStatusText("Select one task workspace to ingest the discovered event."); + }, [account, hasSelectedEvent, liveModeReady, taskWorkspaces.length]); + + async function handleSubmit(event: FormEvent<HTMLFormElement>) { + event.preventDefault(); + + if (!account) { + setStatusTone("danger"); + setStatusText("Select a Calendar account before submitting ingestion."); + return; + } + + if (!taskWorkspaceId) { + setStatusTone("danger"); + setStatusText("Select a task workspace before submitting ingestion."); + return; + } + + if (!hasSelectedEvent) { + setStatusTone("danger"); + setStatusText("Select one discovered event before submitting ingestion."); + return; + } + + if (!apiBaseUrl || !userId || !liveModeReady) { + setStatusTone("info"); + setStatusText( + "Event ingestion is unavailable until live API configuration, live account detail, and live task workspace list are present.", + ); + return; + } + + setIsSubmitting(true); + setStatusTone("info"); + setStatusText("Submitting event ingestion..."); + setResultUnavailableReason(undefined); + + try { + const payload = await ingestCalendarEvent( + apiBaseUrl, + account.id, + selectedProviderEventId.trim(), + { + user_id: userId, + task_workspace_id: taskWorkspaceId, + }, + ); + + setResult(payload); + setResultSource("live"); + setStatusTone("success"); + setStatusText(`Ingestion completed. Artifact path: ${payload.event.artifact_relative_path}`); + router.refresh(); + } catch (error) { + const detail = error instanceof Error ? error.message : "Ingestion failed"; + setResult(null); + setResultSource("unavailable"); + setResultUnavailableReason(detail); + setStatusTone("danger"); + setStatusText(`Unable to ingest event: ${detail}`); + } finally { + setIsSubmitting(false); + } + } + + const canSubmit = Boolean( + liveModeReady && taskWorkspaceId && hasSelectedEvent && !isSubmitting, + ); + + if (!account) { + return ( + <div className="stack"> + <SectionCard + eyebrow="Ingest event" + title="No account selected" + description="Select one Calendar account before ingesting one provider event into a task workspace." + > + <EmptyState + title="Ingestion form is disabled" + description="Choose one account from the list to activate this bounded ingestion action." + /> + </SectionCard> + <CalendarIngestionSummary result={null} source={null} /> + </div> + ); + } + + return ( + <div className="stack"> + <SectionCard + eyebrow="Ingest event" + title="Single-event ingestion" + description="Ingest one selected discovered event into one selected task workspace through the shipped text artifact seam." + > + <form className="detail-stack" onSubmit={handleSubmit}> + <div className="cluster"> + <StatusBadge + status={accountSource ?? "unavailable"} + label={ + accountSource === "live" + ? "Live account" + : accountSource === "fixture" + ? "Fixture account" + : "Account unavailable" + } + /> + <StatusBadge + status={taskWorkspaceSource} + label={ + taskWorkspaceSource === "live" + ? "Live workspaces" + : taskWorkspaceSource === "fixture" + ? "Fixture workspaces" + : "Workspaces unavailable" + } + /> + <StatusBadge + status={selectedEventSource ?? "unavailable"} + label={ + selectedEventSource === "live" + ? "Live selection" + : selectedEventSource === "fixture" + ? "Fixture selection" + : "Selection unavailable" + } + /> + </div> + + <div className="detail-group detail-group--muted"> + <h3>Selected discovered event</h3> + {hasSelectedEvent ? ( + <p className="mono">{selectedProviderEventId}</p> + ) : ( + <p className="muted-copy">Select one discovered event from the event list first.</p> + )} + </div> + + <div className="form-field"> + <label htmlFor="calendar-task-workspace-id">Task workspace</label> + <select + id="calendar-task-workspace-id" + name="calendar-task-workspace-id" + value={taskWorkspaceId} + onChange={(event) => setTaskWorkspaceId(event.target.value)} + disabled={!liveModeReady || isSubmitting || taskWorkspaces.length === 0} + > + {taskWorkspaces.length === 0 ? ( + <option value="">No task workspace available</option> + ) : ( + taskWorkspaces.map((workspace) => ( + <option key={workspace.id} value={workspace.id}> + {workspace.id} · {workspace.local_path} + </option> + )) + )} + </select> + </div> + + <div className="composer-actions"> + <div className="composer-status" aria-live="polite"> + <StatusBadge + status={ + isSubmitting + ? "submitting" + : statusTone === "success" + ? "success" + : statusTone === "danger" + ? "error" + : liveModeReady + ? "info" + : "unavailable" + } + label={ + isSubmitting + ? "Submitting" + : statusTone === "success" + ? "Completed" + : statusTone === "danger" + ? "Error" + : liveModeReady + ? "Ready" + : "Unavailable" + } + /> + <span>{statusText}</span> + </div> + + <button type="submit" className="button" disabled={!canSubmit}> + Ingest selected event + </button> + </div> + </form> + </SectionCard> + + <CalendarIngestionSummary + result={result} + source={resultSource} + unavailableReason={resultUnavailableReason} + /> + </div> + ); +} diff --git a/apps/web/components/calendar-event-list.test.tsx b/apps/web/components/calendar-event-list.test.tsx new file mode 100644 index 0000000..9fae295 --- /dev/null +++ b/apps/web/components/calendar-event-list.test.tsx @@ -0,0 +1,113 @@ +import React from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { CalendarEventList } from "./calendar-event-list"; + +vi.mock("next/link", () => ({ + default: ({ + href, + children, + className, + "aria-current": ariaCurrent, + }: { + href: string; + children: React.ReactNode; + className?: string; + "aria-current"?: string; + }) => ( + <a href={href} className={className} aria-current={ariaCurrent}> + {children} + </a> + ), +})); + +const baseAccount = { + id: "calendar-account-1", + provider: "google_calendar", + auth_kind: "oauth_access_token", + provider_account_id: "acct-owner-001", + email_address: "owner@gmail.example", + display_name: "Owner", + scope: "https://www.googleapis.com/auth/calendar.readonly" as const, + created_at: "2026-03-18T10:00:00Z", + updated_at: "2026-03-18T10:00:00Z", +}; + +const baseEvents = [ + { + provider_event_id: "evt-001", + status: "confirmed", + summary: "Planning", + start_time: "2026-03-20T09:00:00+00:00", + end_time: "2026-03-20T09:30:00+00:00", + html_link: "https://calendar.google.com/event?eid=evt-001", + updated_at: "2026-03-19T09:00:00+00:00", + }, + { + provider_event_id: "evt-002", + status: "tentative", + summary: "Retro", + start_time: "2026-03-20T11:00:00+00:00", + end_time: "2026-03-20T11:30:00+00:00", + html_link: null, + updated_at: "2026-03-19T10:00:00+00:00", + }, +]; + +describe("CalendarEventList", () => { + afterEach(() => { + cleanup(); + }); + + it("renders discovered event links that preserve selection and bounded filter state", () => { + render( + <CalendarEventList + account={baseAccount} + source="live" + events={baseEvents} + summary={{ + total_count: 2, + limit: 20, + order: ["start_time_asc", "provider_event_id_asc"], + time_min: "2026-03-20T00:00:00Z", + time_max: "2026-03-21T00:00:00Z", + }} + selectedEventId="evt-002" + limit={20} + timeMin="2026-03-20T00:00:00Z" + timeMax="2026-03-21T00:00:00Z" + />, + ); + + expect(screen.getByRole("link", { name: /Planning/i })).toHaveAttribute( + "href", + "/calendar?account=calendar-account-1&event=evt-001&limit=20&time_min=2026-03-20T00%3A00%3A00Z&time_max=2026-03-21T00%3A00%3A00Z", + ); + expect(screen.getByRole("link", { name: /Retro/i })).toHaveAttribute( + "href", + "/calendar?account=calendar-account-1&event=evt-002&limit=20&time_min=2026-03-20T00%3A00%3A00Z&time_max=2026-03-21T00%3A00%3A00Z", + ); + expect(screen.getByRole("link", { name: /Retro/i })).toHaveAttribute("aria-current", "page"); + }); + + it("renders explicit unavailable state when event discovery is unavailable", () => { + render( + <CalendarEventList + account={baseAccount} + source="unavailable" + events={[]} + summary={null} + selectedEventId="" + unavailableReason="calendar events could not be fetched" + limit={20} + timeMin="" + timeMax="" + />, + ); + + expect(screen.getByText("Discovered events unavailable")).toBeInTheDocument(); + expect(screen.getByText("Events unavailable")).toBeInTheDocument(); + expect(screen.getByText("calendar events could not be fetched")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/calendar-event-list.tsx b/apps/web/components/calendar-event-list.tsx new file mode 100644 index 0000000..8bc1cbc --- /dev/null +++ b/apps/web/components/calendar-event-list.tsx @@ -0,0 +1,222 @@ +import Link from "next/link"; + +import type { + ApiSource, + CalendarAccountRecord, + CalendarEventListSummary, + CalendarEventSummaryRecord, +} from "../lib/api"; +import { EmptyState } from "./empty-state"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +type CalendarEventListProps = { + account: CalendarAccountRecord | null; + source: ApiSource | "unavailable" | null; + events: CalendarEventSummaryRecord[]; + summary: CalendarEventListSummary | null; + selectedEventId: string; + unavailableReason?: string; + limit: number; + timeMin: string; + timeMax: string; +}; + +function formatDateTime(value: string | null) { + if (!value) { + return "Start time unavailable"; + } + + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return value; + } + + return new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(parsed); +} + +function buildCalendarHref( + accountId: string, + eventId: string, + limit: number, + timeMin: string, + timeMax: string, +) { + const searchParams = new URLSearchParams(); + searchParams.set("account", accountId); + searchParams.set("event", eventId); + searchParams.set("limit", String(limit)); + + if (timeMin.trim()) { + searchParams.set("time_min", timeMin.trim()); + } + if (timeMax.trim()) { + searchParams.set("time_max", timeMax.trim()); + } + + return `/calendar?${searchParams.toString()}`; +} + +export function CalendarEventList({ + account, + source, + events, + summary, + selectedEventId, + unavailableReason, + limit, + timeMin, + timeMax, +}: CalendarEventListProps) { + if (!account) { + return ( + <SectionCard + eyebrow="Event discovery" + title="No account selected" + description="Select one Calendar account before loading bounded discovered events." + > + <EmptyState + title="Event discovery is idle" + description="Choose one account from the list to review and select one discovered event." + /> + </SectionCard> + ); + } + + if (source === "unavailable") { + return ( + <SectionCard + eyebrow="Event discovery" + title="Discovered events unavailable" + description="The selected account loaded, but discovered events are currently unavailable." + > + <div className="detail-stack"> + <StatusBadge status="unavailable" label="Events unavailable" /> + {unavailableReason ? ( + <div className="execution-summary__note execution-summary__note--danger"> + <p className="execution-summary__label">Event discovery read</p> + <p>{unavailableReason}</p> + </div> + ) : null} + </div> + </SectionCard> + ); + } + + return ( + <SectionCard + eyebrow="Event discovery" + title="Bounded discovered events" + description="Refresh bounded discovery results, then select one discovered event for explicit ingestion." + > + <div className="list-panel"> + <div className="list-panel__header"> + <div className="cluster"> + <StatusBadge + status={source ?? "unavailable"} + label={ + source === "live" + ? "Live events" + : source === "fixture" + ? "Fixture events" + : "Events unavailable" + } + /> + {summary ? <span className="meta-pill">{summary.total_count} listed</span> : null} + {summary ? <span className="meta-pill">Limit {summary.limit}</span> : null} + </div> + </div> + + <form method="get" action="/calendar" className="detail-stack"> + <input type="hidden" name="account" value={account.id} /> + {selectedEventId ? <input type="hidden" name="event" value={selectedEventId} /> : null} + + <div className="form-field-group form-field-group--two-up"> + <div className="form-field"> + <label htmlFor="calendar-event-limit">Limit</label> + <input + id="calendar-event-limit" + name="limit" + type="number" + min={1} + max={50} + defaultValue={limit} + /> + </div> + + <div className="form-field"> + <label htmlFor="calendar-event-time-min">Time min (optional)</label> + <input + id="calendar-event-time-min" + name="time_min" + type="text" + placeholder="2026-03-20T00:00:00Z" + defaultValue={timeMin} + /> + </div> + </div> + + <div className="form-field"> + <label htmlFor="calendar-event-time-max">Time max (optional)</label> + <input + id="calendar-event-time-max" + name="time_max" + type="text" + placeholder="2026-03-21T00:00:00Z" + defaultValue={timeMax} + /> + </div> + + <button type="submit" className="button-secondary"> + Refresh event list + </button> + </form> + + {unavailableReason ? ( + <p className="responsive-note">Live event discovery read failed: {unavailableReason}</p> + ) : null} + + {events.length === 0 ? ( + <EmptyState + title="No discovered events" + description="Adjust the limit or optional time window, then refresh discovery for this selected account." + className="empty-state--compact" + /> + ) : ( + <div className="list-rows"> + {events.map((event) => ( + <Link + key={event.provider_event_id} + href={buildCalendarHref(account.id, event.provider_event_id, limit, timeMin, timeMax)} + className={`list-row${event.provider_event_id === selectedEventId ? " is-selected" : ""}`} + aria-current={event.provider_event_id === selectedEventId ? "page" : undefined} + > + <div className="list-row__topline"> + <div className="detail-stack"> + <span className="list-row__eyebrow">{formatDateTime(event.start_time)}</span> + <h3 className="list-row__title">{event.summary ?? "Untitled event"}</h3> + </div> + <StatusBadge status={event.status ?? "unavailable"} /> + </div> + + <div className="list-row__meta"> + <span className="meta-pill mono">{event.provider_event_id}</span> + <span className="meta-pill">End: {formatDateTime(event.end_time)}</span> + {event.updated_at ? ( + <span className="meta-pill">Updated: {formatDateTime(event.updated_at)}</span> + ) : null} + {event.html_link ? <span className="meta-pill">Source link available</span> : null} + </div> + </Link> + ))} + </div> + )} + </div> + </SectionCard> + ); +} diff --git a/apps/web/components/calendar-ingestion-summary.tsx b/apps/web/components/calendar-ingestion-summary.tsx new file mode 100644 index 0000000..e9a433c --- /dev/null +++ b/apps/web/components/calendar-ingestion-summary.tsx @@ -0,0 +1,102 @@ +import type { ApiSource, CalendarEventIngestionResponse } from "../lib/api"; +import { EmptyState } from "./empty-state"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +type CalendarIngestionSummaryProps = { + result: CalendarEventIngestionResponse | null; + source: ApiSource | "unavailable" | null; + unavailableReason?: string; +}; + +export function CalendarIngestionSummary({ + result, + source, + unavailableReason, +}: CalendarIngestionSummaryProps) { + if (source === "unavailable") { + return ( + <SectionCard + eyebrow="Ingestion summary" + title="Latest ingestion unavailable" + description="No ingestion result is available because the latest request failed." + > + <div className="detail-stack"> + <StatusBadge status="unavailable" label="Result unavailable" /> + {unavailableReason ? ( + <div className="execution-summary__note execution-summary__note--danger"> + <p className="execution-summary__label">Ingestion result</p> + <p>{unavailableReason}</p> + </div> + ) : null} + </div> + </SectionCard> + ); + } + + if (!result) { + return ( + <SectionCard + eyebrow="Ingestion summary" + title="No event ingested yet" + description="Run one discovered-event ingestion to review artifact linkage and media metadata." + > + <EmptyState + title="Summary is idle" + description="A successful ingestion will appear here with the resulting artifact path and media type." + /> + </SectionCard> + ); + } + + return ( + <SectionCard + eyebrow="Ingestion summary" + title="Selected-event ingestion result" + description="Review the resulting artifact path, media type, and linked workspace target." + > + <div className="detail-grid"> + <div className="detail-summary"> + <StatusBadge status={result.artifact.ingestion_status} /> + <StatusBadge + status={source ?? "unavailable"} + label={ + source === "live" + ? "Live result" + : source === "fixture" + ? "Fixture result" + : "Result unavailable" + } + /> + </div> + + <dl className="key-value-grid key-value-grid--compact"> + <div> + <dt>Provider event ID</dt> + <dd className="mono">{result.event.provider_event_id}</dd> + </div> + <div> + <dt>Account email</dt> + <dd>{result.account.email_address}</dd> + </div> + <div> + <dt>Artifact path</dt> + <dd className="mono">{result.event.artifact_relative_path}</dd> + </div> + <div> + <dt>Linked artifact target</dt> + <dd className="mono">{result.artifact.relative_path}</dd> + </div> + <div> + <dt>Task workspace ID</dt> + <dd className="mono">{result.artifact.task_workspace_id}</dd> + </div> + <div> + <dt>Media type</dt> + <dd className="mono">{result.event.media_type}</dd> + </div> + </dl> + </div> + </SectionCard> + ); +} diff --git a/apps/web/components/chief-of-staff-action-handoff-panel.test.tsx b/apps/web/components/chief-of-staff-action-handoff-panel.test.tsx new file mode 100644 index 0000000..43e9ed1 --- /dev/null +++ b/apps/web/components/chief-of-staff-action-handoff-panel.test.tsx @@ -0,0 +1,498 @@ +import React from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import { ChiefOfStaffActionHandoffPanel } from "./chief-of-staff-action-handoff-panel"; + +const briefFixture = { + assembly_version: "chief_of_staff_priority_brief_v0", + scope: { + thread_id: "thread-1", + since: null, + until: null, + }, + ranked_items: [], + overdue_items: [], + stale_waiting_for_items: [], + slipped_commitments: [], + escalation_posture: { + posture: "watch" as const, + reason: "No active follow-through escalations are present.", + total_follow_through_count: 0, + nudge_count: 0, + defer_count: 0, + escalate_count: 0, + close_loop_candidate_count: 0, + }, + draft_follow_up: { + status: "none" as const, + mode: "draft_only" as const, + approval_required: true, + auto_send: false, + reason: "No follow-through targets are currently queued for drafting.", + target_metadata: { + continuity_object_id: null, + capture_event_id: null, + object_type: null, + priority_posture: null, + follow_through_posture: null, + recommendation_action: null, + thread_id: "thread-1", + }, + content: { subject: "", body: "" }, + }, + recommended_next_action: { + action_type: "execute_next_action" as const, + title: "Next Action: Ship dashboard", + target_priority_id: "priority-1", + priority_posture: "urgent" as const, + confidence_posture: "low" as const, + reason: "Marked urgent because this item is a deterministic immediate focus from resumption signals.", + provenance_references: [], + deterministic_rank_key: "1:priority-1:640.000000", + }, + preparation_brief: { + scope: { thread_id: "thread-1", since: null, until: null }, + context_items: [], + last_decision: null, + open_loops: [], + next_action: null, + confidence_posture: "low" as const, + confidence_reason: "Memory quality posture is weak.", + summary: { limit: 6, returned_count: 0, total_count: 0, order: ["rank_asc", "created_at_desc", "id_desc"] }, + }, + what_changed_summary: { + items: [], + confidence_posture: "low" as const, + confidence_reason: "Memory quality posture is weak.", + summary: { limit: 6, returned_count: 0, total_count: 0, order: ["rank_asc", "created_at_desc", "id_desc"] }, + }, + prep_checklist: { + items: [], + confidence_posture: "low" as const, + confidence_reason: "Memory quality posture is weak.", + summary: { limit: 6, returned_count: 0, total_count: 0, order: ["rank_asc", "created_at_desc", "id_desc"] }, + }, + suggested_talking_points: { + items: [], + confidence_posture: "low" as const, + confidence_reason: "Memory quality posture is weak.", + summary: { limit: 6, returned_count: 0, total_count: 0, order: ["rank_asc", "created_at_desc", "id_desc"] }, + }, + resumption_supervision: { + recommendations: [], + confidence_posture: "low" as const, + confidence_reason: "Memory quality posture is weak.", + summary: { limit: 3, returned_count: 0, total_count: 0, order: ["rank_asc"] }, + }, + weekly_review_brief: { + scope: { thread_id: "thread-1", since: null, until: null }, + rollup: { + total_count: 0, + waiting_for_count: 0, + blocker_count: 0, + stale_count: 0, + correction_recurrence_count: 0, + freshness_drift_count: 0, + next_action_count: 0, + posture_order: ["waiting_for", "blocker", "stale", "next_action"] as const, + }, + guidance: [], + summary: { + guidance_order: ["close", "defer", "escalate"] as const, + guidance_item_order: ["signal_count_desc", "action_desc"], + }, + }, + recommendation_outcomes: { + items: [], + summary: { + returned_count: 0, + total_count: 0, + outcome_counts: { accept: 0, defer: 0, ignore: 0, rewrite: 0 }, + order: ["created_at_desc", "id_desc"], + }, + }, + priority_learning_summary: { + total_count: 0, + accept_count: 0, + defer_count: 0, + ignore_count: 0, + rewrite_count: 0, + acceptance_rate: 0, + override_rate: 0, + defer_hotspots: [], + ignore_hotspots: [], + priority_shift_explanation: + "No recommendation outcomes are captured yet; prioritization remains anchored to current continuity and trust signals.", + hotspot_order: ["count_desc", "key_asc"], + }, + pattern_drift_summary: { + posture: "insufficient_signal" as const, + reason: "No recommendation outcomes are available yet, so drift posture is informational only.", + supporting_signals: [], + }, + action_handoff_brief: { + summary: + "Prepared 2 deterministic handoff items from recommended_next_action, follow_through signals. All task and approval drafts remain artifact-only and approval-bounded.", + confidence_posture: "low" as const, + non_autonomous_guarantee: + "No task, approval, connector send, or external side effect is executed by this endpoint.", + order: ["score_desc", "source_order_asc", "source_reference_id_asc"], + source_order: ["recommended_next_action", "follow_through", "prep_checklist", "weekly_review"] as const, + provenance_references: [], + }, + handoff_items: [ + { + rank: 1, + handoff_item_id: "handoff-1-recommended_next_action-priority-1", + source_kind: "recommended_next_action" as const, + source_reference_id: "priority-1", + title: "Next Action: Ship dashboard", + recommendation_action: "execute_next_action" as const, + priority_posture: "urgent" as const, + confidence_posture: "low" as const, + rationale: "Marked urgent because this item is a deterministic immediate focus from resumption signals.", + provenance_references: [], + score: 1650, + task_draft: { + status: "draft" as const, + mode: "governed_request_draft" as const, + approval_required: true, + auto_execute: false, + source_handoff_item_id: "handoff-1-recommended_next_action-priority-1", + title: "Next Action: Ship dashboard", + summary: + "Draft-only governed request assembled from chief-of-staff handoff artifacts; requires explicit approval before any execution.", + target: { thread_id: "thread-1", task_id: null, project: null, person: null }, + request: { + action: "execute_next_action", + scope: "chief_of_staff_priority", + domain_hint: "planning", + risk_hint: "governed_handoff", + attributes: {}, + }, + rationale: "Marked urgent because this item is a deterministic immediate focus from resumption signals.", + provenance_references: [], + }, + approval_draft: { + status: "draft_only" as const, + mode: "approval_request_draft" as const, + decision: "approval_required" as const, + approval_required: true, + auto_submit: false, + source_handoff_item_id: "handoff-1-recommended_next_action-priority-1", + request: { + action: "execute_next_action", + scope: "chief_of_staff_priority", + domain_hint: "planning", + risk_hint: "governed_handoff", + attributes: {}, + }, + reason: "Execution remains approval-bounded.", + required_checks: ["operator_review_handoff_artifact"], + provenance_references: [], + }, + }, + ], + handoff_queue_summary: { + total_count: 1, + ready_count: 1, + pending_approval_count: 0, + executed_count: 0, + stale_count: 0, + expired_count: 0, + state_order: ["ready", "pending_approval", "executed", "stale", "expired"] as const, + group_order: ["ready", "pending_approval", "executed", "stale", "expired"] as const, + item_order: ["queue_rank_asc", "handoff_rank_asc", "score_desc", "handoff_item_id_asc"], + review_action_order: ["mark_ready", "mark_pending_approval", "mark_executed", "mark_stale", "mark_expired"] as const, + }, + handoff_queue_groups: { + ready: { + items: [ + { + queue_rank: 1, + handoff_rank: 1, + handoff_item_id: "handoff-1-recommended_next_action-priority-1", + lifecycle_state: "ready" as const, + state_reason: "Handoff item is ready for explicit operator review.", + source_kind: "recommended_next_action" as const, + source_reference_id: "priority-1", + title: "Next Action: Ship dashboard", + recommendation_action: "execute_next_action" as const, + priority_posture: "urgent" as const, + confidence_posture: "low" as const, + score: 1650, + age_hours_relative_to_latest: 0, + review_action_order: ["mark_ready", "mark_pending_approval", "mark_executed", "mark_stale", "mark_expired"] as const, + available_review_actions: ["mark_pending_approval", "mark_executed", "mark_stale", "mark_expired"] as const, + last_review_action: null, + provenance_references: [], + }, + ], + summary: { + lifecycle_state: "ready" as const, + returned_count: 1, + total_count: 1, + order: ["queue_rank_asc", "handoff_rank_asc", "score_desc", "handoff_item_id_asc"], + }, + empty_state: { + is_empty: false, + message: "No ready handoff items for this scope.", + }, + }, + pending_approval: { + items: [], + summary: { + lifecycle_state: "pending_approval" as const, + returned_count: 0, + total_count: 0, + order: ["queue_rank_asc", "handoff_rank_asc", "score_desc", "handoff_item_id_asc"], + }, + empty_state: { + is_empty: true, + message: "No handoff items are currently pending approval.", + }, + }, + executed: { + items: [], + summary: { + lifecycle_state: "executed" as const, + returned_count: 0, + total_count: 0, + order: ["queue_rank_asc", "handoff_rank_asc", "score_desc", "handoff_item_id_asc"], + }, + empty_state: { + is_empty: true, + message: "No handoff items are currently marked executed.", + }, + }, + stale: { + items: [], + summary: { + lifecycle_state: "stale" as const, + returned_count: 0, + total_count: 0, + order: ["queue_rank_asc", "handoff_rank_asc", "score_desc", "handoff_item_id_asc"], + }, + empty_state: { + is_empty: true, + message: "No stale handoff items are currently surfaced.", + }, + }, + expired: { + items: [], + summary: { + lifecycle_state: "expired" as const, + returned_count: 0, + total_count: 0, + order: ["queue_rank_asc", "handoff_rank_asc", "score_desc", "handoff_item_id_asc"], + }, + empty_state: { + is_empty: true, + message: "No expired handoff items are currently surfaced.", + }, + }, + }, + handoff_review_actions: [], + execution_routing_summary: { + total_handoff_count: 1, + routed_handoff_count: 0, + unrouted_handoff_count: 1, + task_workflow_draft_count: 0, + approval_workflow_draft_count: 0, + follow_up_draft_only_count: 0, + route_target_order: ["task_workflow_draft", "approval_workflow_draft", "follow_up_draft_only"] as const, + routed_item_order: ["handoff_rank_asc", "handoff_item_id_asc"], + audit_order: ["created_at_desc", "id_desc"], + transition_order: ["routed", "reaffirmed"] as const, + approval_required: true, + non_autonomous_guarantee: + "No task, approval, connector send, or external side effect is executed by this endpoint.", + reason: "Routing transitions are explicit and auditable.", + }, + routed_handoff_items: [ + { + handoff_rank: 1, + handoff_item_id: "handoff-1-recommended_next_action-priority-1", + title: "Next Action: Ship dashboard", + source_kind: "recommended_next_action" as const, + recommendation_action: "execute_next_action" as const, + route_target_order: ["task_workflow_draft", "approval_workflow_draft", "follow_up_draft_only"] as const, + available_route_targets: ["task_workflow_draft", "approval_workflow_draft"] as const, + routed_targets: [] as const, + is_routed: false, + task_workflow_draft_routed: false, + approval_workflow_draft_routed: false, + follow_up_draft_only_routed: false, + follow_up_draft_only_applicable: false, + task_draft: { + status: "draft" as const, + mode: "governed_request_draft" as const, + approval_required: true, + auto_execute: false, + source_handoff_item_id: "handoff-1-recommended_next_action-priority-1", + title: "Next Action: Ship dashboard", + summary: + "Draft-only governed request assembled from chief-of-staff handoff artifacts; requires explicit approval before any execution.", + target: { thread_id: "thread-1", task_id: null, project: null, person: null }, + request: { + action: "execute_next_action", + scope: "chief_of_staff_priority", + domain_hint: "planning", + risk_hint: "governed_handoff", + attributes: {}, + }, + rationale: "Marked urgent because this item is a deterministic immediate focus from resumption signals.", + provenance_references: [], + }, + approval_draft: { + status: "draft_only" as const, + mode: "approval_request_draft" as const, + decision: "approval_required" as const, + approval_required: true, + auto_submit: false, + source_handoff_item_id: "handoff-1-recommended_next_action-priority-1", + request: { + action: "execute_next_action", + scope: "chief_of_staff_priority", + domain_hint: "planning", + risk_hint: "governed_handoff", + attributes: {}, + }, + reason: "Execution remains approval-bounded.", + required_checks: ["operator_review_handoff_artifact"], + provenance_references: [], + }, + last_routing_transition: null, + }, + ], + routing_audit_trail: [], + execution_readiness_posture: { + posture: "approval_required_draft_only" as const, + approval_required: true, + autonomous_execution: false, + external_side_effects_allowed: false, + approval_path_visible: true, + route_target_order: ["task_workflow_draft", "approval_workflow_draft", "follow_up_draft_only"] as const, + required_route_targets: ["task_workflow_draft", "approval_workflow_draft"] as const, + transition_order: ["routed", "reaffirmed"] as const, + non_autonomous_guarantee: + "No task, approval, connector send, or external side effect is executed by this endpoint.", + reason: "Execution routing remains draft-only.", + }, + task_draft: { + status: "draft" as const, + mode: "governed_request_draft" as const, + approval_required: true, + auto_execute: false, + source_handoff_item_id: "handoff-1-recommended_next_action-priority-1", + title: "Next Action: Ship dashboard", + summary: + "Draft-only governed request assembled from chief-of-staff handoff artifacts; requires explicit approval before any execution.", + target: { thread_id: "thread-1", task_id: null, project: null, person: null }, + request: { + action: "execute_next_action", + scope: "chief_of_staff_priority", + domain_hint: "planning", + risk_hint: "governed_handoff", + attributes: {}, + }, + rationale: "Marked urgent because this item is a deterministic immediate focus from resumption signals.", + provenance_references: [], + }, + approval_draft: { + status: "draft_only" as const, + mode: "approval_request_draft" as const, + decision: "approval_required" as const, + approval_required: true, + auto_submit: false, + source_handoff_item_id: "handoff-1-recommended_next_action-priority-1", + request: { + action: "execute_next_action", + scope: "chief_of_staff_priority", + domain_hint: "planning", + risk_hint: "governed_handoff", + attributes: {}, + }, + reason: + "Execution remains approval-bounded. This approval draft is artifact-only and must be explicitly submitted and resolved before any side effect.", + required_checks: [ + "operator_review_handoff_artifact", + "submit_governed_approval_request", + "explicit_approval_resolution", + ], + provenance_references: [], + }, + execution_posture: { + posture: "approval_bounded_artifact_only" as const, + approval_required: true, + autonomous_execution: false, + external_side_effects_allowed: false, + default_routing_decision: "approval_required" as const, + required_operator_actions: [ + "review_handoff_items", + "submit_task_or_approval_request", + "resolve_approval_before_execution", + ], + non_autonomous_guarantee: + "No task, approval, connector send, or external side effect is executed by this endpoint.", + reason: "Chief-of-staff handoff artifacts are deterministic execution-prep only in P8-S29.", + }, + summary: { + limit: 12, + returned_count: 0, + total_count: 0, + posture_order: ["urgent", "important", "waiting", "blocked", "stale", "defer"] as const, + order: ["score_desc", "created_at_desc", "id_desc"], + follow_through_posture_order: ["overdue", "stale_waiting_for", "slipped_commitment"] as const, + follow_through_item_order: ["recommendation_action_desc", "age_hours_desc", "created_at_desc", "id_desc"], + follow_through_total_count: 0, + overdue_count: 0, + stale_waiting_for_count: 0, + slipped_commitment_count: 0, + trust_confidence_posture: "low" as const, + trust_confidence_reason: "Memory quality posture is weak.", + quality_gate_status: "insufficient_sample" as const, + retrieval_status: "pass" as const, + handoff_item_count: 1, + handoff_item_order: ["score_desc", "source_order_asc", "source_reference_id_asc"], + execution_posture_order: ["approval_bounded_artifact_only"] as const, + handoff_queue_total_count: 1, + handoff_queue_ready_count: 1, + handoff_queue_pending_approval_count: 0, + handoff_queue_executed_count: 0, + handoff_queue_stale_count: 0, + handoff_queue_expired_count: 0, + handoff_queue_state_order: ["ready", "pending_approval", "executed", "stale", "expired"] as const, + handoff_queue_group_order: ["ready", "pending_approval", "executed", "stale", "expired"] as const, + handoff_queue_item_order: ["queue_rank_asc", "handoff_rank_asc", "score_desc", "handoff_item_id_asc"], + }, + sources: ["continuity_recall", "memory_trust_dashboard", "chief_of_staff_action_handoff"], +}; + +describe("ChiefOfStaffActionHandoffPanel", () => { + afterEach(() => { + cleanup(); + }); + + it("renders deterministic handoff artifacts and approval-bounded execution posture", () => { + render(<ChiefOfStaffActionHandoffPanel brief={briefFixture} source="live" />); + + expect(screen.getByText("Live action handoff")).toBeInTheDocument(); + expect(screen.getByText("Execution posture: approval_bounded_artifact_only")).toBeInTheDocument(); + expect(screen.getByText("Action handoff brief")).toBeInTheDocument(); + expect(screen.getByText("Primary task draft")).toBeInTheDocument(); + expect(screen.getByText("Primary approval draft")).toBeInTheDocument(); + expect(screen.getByText("Handoff items")).toBeInTheDocument(); + expect(screen.getAllByText("Request: execute_next_action (chief_of_staff_priority)").length).toBeGreaterThan(0); + expect( + screen.getByText("No task, approval, connector send, or external side effect is executed by this endpoint."), + ).toBeInTheDocument(); + }); + + it("renders explicit fallback when brief payload is absent", () => { + render(<ChiefOfStaffActionHandoffPanel brief={null} source="fixture" />); + + expect(screen.getByText("Action handoff unavailable")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/chief-of-staff-action-handoff-panel.tsx b/apps/web/components/chief-of-staff-action-handoff-panel.tsx new file mode 100644 index 0000000..ad643dd --- /dev/null +++ b/apps/web/components/chief-of-staff-action-handoff-panel.tsx @@ -0,0 +1,147 @@ +import type { ApiSource, ChiefOfStaffActionHandoffItem, ChiefOfStaffPriorityBrief } from "../lib/api"; +import { EmptyState } from "./empty-state"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +type ChiefOfStaffActionHandoffPanelProps = { + brief: ChiefOfStaffPriorityBrief | null; + source: ApiSource | "unavailable"; + unavailableReason?: string; +}; + +function sourceLabel(source: ApiSource | "unavailable") { + if (source === "live") { + return "Live action handoff"; + } + if (source === "fixture") { + return "Fixture action handoff"; + } + return "Action handoff unavailable"; +} + +function renderProvenance(item: { + provenance_references: Array<{ source_kind: string; source_id: string }>; +}) { + if (item.provenance_references.length === 0) { + return <p className="muted-copy">Provenance: none attached</p>; + } + return ( + <p className="muted-copy"> + Provenance: {item.provenance_references.map((ref) => `${ref.source_kind}:${ref.source_id}`).join(" | ")} + </p> + ); +} + +function renderHandoffItem(item: ChiefOfStaffActionHandoffItem) { + return ( + <li key={item.handoff_item_id} className="list-row"> + <div className="list-row__topline"> + <div className="detail-stack"> + <span className="list-row__eyebrow mono">Rank #{item.rank}</span> + <span className="list-row__title">{item.title}</span> + </div> + <div className="cluster"> + <StatusBadge status={item.source_kind} label={item.source_kind} /> + <StatusBadge status={item.recommendation_action} label={item.recommendation_action} /> + <StatusBadge status={item.confidence_posture} label={`${item.confidence_posture} confidence`} /> + </div> + </div> + <p className="muted-copy"> + Handoff item: {item.handoff_item_id} | Score: {item.score.toFixed(6)} + </p> + <p>{item.rationale}</p> + <p className="muted-copy"> + Task draft request: {item.task_draft.request.action} ({item.task_draft.request.scope}) + </p> + <p className="muted-copy"> + Approval draft decision: {item.approval_draft.decision} | Auto submit:{" "} + {item.approval_draft.auto_submit ? "enabled" : "disabled"} + </p> + {renderProvenance(item)} + </li> + ); +} + +export function ChiefOfStaffActionHandoffPanel({ + brief, + source, + unavailableReason, +}: ChiefOfStaffActionHandoffPanelProps) { + if (brief === null) { + return ( + <SectionCard + eyebrow="Chief of staff" + title="Action handoff" + description="Action handoff artifacts are unavailable in this mode." + > + <EmptyState + title="Action handoff unavailable" + description="Action handoff artifacts are unavailable in this mode." + /> + </SectionCard> + ); + } + + return ( + <SectionCard + eyebrow="Chief of staff" + title="Action handoff" + description="Deterministic recommendation-to-task/approval handoff artifacts with explicit approval-bounded execution posture." + > + <div className="detail-stack"> + <div className="cluster"> + <StatusBadge status={source} label={sourceLabel(source)} /> + <StatusBadge + status={brief.execution_posture.posture} + label={`Execution posture: ${brief.execution_posture.posture}`} + /> + <span className="meta-pill">{brief.summary.handoff_item_count} handoff items</span> + </div> + + {unavailableReason ? ( + <p className="responsive-note">Live action handoff read failed: {unavailableReason}</p> + ) : null} + + <div className="detail-group detail-group--muted"> + <h3>Action handoff brief</h3> + <p>{brief.action_handoff_brief.summary}</p> + <p className="muted-copy">{brief.action_handoff_brief.non_autonomous_guarantee}</p> + </div> + + <div className="detail-group detail-group--muted"> + <h3>Primary task draft</h3> + <p className="muted-copy">Mode: {brief.task_draft.mode}</p> + <p className="muted-copy"> + Approval required: {brief.task_draft.approval_required ? "yes" : "no"} | Auto execute:{" "} + {brief.task_draft.auto_execute ? "enabled" : "disabled"} + </p> + <p className="muted-copy"> + Request: {brief.task_draft.request.action} ({brief.task_draft.request.scope}) + </p> + <p>{brief.task_draft.summary}</p> + </div> + + <div className="detail-group detail-group--muted"> + <h3>Primary approval draft</h3> + <p className="muted-copy"> + Decision: {brief.approval_draft.decision} | Auto submit:{" "} + {brief.approval_draft.auto_submit ? "enabled" : "disabled"} + </p> + <p className="muted-copy"> + Request: {brief.approval_draft.request.action} ({brief.approval_draft.request.scope}) + </p> + <p>{brief.approval_draft.reason}</p> + </div> + + <div className="detail-group"> + <h3>Handoff items</h3> + {brief.handoff_items.length === 0 ? ( + <p className="muted-copy">No handoff items were generated for this scope.</p> + ) : ( + <ul className="detail-stack">{brief.handoff_items.map(renderHandoffItem)}</ul> + )} + </div> + </div> + </SectionCard> + ); +} diff --git a/apps/web/components/chief-of-staff-execution-routing-panel.test.tsx b/apps/web/components/chief-of-staff-execution-routing-panel.test.tsx new file mode 100644 index 0000000..27431a1 --- /dev/null +++ b/apps/web/components/chief-of-staff-execution-routing-panel.test.tsx @@ -0,0 +1,225 @@ +import React from "react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { ChiefOfStaffPriorityBrief } from "../lib/api"; +import { ChiefOfStaffExecutionRoutingPanel } from "./chief-of-staff-execution-routing-panel"; + +const { captureChiefOfStaffExecutionRoutingActionMock } = vi.hoisted(() => ({ + captureChiefOfStaffExecutionRoutingActionMock: vi.fn(), +})); + +vi.mock("../lib/api", async () => { + const actual = await vi.importActual("../lib/api"); + return { + ...actual, + captureChiefOfStaffExecutionRoutingAction: captureChiefOfStaffExecutionRoutingActionMock, + }; +}); + +const briefFixture = { + scope: { + thread_id: "thread-1", + task_id: null, + project: null, + person: null, + }, + execution_routing_summary: { + total_handoff_count: 1, + routed_handoff_count: 0, + unrouted_handoff_count: 1, + task_workflow_draft_count: 0, + approval_workflow_draft_count: 0, + follow_up_draft_only_count: 0, + route_target_order: ["task_workflow_draft", "approval_workflow_draft", "follow_up_draft_only"] as const, + routed_item_order: ["handoff_rank_asc", "handoff_item_id_asc"], + audit_order: ["created_at_desc", "id_desc"], + transition_order: ["routed", "reaffirmed"] as const, + approval_required: true, + non_autonomous_guarantee: + "No task, approval, connector send, or external side effect is executed by this endpoint.", + reason: "Routing transitions are explicit and auditable.", + }, + routed_handoff_items: [ + { + handoff_rank: 1, + handoff_item_id: "handoff-1", + title: "Next Action: Ship dashboard", + source_kind: "recommended_next_action", + recommendation_action: "execute_next_action", + route_target_order: ["task_workflow_draft", "approval_workflow_draft", "follow_up_draft_only"] as const, + available_route_targets: ["task_workflow_draft", "approval_workflow_draft"] as const, + routed_targets: [], + is_routed: false, + task_workflow_draft_routed: false, + approval_workflow_draft_routed: false, + follow_up_draft_only_routed: false, + follow_up_draft_only_applicable: false, + task_draft: { + status: "draft", + mode: "governed_request_draft", + approval_required: true, + auto_execute: false, + source_handoff_item_id: "handoff-1", + title: "Next Action: Ship dashboard", + summary: "Draft-only governed request.", + target: { thread_id: "thread-1", task_id: null, project: null, person: null }, + request: { + action: "execute_next_action", + scope: "chief_of_staff_priority", + domain_hint: "planning", + risk_hint: "governed_handoff", + attributes: {}, + }, + rationale: "fixture rationale", + provenance_references: [], + }, + approval_draft: { + status: "draft_only", + mode: "approval_request_draft", + decision: "approval_required", + approval_required: true, + auto_submit: false, + source_handoff_item_id: "handoff-1", + request: { + action: "execute_next_action", + scope: "chief_of_staff_priority", + domain_hint: "planning", + risk_hint: "governed_handoff", + attributes: {}, + }, + reason: "approval required", + required_checks: ["operator_review_handoff_artifact"], + provenance_references: [], + }, + last_routing_transition: null, + }, + ], + routing_audit_trail: [], + execution_readiness_posture: { + posture: "approval_required_draft_only", + approval_required: true, + autonomous_execution: false, + external_side_effects_allowed: false, + approval_path_visible: true, + route_target_order: ["task_workflow_draft", "approval_workflow_draft", "follow_up_draft_only"] as const, + required_route_targets: ["task_workflow_draft", "approval_workflow_draft"] as const, + transition_order: ["routed", "reaffirmed"] as const, + non_autonomous_guarantee: + "No task, approval, connector send, or external side effect is executed by this endpoint.", + reason: "Execution routing remains draft-only.", + }, +} as const; + +describe("ChiefOfStaffExecutionRoutingPanel", () => { + beforeEach(() => { + captureChiefOfStaffExecutionRoutingActionMock.mockReset(); + captureChiefOfStaffExecutionRoutingActionMock.mockResolvedValue({ + routing_action: { + id: "routing-1", + capture_event_id: "capture-routing-1", + handoff_item_id: "handoff-1", + route_target: "task_workflow_draft", + transition: "routed", + previously_routed: false, + route_state: true, + reason: "Operator routed handoff.", + note: null, + provenance_references: [], + created_at: "2026-04-01T09:30:00Z", + updated_at: "2026-04-01T09:30:00Z", + }, + execution_routing_summary: { + ...briefFixture.execution_routing_summary, + routed_handoff_count: 1, + unrouted_handoff_count: 0, + task_workflow_draft_count: 1, + }, + routed_handoff_items: [ + { + ...briefFixture.routed_handoff_items[0], + routed_targets: ["task_workflow_draft"], + is_routed: true, + task_workflow_draft_routed: true, + last_routing_transition: { + id: "routing-1", + capture_event_id: "capture-routing-1", + handoff_item_id: "handoff-1", + route_target: "task_workflow_draft", + transition: "routed", + previously_routed: false, + route_state: true, + reason: "Operator routed handoff.", + note: null, + provenance_references: [], + created_at: "2026-04-01T09:30:00Z", + updated_at: "2026-04-01T09:30:00Z", + }, + }, + ], + routing_audit_trail: [ + { + id: "routing-1", + capture_event_id: "capture-routing-1", + handoff_item_id: "handoff-1", + route_target: "task_workflow_draft", + transition: "routed", + previously_routed: false, + route_state: true, + reason: "Operator routed handoff.", + note: null, + provenance_references: [], + created_at: "2026-04-01T09:30:00Z", + updated_at: "2026-04-01T09:30:00Z", + }, + ], + execution_readiness_posture: briefFixture.execution_readiness_posture, + }); + }); + + afterEach(() => { + cleanup(); + }); + + it("renders deterministic execution routing posture and controls", () => { + render( + <ChiefOfStaffExecutionRoutingPanel + brief={briefFixture as unknown as ChiefOfStaffPriorityBrief} + source="fixture" + />, + ); + + expect(screen.getByText("Fixture execution routing")).toBeInTheDocument(); + expect(screen.getByText("Execution readiness posture")).toBeInTheDocument(); + expect(screen.getByText("Routed handoff items")).toBeInTheDocument(); + expect(screen.getByText("No execution routing transitions captured for this scope.")).toBeInTheDocument(); + }); + + it("submits execution routing actions in live mode and updates audit trail", async () => { + render( + <ChiefOfStaffExecutionRoutingPanel + apiBaseUrl="https://api.example.com" + userId="user-1" + brief={briefFixture as unknown as ChiefOfStaffPriorityBrief} + source="live" + />, + ); + + fireEvent.click(screen.getByRole("button", { name: "Route task draft" })); + + await waitFor(() => { + expect(captureChiefOfStaffExecutionRoutingActionMock).toHaveBeenCalledWith( + "https://api.example.com", + expect.objectContaining({ + user_id: "user-1", + handoff_item_id: "handoff-1", + route_target: "task_workflow_draft", + thread_id: "thread-1", + }), + ); + }); + + expect(screen.getByText("Routed handoff-1 -> task_workflow_draft.")).toBeInTheDocument(); + expect(screen.getByText("Operator routed handoff.")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/chief-of-staff-execution-routing-panel.tsx b/apps/web/components/chief-of-staff-execution-routing-panel.tsx new file mode 100644 index 0000000..88203a0 --- /dev/null +++ b/apps/web/components/chief-of-staff-execution-routing-panel.tsx @@ -0,0 +1,273 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import type { + ApiSource, + ChiefOfStaffExecutionRouteTarget, + ChiefOfStaffExecutionReadinessPosture, + ChiefOfStaffExecutionRoutingAuditRecord, + ChiefOfStaffExecutionRoutingSummary, + ChiefOfStaffPriorityBrief, + ChiefOfStaffRoutedHandoffItem, +} from "../lib/api"; +import { captureChiefOfStaffExecutionRoutingAction } from "../lib/api"; +import { EmptyState } from "./empty-state"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +type ChiefOfStaffExecutionRoutingPanelProps = { + apiBaseUrl?: string; + userId?: string; + brief: ChiefOfStaffPriorityBrief | null; + source: ApiSource | "unavailable"; + unavailableReason?: string; +}; + +const ROUTE_TARGET_LABELS: Record<ChiefOfStaffExecutionRouteTarget, string> = { + task_workflow_draft: "Route task draft", + approval_workflow_draft: "Route approval draft", + follow_up_draft_only: "Route follow-up draft", +}; + +function sourceLabel(source: ApiSource | "unavailable") { + if (source === "live") { + return "Live execution routing"; + } + if (source === "fixture") { + return "Fixture execution routing"; + } + return "Execution routing unavailable"; +} + +function formatRouteTarget(routeTarget: ChiefOfStaffExecutionRouteTarget) { + return routeTarget.replaceAll("_", " "); +} + +export function ChiefOfStaffExecutionRoutingPanel({ + apiBaseUrl, + userId, + brief, + source, + unavailableReason, +}: ChiefOfStaffExecutionRoutingPanelProps) { + const liveModeReady = Boolean(apiBaseUrl && userId && source === "live"); + const [routingSummary, setRoutingSummary] = useState<ChiefOfStaffExecutionRoutingSummary | null>( + brief?.execution_routing_summary ?? null, + ); + const [routedItems, setRoutedItems] = useState<ChiefOfStaffRoutedHandoffItem[]>( + brief?.routed_handoff_items ?? [], + ); + const [routingAuditTrail, setRoutingAuditTrail] = useState<ChiefOfStaffExecutionRoutingAuditRecord[]>( + brief?.routing_audit_trail ?? [], + ); + const [readinessPosture, setReadinessPosture] = useState<ChiefOfStaffExecutionReadinessPosture | null>( + brief?.execution_readiness_posture ?? null, + ); + const [submittingRouteKey, setSubmittingRouteKey] = useState<string | null>(null); + const [statusTone, setStatusTone] = useState<"info" | "success" | "danger">("info"); + const [statusText, setStatusText] = useState( + "Route selected handoff items into governed draft paths with explicit audit transitions.", + ); + + useEffect(() => { + setRoutingSummary(brief?.execution_routing_summary ?? null); + setRoutedItems(brief?.routed_handoff_items ?? []); + setRoutingAuditTrail(brief?.routing_audit_trail ?? []); + setReadinessPosture(brief?.execution_readiness_posture ?? null); + setSubmittingRouteKey(null); + setStatusTone("info"); + setStatusText("Route selected handoff items into governed draft paths with explicit audit transitions."); + }, [brief]); + + async function applyRoute(item: ChiefOfStaffRoutedHandoffItem, routeTarget: ChiefOfStaffExecutionRouteTarget) { + if (!brief || !apiBaseUrl || !userId || !liveModeReady) { + setStatusTone("info"); + setStatusText("Execution routing controls are available only when live API mode is configured."); + return; + } + + const routeKey = `${item.handoff_item_id}:${routeTarget}`; + setSubmittingRouteKey(routeKey); + setStatusTone("info"); + setStatusText(`Routing ${item.handoff_item_id} -> ${routeTarget}...`); + + try { + const payload = await captureChiefOfStaffExecutionRoutingAction(apiBaseUrl, { + user_id: userId, + handoff_item_id: item.handoff_item_id, + route_target: routeTarget, + note: `Captured from execution routing controls as ${routeTarget}.`, + thread_id: brief.scope.thread_id ?? null, + task_id: brief.scope.task_id ?? null, + project: brief.scope.project ?? null, + person: brief.scope.person ?? null, + }); + setRoutingSummary(payload.execution_routing_summary); + setRoutedItems(payload.routed_handoff_items); + setRoutingAuditTrail(payload.routing_audit_trail); + setReadinessPosture(payload.execution_readiness_posture); + setStatusTone("success"); + setStatusText(`Routed ${item.handoff_item_id} -> ${routeTarget}.`); + } catch (error) { + setStatusTone("danger"); + setStatusText( + `Unable to capture routing action: ${error instanceof Error ? error.message : "Request failed"}`, + ); + } finally { + setSubmittingRouteKey(null); + } + } + + if (brief === null || routingSummary === null || readinessPosture === null) { + return ( + <SectionCard + eyebrow="Chief of staff" + title="Execution routing" + description="Execution routing artifacts are unavailable in this mode." + > + <EmptyState + title="Execution routing unavailable" + description="Execution routing artifacts are unavailable in this mode." + /> + </SectionCard> + ); + } + + return ( + <SectionCard + eyebrow="Chief of staff" + title="Execution routing" + description="Deterministic governed routing controls for draft-only task, approval, and follow-up execution preparation." + > + <div className="detail-stack"> + <div className="cluster"> + <StatusBadge status={source} label={sourceLabel(source)} /> + <StatusBadge status={readinessPosture.posture} label={`Readiness: ${readinessPosture.posture}`} /> + <span className="meta-pill">{routingSummary.routed_handoff_count} routed</span> + <span className="meta-pill">{routingSummary.unrouted_handoff_count} unrouted</span> + </div> + + {unavailableReason ? ( + <p className="responsive-note">Live execution routing read failed: {unavailableReason}</p> + ) : null} + + <div className="composer-status" aria-live="polite"> + <StatusBadge + status={ + submittingRouteKey + ? "submitting" + : statusTone === "success" + ? "success" + : statusTone === "danger" + ? "error" + : liveModeReady + ? "ready" + : "unavailable" + } + label={ + submittingRouteKey + ? "Submitting" + : statusTone === "success" + ? "Captured" + : statusTone === "danger" + ? "Attention" + : liveModeReady + ? "Ready" + : "Unavailable" + } + /> + <span>{statusText}</span> + </div> + + <div className="detail-group detail-group--muted"> + <h3>Execution readiness posture</h3> + <p>{readinessPosture.reason}</p> + <p className="muted-copy">{readinessPosture.non_autonomous_guarantee}</p> + <p className="muted-copy"> + Approval required: {readinessPosture.approval_required ? "yes" : "no"} | Autonomous execution:{" "} + {readinessPosture.autonomous_execution ? "enabled" : "disabled"} | External side effects:{" "} + {readinessPosture.external_side_effects_allowed ? "allowed" : "blocked"} + </p> + </div> + + <div className="detail-group"> + <h3>Routed handoff items</h3> + {routedItems.length === 0 ? ( + <p className="muted-copy">No handoff items are currently available for governed routing.</p> + ) : ( + <ul className="detail-stack"> + {routedItems.map((item) => ( + <li key={item.handoff_item_id} className="list-row"> + <div className="list-row__topline"> + <div className="detail-stack"> + <span className="list-row__eyebrow mono">Rank #{item.handoff_rank}</span> + <span className="list-row__title">{item.title}</span> + </div> + <div className="cluster"> + <StatusBadge + status={item.is_routed ? "routed" : "not_routed"} + label={item.is_routed ? "Routed" : "Not routed"} + /> + <StatusBadge status={item.source_kind} label={item.source_kind} /> + </div> + </div> + <p className="muted-copy"> + {item.handoff_item_id} | Routed targets: {item.routed_targets.length > 0 ? item.routed_targets.join(", ") : "none"} + </p> + <div className="composer-actions"> + {item.available_route_targets.map((routeTarget) => { + const routeKey = `${item.handoff_item_id}:${routeTarget}`; + return ( + <button + key={routeKey} + type="button" + className="button button--ghost" + disabled={!liveModeReady || submittingRouteKey !== null} + onClick={() => void applyRoute(item, routeTarget)} + > + {ROUTE_TARGET_LABELS[routeTarget]} + </button> + ); + })} + </div> + {item.last_routing_transition ? ( + <p className="muted-copy"> + Last transition: {item.last_routing_transition.transition}{" -> "} + {formatRouteTarget(item.last_routing_transition.route_target)} + </p> + ) : null} + </li> + ))} + </ul> + )} + </div> + + <div className="detail-group detail-group--muted"> + <h3>Routing audit trail</h3> + {routingAuditTrail.length === 0 ? ( + <p className="muted-copy">No execution routing transitions captured for this scope.</p> + ) : ( + <ul className="detail-stack"> + {routingAuditTrail.map((entry) => ( + <li key={entry.id} className="list-row"> + <div className="list-row__topline"> + <div className="detail-stack"> + <span className="list-row__eyebrow mono">{entry.created_at}</span> + <span className="list-row__title">{entry.handoff_item_id}</span> + </div> + <StatusBadge status={entry.route_target} label={formatRouteTarget(entry.route_target)} /> + </div> + <p className="muted-copy"> + Transition: {entry.transition} | Previously routed: {entry.previously_routed ? "yes" : "no"} + </p> + <p>{entry.reason}</p> + </li> + ))} + </ul> + )} + </div> + </div> + </SectionCard> + ); +} diff --git a/apps/web/components/chief-of-staff-follow-through-panel.test.tsx b/apps/web/components/chief-of-staff-follow-through-panel.test.tsx new file mode 100644 index 0000000..38c4792 --- /dev/null +++ b/apps/web/components/chief-of-staff-follow-through-panel.test.tsx @@ -0,0 +1,350 @@ +import React from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import { ChiefOfStaffFollowThroughPanel } from "./chief-of-staff-follow-through-panel"; + +const briefFixture = { + assembly_version: "chief_of_staff_priority_brief_v0", + scope: { + thread_id: "thread-1", + since: null, + until: null, + }, + ranked_items: [], + overdue_items: [ + { + rank: 1, + id: "next-overdue-1", + capture_event_id: "capture-next-overdue-1", + object_type: "NextAction" as const, + status: "active", + title: "Next Action: Send client follow-up", + current_priority_posture: "urgent" as const, + follow_through_posture: "overdue" as const, + recommendation_action: "escalate" as const, + reason: "Execution follow-through is overdue (posture=urgent, age=140.0h), so action 'escalate' is recommended.", + age_hours: 140, + provenance_references: [ + { + source_kind: "continuity_capture_event" as const, + source_id: "capture-next-overdue-1", + }, + ], + created_at: "2026-03-26T08:00:00Z", + updated_at: "2026-03-26T08:00:00Z", + }, + ], + stale_waiting_for_items: [ + { + rank: 1, + id: "waiting-stale-1", + capture_event_id: "capture-waiting-stale-1", + object_type: "WaitingFor" as const, + status: "stale", + title: "Waiting For: Security review", + current_priority_posture: "stale" as const, + follow_through_posture: "stale_waiting_for" as const, + recommendation_action: "nudge" as const, + reason: "Waiting-for item is stale (status=stale, age=96.0h from latest scoped item), so action 'nudge' is recommended.", + age_hours: 96, + provenance_references: [ + { + source_kind: "continuity_capture_event" as const, + source_id: "capture-waiting-stale-1", + }, + ], + created_at: "2026-03-27T00:00:00Z", + updated_at: "2026-03-27T00:00:00Z", + }, + ], + slipped_commitments: [ + { + rank: 1, + id: "commitment-slip-1", + capture_event_id: "capture-commitment-slip-1", + object_type: "Commitment" as const, + status: "active", + title: "Commitment: Ship status report", + current_priority_posture: "important" as const, + follow_through_posture: "slipped_commitment" as const, + recommendation_action: "defer" as const, + reason: "Commitment is slipping (status=active, age=60.0h from latest scoped item), so action 'defer' is recommended.", + age_hours: 60, + provenance_references: [ + { + source_kind: "continuity_capture_event" as const, + source_id: "capture-commitment-slip-1", + }, + ], + created_at: "2026-03-28T12:00:00Z", + updated_at: "2026-03-28T12:00:00Z", + }, + ], + escalation_posture: { + posture: "critical" as const, + reason: "At least one follow-through item requires escalation.", + total_follow_through_count: 3, + nudge_count: 1, + defer_count: 1, + escalate_count: 1, + close_loop_candidate_count: 0, + }, + draft_follow_up: { + status: "drafted" as const, + mode: "draft_only" as const, + approval_required: true, + auto_send: false, + reason: "Highest-severity follow-through item selected deterministically for operator review.", + target_metadata: { + continuity_object_id: "next-overdue-1", + capture_event_id: "capture-next-overdue-1", + object_type: "NextAction" as const, + priority_posture: "urgent" as const, + follow_through_posture: "overdue" as const, + recommendation_action: "escalate" as const, + thread_id: "thread-1", + }, + content: { + subject: "Follow-up: Next Action: Send client follow-up", + body: "This draft is artifact-only and requires explicit approval before any external send.", + }, + }, + recommended_next_action: { + action_type: "execute_next_action" as const, + title: "Next Action: Send client follow-up", + target_priority_id: "next-overdue-1", + priority_posture: "urgent" as const, + confidence_posture: "low" as const, + reason: "Marked urgent because this item is a deterministic immediate focus from resumption signals.", + provenance_references: [ + { + source_kind: "continuity_capture_event" as const, + source_id: "capture-next-overdue-1", + }, + ], + deterministic_rank_key: "1:next-overdue-1:640.000000", + }, + preparation_brief: { + scope: { thread_id: "thread-1", since: null, until: null }, + context_items: [], + last_decision: null, + open_loops: [], + next_action: null, + confidence_posture: "low" as const, + confidence_reason: "Memory quality posture is weak.", + summary: { + limit: 6, + returned_count: 0, + total_count: 0, + order: ["rank_asc", "created_at_desc", "id_desc"], + }, + }, + what_changed_summary: { + items: [], + confidence_posture: "low" as const, + confidence_reason: "Memory quality posture is weak.", + summary: { + limit: 6, + returned_count: 0, + total_count: 0, + order: ["rank_asc", "created_at_desc", "id_desc"], + }, + }, + prep_checklist: { + items: [], + confidence_posture: "low" as const, + confidence_reason: "Memory quality posture is weak.", + summary: { + limit: 6, + returned_count: 0, + total_count: 0, + order: ["rank_asc", "created_at_desc", "id_desc"], + }, + }, + suggested_talking_points: { + items: [], + confidence_posture: "low" as const, + confidence_reason: "Memory quality posture is weak.", + summary: { + limit: 6, + returned_count: 0, + total_count: 0, + order: ["rank_asc", "created_at_desc", "id_desc"], + }, + }, + resumption_supervision: { + recommendations: [], + confidence_posture: "low" as const, + confidence_reason: "Memory quality posture is weak.", + summary: { + limit: 3, + returned_count: 0, + total_count: 0, + order: ["rank_asc"], + }, + }, + weekly_review_brief: { + scope: { thread_id: "thread-1", since: null, until: null }, + rollup: { + total_count: 3, + waiting_for_count: 1, + blocker_count: 1, + stale_count: 1, + correction_recurrence_count: 0, + freshness_drift_count: 1, + next_action_count: 1, + posture_order: ["waiting_for", "blocker", "stale", "next_action"] as const, + }, + guidance: [], + summary: { + guidance_order: ["close", "defer", "escalate"] as const, + guidance_item_order: ["signal_count_desc", "action_desc"], + }, + }, + recommendation_outcomes: { + items: [], + summary: { + returned_count: 0, + total_count: 0, + outcome_counts: { accept: 0, defer: 0, ignore: 0, rewrite: 0 }, + order: ["created_at_desc", "id_desc"], + }, + }, + priority_learning_summary: { + total_count: 0, + accept_count: 0, + defer_count: 0, + ignore_count: 0, + rewrite_count: 0, + acceptance_rate: 0, + override_rate: 0, + defer_hotspots: [], + ignore_hotspots: [], + priority_shift_explanation: + "No recommendation outcomes are captured yet; prioritization remains anchored to current continuity and trust signals.", + hotspot_order: ["count_desc", "key_asc"], + }, + pattern_drift_summary: { + posture: "insufficient_signal" as const, + reason: "No recommendation outcomes are available yet, so drift posture is informational only.", + supporting_signals: [], + }, + action_handoff_brief: { + summary: + "Prepared 1 deterministic handoff item from recommended_next_action signals. All task and approval drafts remain artifact-only and approval-bounded.", + confidence_posture: "low" as const, + non_autonomous_guarantee: + "No task, approval, connector send, or external side effect is executed by this endpoint.", + order: ["score_desc", "source_order_asc", "source_reference_id_asc"], + source_order: ["recommended_next_action", "follow_through", "prep_checklist", "weekly_review"] as const, + provenance_references: [], + }, + handoff_items: [], + task_draft: { + status: "draft" as const, + mode: "governed_request_draft" as const, + approval_required: true, + auto_execute: false, + source_handoff_item_id: "handoff-1-recommended_next_action-next-overdue-1", + title: "Next Action: Send client follow-up", + summary: + "Draft-only governed request assembled from chief-of-staff handoff artifacts; requires explicit approval before any execution.", + target: { thread_id: "thread-1", task_id: null, project: null, person: null }, + request: { + action: "execute_next_action", + scope: "chief_of_staff_priority", + domain_hint: "planning", + risk_hint: "governed_handoff", + attributes: {}, + }, + rationale: "Marked urgent because this item is a deterministic immediate focus from resumption signals.", + provenance_references: [], + }, + approval_draft: { + status: "draft_only" as const, + mode: "approval_request_draft" as const, + decision: "approval_required" as const, + approval_required: true, + auto_submit: false, + source_handoff_item_id: "handoff-1-recommended_next_action-next-overdue-1", + request: { + action: "execute_next_action", + scope: "chief_of_staff_priority", + domain_hint: "planning", + risk_hint: "governed_handoff", + attributes: {}, + }, + reason: + "Execution remains approval-bounded. This approval draft is artifact-only and must be explicitly submitted and resolved before any side effect.", + required_checks: [ + "operator_review_handoff_artifact", + "submit_governed_approval_request", + "explicit_approval_resolution", + ], + provenance_references: [], + }, + execution_posture: { + posture: "approval_bounded_artifact_only" as const, + approval_required: true, + autonomous_execution: false, + external_side_effects_allowed: false, + default_routing_decision: "approval_required" as const, + required_operator_actions: [ + "review_handoff_items", + "submit_task_or_approval_request", + "resolve_approval_before_execution", + ], + non_autonomous_guarantee: + "No task, approval, connector send, or external side effect is executed by this endpoint.", + reason: "Chief-of-staff handoff artifacts are deterministic execution-prep only in P8-S29.", + }, + summary: { + limit: 10, + returned_count: 0, + total_count: 3, + posture_order: ["urgent", "important", "waiting", "blocked", "stale", "defer"] as const, + order: ["score_desc", "created_at_desc", "id_desc"], + follow_through_posture_order: ["overdue", "stale_waiting_for", "slipped_commitment"] as const, + follow_through_item_order: ["recommendation_action_desc", "age_hours_desc", "created_at_desc", "id_desc"], + follow_through_total_count: 3, + overdue_count: 1, + stale_waiting_for_count: 1, + slipped_commitment_count: 1, + trust_confidence_posture: "low" as const, + trust_confidence_reason: "Memory quality posture is weak.", + quality_gate_status: "insufficient_sample" as const, + retrieval_status: "pass" as const, + handoff_item_count: 0, + handoff_item_order: ["score_desc", "source_order_asc", "source_reference_id_asc"], + execution_posture_order: ["approval_bounded_artifact_only"] as const, + }, + sources: ["continuity_recall", "memory_trust_dashboard", "chief_of_staff_action_handoff"], +}; + +describe("ChiefOfStaffFollowThroughPanel", () => { + afterEach(() => { + cleanup(); + }); + + it("renders escalation, follow-through groups, and draft-only follow-up artifact", () => { + render(<ChiefOfStaffFollowThroughPanel brief={briefFixture} source="live" />); + + expect(screen.getByText("Live follow-through")).toBeInTheDocument(); + expect(screen.getByText("Escalation: critical")).toBeInTheDocument(); + expect(screen.getByText("Overdue items")).toBeInTheDocument(); + expect(screen.getByText("Stale waiting-for items")).toBeInTheDocument(); + expect(screen.getByText("Slipped commitments")).toBeInTheDocument(); + expect(screen.getByText("Follow-up: Next Action: Send client follow-up")).toBeInTheDocument(); + expect(screen.getByText("Auto send: disabled")).toBeInTheDocument(); + expect( + screen.getByText("This draft is artifact-only and requires explicit approval before any external send."), + ).toBeInTheDocument(); + }); + + it("renders explicit fallback when brief payload is absent", () => { + render(<ChiefOfStaffFollowThroughPanel brief={null} source="fixture" />); + + expect(screen.getByText("Follow-through unavailable")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/chief-of-staff-follow-through-panel.tsx b/apps/web/components/chief-of-staff-follow-through-panel.tsx new file mode 100644 index 0000000..bc741ea --- /dev/null +++ b/apps/web/components/chief-of-staff-follow-through-panel.tsx @@ -0,0 +1,145 @@ +import type { ApiSource, ChiefOfStaffFollowThroughItem, ChiefOfStaffPriorityBrief } from "../lib/api"; +import { EmptyState } from "./empty-state"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +type ChiefOfStaffFollowThroughPanelProps = { + brief: ChiefOfStaffPriorityBrief | null; + source: ApiSource | "unavailable"; + unavailableReason?: string; +}; + +function sourceLabel(source: ApiSource | "unavailable") { + if (source === "live") { + return "Live follow-through"; + } + if (source === "fixture") { + return "Fixture follow-through"; + } + return "Follow-through unavailable"; +} + +function renderItem(item: ChiefOfStaffFollowThroughItem) { + return ( + <li key={item.id} className="list-row"> + <div className="list-row__topline"> + <div className="detail-stack"> + <span className="list-row__eyebrow mono">Rank #{item.rank}</span> + <span className="list-row__title">{item.title}</span> + </div> + <div className="cluster"> + <StatusBadge status={item.follow_through_posture} label={item.follow_through_posture} /> + <StatusBadge status={item.recommendation_action} label={item.recommendation_action} /> + </div> + </div> + <p className="muted-copy"> + Priority posture: {item.current_priority_posture} | Age: {item.age_hours.toFixed(1)}h | Type: {item.object_type} + </p> + <p>{item.reason}</p> + </li> + ); +} + +function renderGroup( + title: string, + items: ChiefOfStaffFollowThroughItem[], + emptyMessage: string, +) { + return ( + <div className="detail-group" key={title}> + <h3>{title}</h3> + {items.length === 0 ? <p className="muted-copy">{emptyMessage}</p> : <ul className="detail-stack">{items.map(renderItem)}</ul>} + </div> + ); +} + +export function ChiefOfStaffFollowThroughPanel({ + brief, + source, + unavailableReason, +}: ChiefOfStaffFollowThroughPanelProps) { + if (brief === null) { + return ( + <SectionCard + eyebrow="Chief of staff" + title="Follow-through supervision" + description="Overdue, stale waiting-for, and slipped commitment supervision is unavailable in this mode." + > + <EmptyState + title="Follow-through unavailable" + description="Follow-through supervision data is unavailable in this mode." + /> + </SectionCard> + ); + } + + return ( + <SectionCard + eyebrow="Chief of staff" + title="Follow-through supervision" + description="Deterministic supervision queue for overdue, stale waiting-for, and slipped commitments with draft-only follow-up artifacts." + > + <div className="detail-stack"> + <div className="cluster"> + <StatusBadge status={source} label={sourceLabel(source)} /> + <StatusBadge + status={brief.escalation_posture.posture} + label={`Escalation: ${brief.escalation_posture.posture}`} + /> + <span className="meta-pill">{brief.summary.follow_through_total_count} follow-through items</span> + </div> + + {unavailableReason ? ( + <p className="responsive-note">Live follow-through read failed: {unavailableReason}</p> + ) : null} + + <div className="detail-group detail-group--muted"> + <div className="list-row__topline"> + <span className="list-row__eyebrow mono">Escalation rationale</span> + <div className="cluster"> + <span className="meta-pill">Escalate: {brief.escalation_posture.escalate_count}</span> + <span className="meta-pill">Nudge: {brief.escalation_posture.nudge_count}</span> + <span className="meta-pill">Defer: {brief.escalation_posture.defer_count}</span> + </div> + </div> + <p>{brief.escalation_posture.reason}</p> + </div> + + <div className="detail-group detail-group--muted"> + <div className="list-row__topline"> + <div className="detail-stack"> + <span className="list-row__eyebrow mono">Draft follow-up artifact</span> + <span className="list-row__title"> + {brief.draft_follow_up.status === "drafted" + ? brief.draft_follow_up.content.subject + : "No draft follow-up generated"} + </span> + </div> + <StatusBadge status={brief.draft_follow_up.mode} label={brief.draft_follow_up.mode} /> + </div> + <p className="muted-copy">Auto send: {brief.draft_follow_up.auto_send ? "enabled" : "disabled"}</p> + <p>{brief.draft_follow_up.reason}</p> + {brief.draft_follow_up.status === "drafted" ? ( + <pre className="panel-code">{brief.draft_follow_up.content.body}</pre> + ) : null} + </div> + + {renderGroup( + "Overdue items", + brief.overdue_items, + "No overdue follow-through items for the current scope.", + )} + {renderGroup( + "Stale waiting-for items", + brief.stale_waiting_for_items, + "No stale waiting-for items for the current scope.", + )} + {renderGroup( + "Slipped commitments", + brief.slipped_commitments, + "No slipped commitments for the current scope.", + )} + </div> + </SectionCard> + ); +} diff --git a/apps/web/components/chief-of-staff-handoff-queue-panel.test.tsx b/apps/web/components/chief-of-staff-handoff-queue-panel.test.tsx new file mode 100644 index 0000000..bc5482b --- /dev/null +++ b/apps/web/components/chief-of-staff-handoff-queue-panel.test.tsx @@ -0,0 +1,253 @@ +import React from "react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { ChiefOfStaffPriorityBrief } from "../lib/api"; +import { ChiefOfStaffHandoffQueuePanel } from "./chief-of-staff-handoff-queue-panel"; + +const { captureChiefOfStaffHandoffReviewActionMock } = vi.hoisted(() => ({ + captureChiefOfStaffHandoffReviewActionMock: vi.fn(), +})); + +vi.mock("../lib/api", async () => { + const actual = await vi.importActual("../lib/api"); + return { + ...actual, + captureChiefOfStaffHandoffReviewAction: captureChiefOfStaffHandoffReviewActionMock, + }; +}); + +const briefFixture = { + scope: { + thread_id: "thread-1", + task_id: null, + project: null, + person: null, + }, + handoff_queue_summary: { + total_count: 1, + ready_count: 1, + pending_approval_count: 0, + executed_count: 0, + stale_count: 0, + expired_count: 0, + state_order: ["ready", "pending_approval", "executed", "stale", "expired"] as const, + group_order: ["ready", "pending_approval", "executed", "stale", "expired"] as const, + item_order: ["queue_rank_asc", "handoff_rank_asc", "score_desc", "handoff_item_id_asc"], + review_action_order: ["mark_ready", "mark_pending_approval", "mark_executed", "mark_stale", "mark_expired"] as const, + }, + handoff_queue_groups: { + ready: { + items: [ + { + queue_rank: 1, + handoff_rank: 1, + handoff_item_id: "handoff-1", + lifecycle_state: "ready", + state_reason: "Handoff item is ready for explicit operator review.", + source_kind: "recommended_next_action", + source_reference_id: "priority-1", + title: "Next Action: Ship dashboard", + recommendation_action: "execute_next_action", + priority_posture: "urgent", + confidence_posture: "low", + score: 1650, + age_hours_relative_to_latest: 0, + review_action_order: ["mark_ready", "mark_pending_approval", "mark_executed", "mark_stale", "mark_expired"] as const, + available_review_actions: ["mark_pending_approval", "mark_executed", "mark_stale", "mark_expired"] as const, + last_review_action: null, + provenance_references: [], + }, + ], + summary: { + lifecycle_state: "ready", + returned_count: 1, + total_count: 1, + order: ["queue_rank_asc", "handoff_rank_asc", "score_desc", "handoff_item_id_asc"], + }, + empty_state: { + is_empty: false, + message: "No ready handoff items for this scope.", + }, + }, + pending_approval: { + items: [], + summary: { + lifecycle_state: "pending_approval", + returned_count: 0, + total_count: 0, + order: ["queue_rank_asc", "handoff_rank_asc", "score_desc", "handoff_item_id_asc"], + }, + empty_state: { + is_empty: true, + message: "No handoff items are currently pending approval.", + }, + }, + executed: { + items: [], + summary: { + lifecycle_state: "executed", + returned_count: 0, + total_count: 0, + order: ["queue_rank_asc", "handoff_rank_asc", "score_desc", "handoff_item_id_asc"], + }, + empty_state: { + is_empty: true, + message: "No handoff items are currently marked executed.", + }, + }, + stale: { + items: [], + summary: { + lifecycle_state: "stale", + returned_count: 0, + total_count: 0, + order: ["queue_rank_asc", "handoff_rank_asc", "score_desc", "handoff_item_id_asc"], + }, + empty_state: { + is_empty: true, + message: "No stale handoff items are currently surfaced.", + }, + }, + expired: { + items: [], + summary: { + lifecycle_state: "expired", + returned_count: 0, + total_count: 0, + order: ["queue_rank_asc", "handoff_rank_asc", "score_desc", "handoff_item_id_asc"], + }, + empty_state: { + is_empty: true, + message: "No expired handoff items are currently surfaced.", + }, + }, + }, + handoff_review_actions: [], +} as const; + +describe("ChiefOfStaffHandoffQueuePanel", () => { + beforeEach(() => { + captureChiefOfStaffHandoffReviewActionMock.mockReset(); + captureChiefOfStaffHandoffReviewActionMock.mockResolvedValue({ + review_action: { + id: "review-1", + capture_event_id: "capture-review-1", + handoff_item_id: "handoff-1", + review_action: "mark_stale", + previous_lifecycle_state: "ready", + next_lifecycle_state: "stale", + reason: "Operator review action moved queue posture to stale.", + note: null, + provenance_references: [], + created_at: "2026-04-01T09:00:00Z", + updated_at: "2026-04-01T09:00:00Z", + }, + handoff_queue_summary: { + ...briefFixture.handoff_queue_summary, + ready_count: 0, + stale_count: 1, + }, + handoff_queue_groups: { + ...briefFixture.handoff_queue_groups, + ready: { + ...briefFixture.handoff_queue_groups.ready, + items: [], + summary: { + ...briefFixture.handoff_queue_groups.ready.summary, + returned_count: 0, + total_count: 0, + }, + empty_state: { + is_empty: true, + message: "No ready handoff items for this scope.", + }, + }, + stale: { + ...briefFixture.handoff_queue_groups.stale, + items: [ + { + ...briefFixture.handoff_queue_groups.ready.items[0], + lifecycle_state: "stale", + state_reason: "Latest operator review action 'mark_stale' set lifecycle state to 'stale'.", + available_review_actions: ["mark_ready", "mark_pending_approval", "mark_executed", "mark_expired"], + }, + ], + summary: { + ...briefFixture.handoff_queue_groups.stale.summary, + returned_count: 1, + total_count: 1, + }, + empty_state: { + is_empty: false, + message: "No stale handoff items are currently surfaced.", + }, + }, + }, + handoff_review_actions: [ + { + id: "review-1", + capture_event_id: "capture-review-1", + handoff_item_id: "handoff-1", + review_action: "mark_stale", + previous_lifecycle_state: "ready", + next_lifecycle_state: "stale", + reason: "Operator review action moved queue posture to stale.", + note: null, + provenance_references: [], + created_at: "2026-04-01T09:00:00Z", + updated_at: "2026-04-01T09:00:00Z", + }, + ], + }); + }); + + afterEach(() => { + cleanup(); + }); + + it("renders deterministic grouped handoff queue posture", () => { + render( + <ChiefOfStaffHandoffQueuePanel + brief={briefFixture as unknown as ChiefOfStaffPriorityBrief} + source="fixture" + />, + ); + + expect(screen.getByText("Fixture handoff queue")).toBeInTheDocument(); + expect(screen.getByText("ready (1)")).toBeInTheDocument(); + expect(screen.getByText("Next Action: Ship dashboard")).toBeInTheDocument(); + expect(screen.getByText("Review action history")).toBeInTheDocument(); + expect( + screen.getByText("No explicit handoff review actions captured for this scope."), + ).toBeInTheDocument(); + }); + + it("submits explicit review actions in live mode and updates queue posture", async () => { + render( + <ChiefOfStaffHandoffQueuePanel + apiBaseUrl="https://api.example.com" + userId="user-1" + brief={briefFixture as unknown as ChiefOfStaffPriorityBrief} + source="live" + />, + ); + + fireEvent.click(screen.getByRole("button", { name: "Mark stale" })); + + await waitFor(() => { + expect(captureChiefOfStaffHandoffReviewActionMock).toHaveBeenCalledWith( + "https://api.example.com", + expect.objectContaining({ + user_id: "user-1", + handoff_item_id: "handoff-1", + review_action: "mark_stale", + thread_id: "thread-1", + }), + ); + }); + + expect(screen.getByText("Applied mark_stale to handoff-1.")).toBeInTheDocument(); + expect(screen.getByText("Operator review action moved queue posture to stale.")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/chief-of-staff-handoff-queue-panel.tsx b/apps/web/components/chief-of-staff-handoff-queue-panel.tsx new file mode 100644 index 0000000..a213455 --- /dev/null +++ b/apps/web/components/chief-of-staff-handoff-queue-panel.tsx @@ -0,0 +1,292 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import type { + ApiSource, + ChiefOfStaffHandoffQueueGroups, + ChiefOfStaffHandoffQueueItem, + ChiefOfStaffHandoffQueueLifecycleState, + ChiefOfStaffHandoffQueueSummary, + ChiefOfStaffHandoffReviewAction, + ChiefOfStaffHandoffReviewActionRecord, + ChiefOfStaffPriorityBrief, +} from "../lib/api"; +import { captureChiefOfStaffHandoffReviewAction } from "../lib/api"; +import { EmptyState } from "./empty-state"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +type ChiefOfStaffHandoffQueuePanelProps = { + apiBaseUrl?: string; + userId?: string; + brief: ChiefOfStaffPriorityBrief | null; + source: ApiSource | "unavailable"; + unavailableReason?: string; +}; + +const DEFAULT_STATE_ORDER: ChiefOfStaffHandoffQueueLifecycleState[] = [ + "ready", + "pending_approval", + "executed", + "stale", + "expired", +]; + +const ACTION_LABELS: Record<ChiefOfStaffHandoffReviewAction, string> = { + mark_ready: "Mark ready", + mark_pending_approval: "Mark pending approval", + mark_executed: "Mark executed", + mark_stale: "Mark stale", + mark_expired: "Mark expired", +}; + +function sourceLabel(source: ApiSource | "unavailable") { + if (source === "live") { + return "Live handoff queue"; + } + if (source === "fixture") { + return "Fixture handoff queue"; + } + return "Handoff queue unavailable"; +} + +function formatLifecycleState(state: ChiefOfStaffHandoffQueueLifecycleState) { + return state.replaceAll("_", " "); +} + +function renderQueueItem( + item: ChiefOfStaffHandoffQueueItem, + options: { + isSubmitting: boolean; + liveModeReady: boolean; + onApply: (item: ChiefOfStaffHandoffQueueItem, action: ChiefOfStaffHandoffReviewAction) => Promise<void>; + }, +) { + const { isSubmitting, liveModeReady, onApply } = options; + return ( + <li key={item.handoff_item_id} className="list-row"> + <div className="list-row__topline"> + <div className="detail-stack"> + <span className="list-row__eyebrow mono">Queue #{item.queue_rank}</span> + <span className="list-row__title">{item.title}</span> + </div> + <div className="cluster"> + <StatusBadge status={item.lifecycle_state} label={formatLifecycleState(item.lifecycle_state)} /> + <StatusBadge status={item.recommendation_action} label={item.recommendation_action} /> + <StatusBadge status={item.confidence_posture} label={`${item.confidence_posture} confidence`} /> + </div> + </div> + + <p className="muted-copy"> + Handoff #{item.handoff_rank} ({item.handoff_item_id}) | Score: {item.score.toFixed(6)} + </p> + {item.age_hours_relative_to_latest !== null ? ( + <p className="muted-copy">Age relative to latest source: {item.age_hours_relative_to_latest.toFixed(1)}h</p> + ) : null} + <p>{item.state_reason}</p> + + <div className="composer-actions"> + {item.available_review_actions.map((action) => ( + <button + key={`${item.handoff_item_id}-${action}`} + type="button" + className="button button--ghost" + disabled={!liveModeReady || isSubmitting} + onClick={() => void onApply(item, action)} + > + {ACTION_LABELS[action]} + </button> + ))} + </div> + </li> + ); +} + +export function ChiefOfStaffHandoffQueuePanel({ + apiBaseUrl, + userId, + brief, + source, + unavailableReason, +}: ChiefOfStaffHandoffQueuePanelProps) { + const liveModeReady = Boolean(apiBaseUrl && userId && source === "live"); + const [queueSummary, setQueueSummary] = useState<ChiefOfStaffHandoffQueueSummary | null>( + brief?.handoff_queue_summary ?? null, + ); + const [queueGroups, setQueueGroups] = useState<ChiefOfStaffHandoffQueueGroups | null>( + brief?.handoff_queue_groups ?? null, + ); + const [reviewActions, setReviewActions] = useState<ChiefOfStaffHandoffReviewActionRecord[]>( + brief?.handoff_review_actions ?? [], + ); + const [submittingItemId, setSubmittingItemId] = useState<string | null>(null); + const [statusTone, setStatusTone] = useState<"info" | "success" | "danger">("info"); + const [statusText, setStatusText] = useState( + "Use explicit review actions to transition queue posture without autonomous side effects.", + ); + + useEffect(() => { + setQueueSummary(brief?.handoff_queue_summary ?? null); + setQueueGroups(brief?.handoff_queue_groups ?? null); + setReviewActions(brief?.handoff_review_actions ?? []); + setSubmittingItemId(null); + setStatusTone("info"); + setStatusText("Use explicit review actions to transition queue posture without autonomous side effects."); + }, [brief]); + + async function applyReviewAction( + item: ChiefOfStaffHandoffQueueItem, + action: ChiefOfStaffHandoffReviewAction, + ) { + if (!brief || !apiBaseUrl || !userId || !liveModeReady) { + setStatusTone("info"); + setStatusText("Review actions are available only when live API mode is configured."); + return; + } + + setSubmittingItemId(item.handoff_item_id); + setStatusTone("info"); + setStatusText(`Applying ${action} to ${item.handoff_item_id}...`); + + try { + const payload = await captureChiefOfStaffHandoffReviewAction(apiBaseUrl, { + user_id: userId, + handoff_item_id: item.handoff_item_id, + review_action: action, + note: `Captured from handoff queue controls as ${action}.`, + thread_id: brief.scope.thread_id ?? null, + task_id: brief.scope.task_id ?? null, + project: brief.scope.project ?? null, + person: brief.scope.person ?? null, + }); + setQueueSummary(payload.handoff_queue_summary); + setQueueGroups(payload.handoff_queue_groups); + setReviewActions(payload.handoff_review_actions); + setStatusTone("success"); + setStatusText(`Applied ${action} to ${item.handoff_item_id}.`); + } catch (error) { + setStatusTone("danger"); + setStatusText( + `Unable to apply review action: ${error instanceof Error ? error.message : "Request failed"}`, + ); + } finally { + setSubmittingItemId(null); + } + } + + if (brief === null || queueSummary === null || queueGroups === null) { + return ( + <SectionCard + eyebrow="Chief of staff" + title="Handoff queue" + description="Handoff queue artifacts are unavailable in this mode." + > + <EmptyState + title="Handoff queue unavailable" + description="Handoff queue artifacts are unavailable in this mode." + /> + </SectionCard> + ); + } + + const stateOrder = queueSummary.state_order.length > 0 ? queueSummary.state_order : DEFAULT_STATE_ORDER; + + return ( + <SectionCard + eyebrow="Chief of staff" + title="Handoff queue" + description="Deterministic grouped queue posture with explicit operator lifecycle review controls." + > + <div className="detail-stack"> + <div className="cluster"> + <StatusBadge status={source} label={sourceLabel(source)} /> + <span className="meta-pill">{queueSummary.total_count} queued</span> + <span className="meta-pill">{queueSummary.stale_count} stale</span> + <span className="meta-pill">{queueSummary.expired_count} expired</span> + </div> + + {unavailableReason ? ( + <p className="responsive-note">Live handoff queue read failed: {unavailableReason}</p> + ) : null} + + <div className="composer-status" aria-live="polite"> + <StatusBadge + status={ + submittingItemId + ? "submitting" + : statusTone === "success" + ? "success" + : statusTone === "danger" + ? "error" + : liveModeReady + ? "ready" + : "unavailable" + } + label={ + submittingItemId + ? "Submitting" + : statusTone === "success" + ? "Captured" + : statusTone === "danger" + ? "Attention" + : liveModeReady + ? "Ready" + : "Unavailable" + } + /> + <span>{statusText}</span> + </div> + + {stateOrder.map((lifecycleState) => { + const group = queueGroups[lifecycleState]; + return ( + <div key={lifecycleState} className="detail-group"> + <h3> + {formatLifecycleState(lifecycleState)} ({group.summary.total_count}) + </h3> + {group.items.length === 0 ? ( + <p className="muted-copy">{group.empty_state.message}</p> + ) : ( + <ul className="detail-stack"> + {group.items.map((item) => + renderQueueItem(item, { + isSubmitting: submittingItemId !== null, + liveModeReady, + onApply: applyReviewAction, + }), + )} + </ul> + )} + </div> + ); + })} + + <div className="detail-group detail-group--muted"> + <h3>Review action history</h3> + {reviewActions.length === 0 ? ( + <p className="muted-copy">No explicit handoff review actions captured for this scope.</p> + ) : ( + <ul className="detail-stack"> + {reviewActions.map((action) => ( + <li key={action.id} className="list-row"> + <div className="list-row__topline"> + <div className="detail-stack"> + <span className="list-row__eyebrow mono">{action.created_at}</span> + <span className="list-row__title">{action.handoff_item_id}</span> + </div> + <StatusBadge status={action.review_action} label={ACTION_LABELS[action.review_action]} /> + </div> + <p className="muted-copy"> + {action.previous_lifecycle_state ?? "none"} → {action.next_lifecycle_state} + </p> + <p>{action.reason}</p> + </li> + ))} + </ul> + )} + </div> + </div> + </SectionCard> + ); +} diff --git a/apps/web/components/chief-of-staff-outcome-learning-panel.test.tsx b/apps/web/components/chief-of-staff-outcome-learning-panel.test.tsx new file mode 100644 index 0000000..bf7532f --- /dev/null +++ b/apps/web/components/chief-of-staff-outcome-learning-panel.test.tsx @@ -0,0 +1,260 @@ +import React from "react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { ChiefOfStaffPriorityBrief } from "../lib/api"; +import { ChiefOfStaffOutcomeLearningPanel } from "./chief-of-staff-outcome-learning-panel"; + +const { captureChiefOfStaffHandoffOutcomeMock } = vi.hoisted(() => ({ + captureChiefOfStaffHandoffOutcomeMock: vi.fn(), +})); + +vi.mock("../lib/api", async () => { + const actual = await vi.importActual("../lib/api"); + return { + ...actual, + captureChiefOfStaffHandoffOutcome: captureChiefOfStaffHandoffOutcomeMock, + }; +}); + +const briefFixture = { + scope: { + thread_id: "thread-1", + task_id: null, + project: null, + person: null, + }, + routed_handoff_items: [ + { + handoff_rank: 1, + handoff_item_id: "handoff-1", + title: "Next Action: Ship dashboard", + source_kind: "recommended_next_action", + recommendation_action: "execute_next_action", + route_target_order: ["task_workflow_draft", "approval_workflow_draft", "follow_up_draft_only"] as const, + available_route_targets: ["task_workflow_draft", "approval_workflow_draft"] as const, + routed_targets: ["task_workflow_draft"] as const, + is_routed: true, + task_workflow_draft_routed: true, + approval_workflow_draft_routed: false, + follow_up_draft_only_routed: false, + follow_up_draft_only_applicable: false, + task_draft: { + status: "draft", + mode: "governed_request_draft", + approval_required: true, + auto_execute: false, + source_handoff_item_id: "handoff-1", + title: "Next Action: Ship dashboard", + summary: "Draft-only governed request.", + target: { thread_id: "thread-1", task_id: null, project: null, person: null }, + request: { + action: "execute_next_action", + scope: "chief_of_staff_priority", + domain_hint: "planning", + risk_hint: "governed_handoff", + attributes: {}, + }, + rationale: "fixture rationale", + provenance_references: [], + }, + approval_draft: { + status: "draft_only", + mode: "approval_request_draft", + decision: "approval_required", + approval_required: true, + auto_submit: false, + source_handoff_item_id: "handoff-1", + request: { + action: "execute_next_action", + scope: "chief_of_staff_priority", + domain_hint: "planning", + risk_hint: "governed_handoff", + attributes: {}, + }, + reason: "approval required", + required_checks: ["operator_review_handoff_artifact"], + provenance_references: [], + }, + last_routing_transition: null, + }, + ], + handoff_outcome_summary: { + returned_count: 0, + total_count: 0, + latest_total_count: 0, + status_counts: { + reviewed: 0, + approved: 0, + rejected: 0, + rewritten: 0, + executed: 0, + ignored: 0, + expired: 0, + }, + latest_status_counts: { + reviewed: 0, + approved: 0, + rejected: 0, + rewritten: 0, + executed: 0, + ignored: 0, + expired: 0, + }, + status_order: ["reviewed", "approved", "rejected", "rewritten", "executed", "ignored", "expired"] as const, + order: ["created_at_desc", "id_desc"], + }, + handoff_outcomes: [], + closure_quality_summary: { + posture: "insufficient_signal", + reason: "No handoff outcomes are captured yet, so closure quality remains informational.", + closed_loop_count: 0, + unresolved_count: 0, + rejected_count: 0, + ignored_count: 0, + expired_count: 0, + closure_rate: 0, + explanation: "Closure quality uses the latest immutable outcome per handoff item.", + }, + conversion_signal_summary: { + total_handoff_count: 1, + latest_outcome_count: 0, + executed_count: 0, + approved_count: 0, + reviewed_count: 0, + rewritten_count: 0, + rejected_count: 0, + ignored_count: 0, + expired_count: 0, + recommendation_to_execution_conversion_rate: 0, + recommendation_to_closure_conversion_rate: 0, + capture_coverage_rate: 0, + explanation: "Conversion signals are derived from latest immutable outcomes.", + }, + stale_ignored_escalation_posture: { + posture: "watch", + reason: "No stale queue pressure or ignored/expired latest outcomes are currently detected.", + stale_queue_count: 0, + ignored_count: 0, + expired_count: 0, + trigger_count: 0, + guidance_posture_explanation: "Guidance posture is derived from stale queue load plus ignored/expired outcomes.", + supporting_signals: ["stale_queue_count=0", "ignored_count=0", "expired_count=0", "trigger_count=0"], + }, +} as const; + +describe("ChiefOfStaffOutcomeLearningPanel", () => { + beforeEach(() => { + captureChiefOfStaffHandoffOutcomeMock.mockReset(); + captureChiefOfStaffHandoffOutcomeMock.mockResolvedValue({ + handoff_outcome: { + id: "handoff-outcome-1", + capture_event_id: "capture-handoff-outcome-1", + handoff_item_id: "handoff-1", + outcome_status: "executed", + previous_outcome_status: null, + is_latest_outcome: true, + reason: "Operator captured routed handoff outcome 'executed' for 'handoff-1'.", + note: null, + provenance_references: [], + created_at: "2026-04-07T09:30:00Z", + updated_at: "2026-04-07T09:30:00Z", + }, + handoff_outcome_summary: { + ...briefFixture.handoff_outcome_summary, + returned_count: 1, + total_count: 1, + latest_total_count: 1, + status_counts: { + ...briefFixture.handoff_outcome_summary.status_counts, + executed: 1, + }, + latest_status_counts: { + ...briefFixture.handoff_outcome_summary.latest_status_counts, + executed: 1, + }, + }, + handoff_outcomes: [ + { + id: "handoff-outcome-1", + capture_event_id: "capture-handoff-outcome-1", + handoff_item_id: "handoff-1", + outcome_status: "executed", + previous_outcome_status: null, + is_latest_outcome: true, + reason: "Operator captured routed handoff outcome 'executed' for 'handoff-1'.", + note: null, + provenance_references: [], + created_at: "2026-04-07T09:30:00Z", + updated_at: "2026-04-07T09:30:00Z", + }, + ], + closure_quality_summary: { + posture: "healthy", + reason: "Closed-loop outcomes are leading with bounded unresolved and ignored outcomes.", + closed_loop_count: 1, + unresolved_count: 0, + rejected_count: 0, + ignored_count: 0, + expired_count: 0, + closure_rate: 1, + explanation: "Closure quality uses latest immutable outcomes.", + }, + conversion_signal_summary: { + ...briefFixture.conversion_signal_summary, + latest_outcome_count: 1, + executed_count: 1, + recommendation_to_execution_conversion_rate: 1, + recommendation_to_closure_conversion_rate: 1, + capture_coverage_rate: 1, + }, + stale_ignored_escalation_posture: briefFixture.stale_ignored_escalation_posture, + }); + }); + + afterEach(() => { + cleanup(); + }); + + it("renders deterministic outcome-learning artifacts and controls", () => { + render( + <ChiefOfStaffOutcomeLearningPanel + brief={briefFixture as unknown as ChiefOfStaffPriorityBrief} + source="fixture" + />, + ); + + expect(screen.getByText("Fixture outcome learning")).toBeInTheDocument(); + expect(screen.getByText("Routed handoff outcome capture controls")).toBeInTheDocument(); + expect(screen.getByText("Closure quality summary")).toBeInTheDocument(); + expect(screen.getByText("No handoff outcomes captured for this scope.")).toBeInTheDocument(); + }); + + it("captures routed handoff outcomes in live mode and updates summaries", async () => { + render( + <ChiefOfStaffOutcomeLearningPanel + apiBaseUrl="https://api.example.com" + userId="user-1" + brief={briefFixture as unknown as ChiefOfStaffPriorityBrief} + source="live" + />, + ); + + fireEvent.click(screen.getByRole("button", { name: "Executed" })); + + await waitFor(() => { + expect(captureChiefOfStaffHandoffOutcomeMock).toHaveBeenCalledWith( + "https://api.example.com", + expect.objectContaining({ + user_id: "user-1", + handoff_item_id: "handoff-1", + outcome_status: "executed", + thread_id: "thread-1", + }), + ); + }); + + expect(screen.getByText("Captured executed for handoff-1.")).toBeInTheDocument(); + expect(screen.getByText("Operator captured routed handoff outcome 'executed' for 'handoff-1'.")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/chief-of-staff-outcome-learning-panel.tsx b/apps/web/components/chief-of-staff-outcome-learning-panel.tsx new file mode 100644 index 0000000..8c1edc7 --- /dev/null +++ b/apps/web/components/chief-of-staff-outcome-learning-panel.tsx @@ -0,0 +1,332 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import type { + ApiSource, + ChiefOfStaffClosureQualitySummary, + ChiefOfStaffConversionSignalSummary, + ChiefOfStaffHandoffOutcomeRecord, + ChiefOfStaffHandoffOutcomeStatus, + ChiefOfStaffHandoffOutcomeSummary, + ChiefOfStaffPriorityBrief, + ChiefOfStaffRoutedHandoffItem, + ChiefOfStaffStaleIgnoredEscalationPosture, +} from "../lib/api"; +import { captureChiefOfStaffHandoffOutcome } from "../lib/api"; +import { EmptyState } from "./empty-state"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +type ChiefOfStaffOutcomeLearningPanelProps = { + apiBaseUrl?: string; + userId?: string; + brief: ChiefOfStaffPriorityBrief | null; + source: ApiSource | "unavailable"; + unavailableReason?: string; +}; + +const OUTCOME_STATUS_LABELS: Record<ChiefOfStaffHandoffOutcomeStatus, string> = { + reviewed: "Reviewed", + approved: "Approved", + rejected: "Rejected", + rewritten: "Rewritten", + executed: "Executed", + ignored: "Ignored", + expired: "Expired", +}; + +function sourceLabel(source: ApiSource | "unavailable") { + if (source === "live") { + return "Live outcome learning"; + } + if (source === "fixture") { + return "Fixture outcome learning"; + } + return "Outcome learning unavailable"; +} + +function renderOutcomeCaptureControls( + item: ChiefOfStaffRoutedHandoffItem, + options: { + isSubmitting: boolean; + liveModeReady: boolean; + onCapture: (item: ChiefOfStaffRoutedHandoffItem, status: ChiefOfStaffHandoffOutcomeStatus) => Promise<void>; + }, +) { + const { isSubmitting, liveModeReady, onCapture } = options; + return ( + <li key={item.handoff_item_id} className="list-row"> + <div className="list-row__topline"> + <div className="detail-stack"> + <span className="list-row__eyebrow mono">Handoff #{item.handoff_rank}</span> + <span className="list-row__title">{item.title}</span> + </div> + <div className="cluster"> + <StatusBadge status={item.source_kind} label={item.source_kind} /> + <StatusBadge status="routed" label="Routed" /> + </div> + </div> + <p className="muted-copy"> + {item.handoff_item_id} | Routed targets: {item.routed_targets.join(", ")} + </p> + <div className="composer-actions"> + {(Object.keys(OUTCOME_STATUS_LABELS) as ChiefOfStaffHandoffOutcomeStatus[]).map((status) => ( + <button + key={`${item.handoff_item_id}-${status}`} + type="button" + className="button button--ghost" + disabled={!liveModeReady || isSubmitting} + onClick={() => void onCapture(item, status)} + > + {OUTCOME_STATUS_LABELS[status]} + </button> + ))} + </div> + </li> + ); +} + +export function ChiefOfStaffOutcomeLearningPanel({ + apiBaseUrl, + userId, + brief, + source, + unavailableReason, +}: ChiefOfStaffOutcomeLearningPanelProps) { + const liveModeReady = Boolean(apiBaseUrl && userId && source === "live"); + const [handoffOutcomeSummary, setHandoffOutcomeSummary] = useState<ChiefOfStaffHandoffOutcomeSummary | null>( + brief?.handoff_outcome_summary ?? null, + ); + const [handoffOutcomes, setHandoffOutcomes] = useState<ChiefOfStaffHandoffOutcomeRecord[]>( + brief?.handoff_outcomes ?? [], + ); + const [closureQualitySummary, setClosureQualitySummary] = useState<ChiefOfStaffClosureQualitySummary | null>( + brief?.closure_quality_summary ?? null, + ); + const [conversionSignalSummary, setConversionSignalSummary] = useState<ChiefOfStaffConversionSignalSummary | null>( + brief?.conversion_signal_summary ?? null, + ); + const [staleIgnoredEscalationPosture, setStaleIgnoredEscalationPosture] = + useState<ChiefOfStaffStaleIgnoredEscalationPosture | null>(brief?.stale_ignored_escalation_posture ?? null); + const [submittingKey, setSubmittingKey] = useState<string | null>(null); + const [statusTone, setStatusTone] = useState<"info" | "success" | "danger">("info"); + const [statusText, setStatusText] = useState( + "Capture explicit routed handoff outcomes to keep closure quality and conversion signals deterministic.", + ); + + useEffect(() => { + setHandoffOutcomeSummary(brief?.handoff_outcome_summary ?? null); + setHandoffOutcomes(brief?.handoff_outcomes ?? []); + setClosureQualitySummary(brief?.closure_quality_summary ?? null); + setConversionSignalSummary(brief?.conversion_signal_summary ?? null); + setStaleIgnoredEscalationPosture(brief?.stale_ignored_escalation_posture ?? null); + setSubmittingKey(null); + setStatusTone("info"); + setStatusText("Capture explicit routed handoff outcomes to keep closure quality and conversion signals deterministic."); + }, [brief]); + + async function captureOutcome( + item: ChiefOfStaffRoutedHandoffItem, + outcomeStatus: ChiefOfStaffHandoffOutcomeStatus, + ) { + if (!brief || !apiBaseUrl || !userId || !liveModeReady) { + setStatusTone("info"); + setStatusText("Outcome capture controls are available only when live API mode is configured."); + return; + } + + const actionKey = `${item.handoff_item_id}:${outcomeStatus}`; + setSubmittingKey(actionKey); + setStatusTone("info"); + setStatusText(`Capturing ${outcomeStatus} for ${item.handoff_item_id}...`); + + try { + const payload = await captureChiefOfStaffHandoffOutcome(apiBaseUrl, { + user_id: userId, + handoff_item_id: item.handoff_item_id, + outcome_status: outcomeStatus, + note: `Captured from outcome learning controls as ${outcomeStatus}.`, + thread_id: brief.scope.thread_id ?? null, + task_id: brief.scope.task_id ?? null, + project: brief.scope.project ?? null, + person: brief.scope.person ?? null, + }); + setHandoffOutcomeSummary(payload.handoff_outcome_summary); + setHandoffOutcomes(payload.handoff_outcomes); + setClosureQualitySummary(payload.closure_quality_summary); + setConversionSignalSummary(payload.conversion_signal_summary); + setStaleIgnoredEscalationPosture(payload.stale_ignored_escalation_posture); + setStatusTone("success"); + setStatusText(`Captured ${outcomeStatus} for ${item.handoff_item_id}.`); + } catch (error) { + setStatusTone("danger"); + setStatusText( + `Unable to capture handoff outcome: ${error instanceof Error ? error.message : "Request failed"}`, + ); + } finally { + setSubmittingKey(null); + } + } + + if ( + brief === null || + handoffOutcomeSummary === null || + closureQualitySummary === null || + conversionSignalSummary === null || + staleIgnoredEscalationPosture === null + ) { + return ( + <SectionCard + eyebrow="Chief of staff" + title="Outcome learning" + description="Outcome-learning artifacts are unavailable in this mode." + > + <EmptyState + title="Outcome learning unavailable" + description="Outcome-learning artifacts are unavailable in this mode." + /> + </SectionCard> + ); + } + + const captureCandidates = brief.routed_handoff_items.filter((item) => item.is_routed); + + return ( + <SectionCard + eyebrow="Chief of staff" + title="Outcome learning" + description="Deterministic routed-handoff outcome capture with explainable closure quality, conversion signals, and stale/ignored escalation posture." + > + <div className="detail-stack"> + <div className="cluster"> + <StatusBadge status={source} label={sourceLabel(source)} /> + <StatusBadge status={closureQualitySummary.posture} label={`Closure: ${closureQualitySummary.posture}`} /> + <StatusBadge + status={staleIgnoredEscalationPosture.posture} + label={`Escalation: ${staleIgnoredEscalationPosture.posture}`} + /> + <span className="meta-pill">{handoffOutcomeSummary.latest_total_count} latest outcomes</span> + </div> + + {unavailableReason ? ( + <p className="responsive-note">Live outcome-learning read failed: {unavailableReason}</p> + ) : null} + + <div className="composer-status" aria-live="polite"> + <StatusBadge + status={ + submittingKey + ? "submitting" + : statusTone === "success" + ? "success" + : statusTone === "danger" + ? "error" + : liveModeReady + ? "ready" + : "unavailable" + } + label={ + submittingKey + ? "Submitting" + : statusTone === "success" + ? "Captured" + : statusTone === "danger" + ? "Attention" + : liveModeReady + ? "Ready" + : "Unavailable" + } + /> + <span>{statusText}</span> + </div> + + <div className="detail-group"> + <h3>Routed handoff outcome capture controls</h3> + {captureCandidates.length === 0 ? ( + <p className="muted-copy"> + Route at least one handoff item in Execution routing before capturing outcomes. + </p> + ) : ( + <ul className="detail-stack"> + {captureCandidates.map((item) => + renderOutcomeCaptureControls(item, { + isSubmitting: submittingKey !== null, + liveModeReady, + onCapture: captureOutcome, + }), + )} + </ul> + )} + </div> + + <div className="detail-group detail-group--muted"> + <h3>Closure quality summary</h3> + <p>{closureQualitySummary.reason}</p> + <p className="muted-copy">{closureQualitySummary.explanation}</p> + <div className="cluster"> + <span className="meta-pill">Closed loop: {closureQualitySummary.closed_loop_count}</span> + <span className="meta-pill">Unresolved: {closureQualitySummary.unresolved_count}</span> + <span className="meta-pill">Ignored: {closureQualitySummary.ignored_count}</span> + <span className="meta-pill">Expired: {closureQualitySummary.expired_count}</span> + </div> + </div> + + <div className="detail-group detail-group--muted"> + <h3>Conversion signals</h3> + <div className="cluster"> + <span className="meta-pill"> + Execution conversion: {conversionSignalSummary.recommendation_to_execution_conversion_rate.toFixed(6)} + </span> + <span className="meta-pill"> + Closure conversion: {conversionSignalSummary.recommendation_to_closure_conversion_rate.toFixed(6)} + </span> + <span className="meta-pill">Coverage: {conversionSignalSummary.capture_coverage_rate.toFixed(6)}</span> + </div> + <p className="muted-copy">{conversionSignalSummary.explanation}</p> + </div> + + <div className="detail-group detail-group--muted"> + <h3>Stale / ignored escalation posture</h3> + <p>{staleIgnoredEscalationPosture.reason}</p> + <p className="muted-copy">{staleIgnoredEscalationPosture.guidance_posture_explanation}</p> + <ul className="detail-stack"> + {staleIgnoredEscalationPosture.supporting_signals.map((signal, index) => ( + <li key={`stale-ignored-signal-${index}`} className="muted-copy"> + {signal} + </li> + ))} + </ul> + </div> + + <div className="detail-group"> + <h3>Captured handoff outcomes</h3> + {handoffOutcomes.length === 0 ? ( + <p className="muted-copy">No handoff outcomes captured for this scope.</p> + ) : ( + <ul className="detail-stack"> + {handoffOutcomes.map((outcome) => ( + <li key={outcome.id} className="list-row"> + <div className="list-row__topline"> + <div className="detail-stack"> + <span className="list-row__eyebrow mono">{outcome.created_at}</span> + <span className="list-row__title">{outcome.handoff_item_id}</span> + </div> + <div className="cluster"> + <StatusBadge status={outcome.outcome_status} label={OUTCOME_STATUS_LABELS[outcome.outcome_status]} /> + {outcome.is_latest_outcome ? <StatusBadge status="latest" label="Latest" /> : null} + </div> + </div> + <p className="muted-copy"> + Previous: {outcome.previous_outcome_status ?? "none"} | Note: {outcome.note ?? "none"} + </p> + <p>{outcome.reason}</p> + </li> + ))} + </ul> + )} + </div> + </div> + </SectionCard> + ); +} diff --git a/apps/web/components/chief-of-staff-preparation-panel.test.tsx b/apps/web/components/chief-of-staff-preparation-panel.test.tsx new file mode 100644 index 0000000..7fcb16f --- /dev/null +++ b/apps/web/components/chief-of-staff-preparation-panel.test.tsx @@ -0,0 +1,398 @@ +import React from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import { ChiefOfStaffPreparationPanel } from "./chief-of-staff-preparation-panel"; + +const briefFixture = { + assembly_version: "chief_of_staff_priority_brief_v0", + scope: { + thread_id: "thread-1", + since: null, + until: null, + }, + ranked_items: [], + overdue_items: [], + stale_waiting_for_items: [], + slipped_commitments: [], + escalation_posture: { + posture: "watch" as const, + reason: "No active follow-through escalations are present.", + total_follow_through_count: 0, + nudge_count: 0, + defer_count: 0, + escalate_count: 0, + close_loop_candidate_count: 0, + }, + draft_follow_up: { + status: "none" as const, + mode: "draft_only" as const, + approval_required: true, + auto_send: false, + reason: "No follow-through targets are currently queued for drafting.", + target_metadata: { + continuity_object_id: null, + capture_event_id: null, + object_type: null, + priority_posture: null, + follow_through_posture: null, + recommendation_action: null, + thread_id: "thread-1", + }, + content: { + subject: "", + body: "", + }, + }, + recommended_next_action: { + action_type: "execute_next_action" as const, + title: "Next Action: Ship dashboard", + target_priority_id: "priority-1", + priority_posture: "urgent" as const, + confidence_posture: "low" as const, + reason: "Marked urgent because this item is a deterministic immediate focus from resumption signals.", + provenance_references: [ + { + source_kind: "continuity_capture_event", + source_id: "capture-1", + }, + ], + deterministic_rank_key: "1:priority-1:640.000000", + }, + preparation_brief: { + scope: { thread_id: "thread-1", since: null, until: null }, + context_items: [ + { + rank: 1, + id: "context-1", + capture_event_id: "capture-context-1", + object_type: "Decision" as const, + status: "active", + title: "Decision: Keep launch staged", + reason: "Decision context carried forward for deterministic meeting prep.", + confidence_posture: "low" as const, + provenance_references: [ + { + source_kind: "continuity_capture_event", + source_id: "capture-context-1", + }, + ], + created_at: "2026-03-31T08:00:00Z", + }, + ], + last_decision: { + rank: 1, + id: "decision-1", + capture_event_id: "capture-decision-1", + object_type: "Decision" as const, + status: "active", + title: "Decision: Keep launch staged", + reason: "Latest scoped decision included to ground upcoming preparation context.", + confidence_posture: "low" as const, + provenance_references: [ + { + source_kind: "continuity_capture_event", + source_id: "capture-decision-1", + }, + ], + created_at: "2026-03-31T08:00:00Z", + }, + open_loops: [], + next_action: null, + confidence_posture: "low" as const, + confidence_reason: + "Memory quality gate is weak (insufficient sample or degraded), so recommendation confidence is capped at low.", + summary: { + limit: 6, + returned_count: 1, + total_count: 1, + order: ["rank_asc", "created_at_desc", "id_desc"], + }, + }, + what_changed_summary: { + items: [ + { + rank: 1, + id: "change-1", + capture_event_id: "capture-change-1", + object_type: "NextAction" as const, + status: "active", + title: "Next Action: Publish launch notes", + reason: "Included from deterministic continuity recent-changes ordering.", + confidence_posture: "low" as const, + provenance_references: [ + { + source_kind: "continuity_capture_event", + source_id: "capture-change-1", + }, + ], + created_at: "2026-03-31T09:00:00Z", + }, + ], + confidence_posture: "low" as const, + confidence_reason: "Memory quality posture is weak.", + summary: { + limit: 6, + returned_count: 1, + total_count: 1, + order: ["rank_asc", "created_at_desc", "id_desc"], + }, + }, + prep_checklist: { + items: [ + { + rank: 1, + id: "checklist-1", + capture_event_id: "capture-checklist-1", + object_type: "WaitingFor" as const, + status: "active", + title: "Waiting For: Security review", + reason: "Prepare a status check and explicit owner for this unresolved open loop.", + confidence_posture: "low" as const, + provenance_references: [ + { + source_kind: "continuity_capture_event", + source_id: "capture-checklist-1", + }, + ], + created_at: "2026-03-31T07:30:00Z", + }, + ], + confidence_posture: "low" as const, + confidence_reason: "Memory quality posture is weak.", + summary: { + limit: 6, + returned_count: 1, + total_count: 1, + order: ["rank_asc", "created_at_desc", "id_desc"], + }, + }, + suggested_talking_points: { + items: [ + { + rank: 1, + id: "talking-1", + capture_event_id: "capture-talking-1", + object_type: "Blocker" as const, + status: "active", + title: "Blocker: Missing launch credential", + reason: "Raise this unresolved dependency explicitly and confirm a concrete follow-up path.", + confidence_posture: "low" as const, + provenance_references: [ + { + source_kind: "continuity_capture_event", + source_id: "capture-talking-1", + }, + ], + created_at: "2026-03-31T06:30:00Z", + }, + ], + confidence_posture: "low" as const, + confidence_reason: "Memory quality posture is weak.", + summary: { + limit: 6, + returned_count: 1, + total_count: 1, + order: ["rank_asc", "created_at_desc", "id_desc"], + }, + }, + resumption_supervision: { + recommendations: [ + { + rank: 1, + action: "execute_next_action" as const, + title: "Next Action: Publish launch notes", + reason: "Marked urgent because this item is a deterministic immediate focus from resumption signals.", + confidence_posture: "low" as const, + target_priority_id: "priority-1", + provenance_references: [ + { + source_kind: "continuity_capture_event", + source_id: "capture-1", + }, + ], + }, + { + rank: 2, + action: "review_scope" as const, + title: "Calibrate recommendation confidence before execution", + reason: + "Memory quality gate is weak (insufficient sample or degraded), so recommendation confidence is capped at low.", + confidence_posture: "low" as const, + target_priority_id: null, + provenance_references: [], + }, + ], + confidence_posture: "low" as const, + confidence_reason: + "Memory quality gate is weak (insufficient sample or degraded), so recommendation confidence is capped at low.", + summary: { + limit: 3, + returned_count: 2, + total_count: 2, + order: ["rank_asc"], + }, + }, + weekly_review_brief: { + scope: { thread_id: "thread-1", since: null, until: null }, + rollup: { + total_count: 1, + waiting_for_count: 0, + blocker_count: 1, + stale_count: 0, + correction_recurrence_count: 0, + freshness_drift_count: 0, + next_action_count: 1, + posture_order: ["waiting_for", "blocker", "stale", "next_action"] as const, + }, + guidance: [], + summary: { + guidance_order: ["close", "defer", "escalate"] as const, + guidance_item_order: ["signal_count_desc", "action_desc"], + }, + }, + recommendation_outcomes: { + items: [], + summary: { + returned_count: 0, + total_count: 0, + outcome_counts: { accept: 0, defer: 0, ignore: 0, rewrite: 0 }, + order: ["created_at_desc", "id_desc"], + }, + }, + priority_learning_summary: { + total_count: 0, + accept_count: 0, + defer_count: 0, + ignore_count: 0, + rewrite_count: 0, + acceptance_rate: 0, + override_rate: 0, + defer_hotspots: [], + ignore_hotspots: [], + priority_shift_explanation: + "No recommendation outcomes are captured yet; prioritization remains anchored to current continuity and trust signals.", + hotspot_order: ["count_desc", "key_asc"], + }, + pattern_drift_summary: { + posture: "insufficient_signal" as const, + reason: "No recommendation outcomes are available yet, so drift posture is informational only.", + supporting_signals: [], + }, + action_handoff_brief: { + summary: + "Prepared 1 deterministic handoff item from recommended_next_action signals. All task and approval drafts remain artifact-only and approval-bounded.", + confidence_posture: "low" as const, + non_autonomous_guarantee: + "No task, approval, connector send, or external side effect is executed by this endpoint.", + order: ["score_desc", "source_order_asc", "source_reference_id_asc"], + source_order: ["recommended_next_action", "follow_through", "prep_checklist", "weekly_review"] as const, + provenance_references: [], + }, + handoff_items: [], + task_draft: { + status: "draft" as const, + mode: "governed_request_draft" as const, + approval_required: true, + auto_execute: false, + source_handoff_item_id: "handoff-1-recommended_next_action-priority-1", + title: "Next Action: Ship dashboard", + summary: + "Draft-only governed request assembled from chief-of-staff handoff artifacts; requires explicit approval before any execution.", + target: { thread_id: "thread-1", task_id: null, project: null, person: null }, + request: { + action: "execute_next_action", + scope: "chief_of_staff_priority", + domain_hint: "planning", + risk_hint: "governed_handoff", + attributes: {}, + }, + rationale: "Marked urgent because this item is a deterministic immediate focus from resumption signals.", + provenance_references: [], + }, + approval_draft: { + status: "draft_only" as const, + mode: "approval_request_draft" as const, + decision: "approval_required" as const, + approval_required: true, + auto_submit: false, + source_handoff_item_id: "handoff-1-recommended_next_action-priority-1", + request: { + action: "execute_next_action", + scope: "chief_of_staff_priority", + domain_hint: "planning", + risk_hint: "governed_handoff", + attributes: {}, + }, + reason: + "Execution remains approval-bounded. This approval draft is artifact-only and must be explicitly submitted and resolved before any side effect.", + required_checks: [ + "operator_review_handoff_artifact", + "submit_governed_approval_request", + "explicit_approval_resolution", + ], + provenance_references: [], + }, + execution_posture: { + posture: "approval_bounded_artifact_only" as const, + approval_required: true, + autonomous_execution: false, + external_side_effects_allowed: false, + default_routing_decision: "approval_required" as const, + required_operator_actions: [ + "review_handoff_items", + "submit_task_or_approval_request", + "resolve_approval_before_execution", + ], + non_autonomous_guarantee: + "No task, approval, connector send, or external side effect is executed by this endpoint.", + reason: "Chief-of-staff handoff artifacts are deterministic execution-prep only in P8-S29.", + }, + summary: { + limit: 10, + returned_count: 0, + total_count: 0, + posture_order: ["urgent", "important", "waiting", "blocked", "stale", "defer"] as const, + order: ["score_desc", "created_at_desc", "id_desc"], + follow_through_posture_order: ["overdue", "stale_waiting_for", "slipped_commitment"] as const, + follow_through_item_order: ["recommendation_action_desc", "age_hours_desc", "created_at_desc", "id_desc"], + follow_through_total_count: 0, + overdue_count: 0, + stale_waiting_for_count: 0, + slipped_commitment_count: 0, + trust_confidence_posture: "low" as const, + trust_confidence_reason: "Memory quality posture is weak.", + quality_gate_status: "insufficient_sample" as const, + retrieval_status: "pass" as const, + handoff_item_count: 0, + handoff_item_order: ["score_desc", "source_order_asc", "source_reference_id_asc"], + execution_posture_order: ["approval_bounded_artifact_only"] as const, + }, + sources: ["continuity_recall", "memory_trust_dashboard", "chief_of_staff_action_handoff"], +}; + +describe("ChiefOfStaffPreparationPanel", () => { + afterEach(() => { + cleanup(); + }); + + it("renders deterministic preparation artifacts with rationale and provenance", () => { + render(<ChiefOfStaffPreparationPanel brief={briefFixture} source="live" />); + + expect(screen.getByText("Live preparation brief")).toBeInTheDocument(); + expect(screen.getByText("Preparation context")).toBeInTheDocument(); + expect(screen.getByText("What changed")).toBeInTheDocument(); + expect(screen.getByText("Prep checklist")).toBeInTheDocument(); + expect(screen.getByText("Suggested talking points")).toBeInTheDocument(); + expect(screen.getByText("Resumption supervision")).toBeInTheDocument(); + expect(screen.getByText("Decision: Keep launch staged")).toBeInTheDocument(); + expect(screen.getByText("Provenance: continuity_capture_event:capture-context-1")).toBeInTheDocument(); + expect(screen.getByText("Calibrate recommendation confidence before execution")).toBeInTheDocument(); + }); + + it("renders explicit fallback when brief payload is absent", () => { + render(<ChiefOfStaffPreparationPanel brief={null} source="fixture" />); + + expect(screen.getByText("Preparation unavailable")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/chief-of-staff-preparation-panel.tsx b/apps/web/components/chief-of-staff-preparation-panel.tsx new file mode 100644 index 0000000..f4f91bb --- /dev/null +++ b/apps/web/components/chief-of-staff-preparation-panel.tsx @@ -0,0 +1,175 @@ +import type { + ApiSource, + ChiefOfStaffPreparationArtifactItem, + ChiefOfStaffPriorityBrief, + ChiefOfStaffResumptionSupervisionRecommendation, +} from "../lib/api"; +import { EmptyState } from "./empty-state"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +type ChiefOfStaffPreparationPanelProps = { + brief: ChiefOfStaffPriorityBrief | null; + source: ApiSource | "unavailable"; + unavailableReason?: string; +}; + +function sourceLabel(source: ApiSource | "unavailable") { + if (source === "live") { + return "Live preparation brief"; + } + if (source === "fixture") { + return "Fixture preparation brief"; + } + return "Preparation brief unavailable"; +} + +function renderProvenance(item: { + provenance_references: Array<{ source_kind: string; source_id: string }>; +}) { + if (item.provenance_references.length === 0) { + return <p className="muted-copy">Provenance: none attached</p>; + } + + return ( + <p className="muted-copy"> + Provenance: {item.provenance_references.map((ref) => `${ref.source_kind}:${ref.source_id}`).join(" | ")} + </p> + ); +} + +function renderPreparationItem(item: ChiefOfStaffPreparationArtifactItem) { + return ( + <li key={item.id} className="list-row"> + <div className="list-row__topline"> + <div className="detail-stack"> + <span className="list-row__eyebrow mono">Rank #{item.rank}</span> + <span className="list-row__title">{item.title}</span> + </div> + <div className="cluster"> + <StatusBadge status={item.object_type} label={item.object_type} /> + <StatusBadge status={item.confidence_posture} label={`${item.confidence_posture} confidence`} /> + </div> + </div> + <p>{item.reason}</p> + {renderProvenance(item)} + </li> + ); +} + +function renderResumptionRecommendation(item: ChiefOfStaffResumptionSupervisionRecommendation) { + return ( + <li key={`${item.rank}-${item.title}`} className="list-row"> + <div className="list-row__topline"> + <div className="detail-stack"> + <span className="list-row__eyebrow mono">Rank #{item.rank}</span> + <span className="list-row__title">{item.title}</span> + </div> + <div className="cluster"> + <StatusBadge status={item.action} label={item.action} /> + <StatusBadge status={item.confidence_posture} label={`${item.confidence_posture} confidence`} /> + </div> + </div> + <p>{item.reason}</p> + {renderProvenance(item)} + </li> + ); +} + +function renderSection( + title: string, + items: ChiefOfStaffPreparationArtifactItem[], + emptyMessage: string, +) { + return ( + <div className="detail-group" key={title}> + <h3>{title}</h3> + {items.length === 0 ? <p className="muted-copy">{emptyMessage}</p> : <ul className="detail-stack">{items.map(renderPreparationItem)}</ul>} + </div> + ); +} + +export function ChiefOfStaffPreparationPanel({ + brief, + source, + unavailableReason, +}: ChiefOfStaffPreparationPanelProps) { + if (brief === null) { + return ( + <SectionCard + eyebrow="Chief of staff" + title="Preparation and resumption" + description="Preparation brief artifacts are unavailable in this mode." + > + <EmptyState + title="Preparation unavailable" + description="Preparation and resumption artifacts are unavailable in this mode." + /> + </SectionCard> + ); + } + + return ( + <SectionCard + eyebrow="Chief of staff" + title="Preparation and resumption" + description="Deterministic preparation brief, what-changed summary, prep checklist, suggested talking points, and trust-aware resumption supervision." + > + <div className="detail-stack"> + <div className="cluster"> + <StatusBadge status={source} label={sourceLabel(source)} /> + <StatusBadge + status={brief.preparation_brief.confidence_posture} + label={`${brief.preparation_brief.confidence_posture} preparation confidence`} + /> + <span className="meta-pill">{brief.preparation_brief.summary.returned_count} context items</span> + </div> + + {unavailableReason ? ( + <p className="responsive-note">Live preparation read failed: {unavailableReason}</p> + ) : null} + + <div className="detail-group detail-group--muted"> + <div className="list-row__topline"> + <span className="list-row__eyebrow mono">Confidence rationale</span> + <StatusBadge + status={brief.resumption_supervision.confidence_posture} + label={`${brief.resumption_supervision.confidence_posture} resumption confidence`} + /> + </div> + <p>{brief.preparation_brief.confidence_reason}</p> + </div> + + {renderSection( + "Preparation context", + brief.preparation_brief.context_items, + "No preparation context items for this scope.", + )} + {renderSection( + "What changed", + brief.what_changed_summary.items, + "No recent changes were detected for this scope.", + )} + {renderSection( + "Prep checklist", + brief.prep_checklist.items, + "No checklist items were generated for this scope.", + )} + {renderSection( + "Suggested talking points", + brief.suggested_talking_points.items, + "No talking points were generated for this scope.", + )} + + <div className="detail-group"> + <h3>Resumption supervision</h3> + {brief.resumption_supervision.recommendations.length === 0 ? ( + <p className="muted-copy">No resumption supervision recommendations were generated.</p> + ) : ( + <ul className="detail-stack">{brief.resumption_supervision.recommendations.map(renderResumptionRecommendation)}</ul> + )} + </div> + </div> + </SectionCard> + ); +} diff --git a/apps/web/components/chief-of-staff-priority-panel.test.tsx b/apps/web/components/chief-of-staff-priority-panel.test.tsx new file mode 100644 index 0000000..1cddeae --- /dev/null +++ b/apps/web/components/chief-of-staff-priority-panel.test.tsx @@ -0,0 +1,341 @@ +import React from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import { ChiefOfStaffPriorityPanel } from "./chief-of-staff-priority-panel"; + +const briefFixture = { + assembly_version: "chief_of_staff_priority_brief_v0", + scope: { + thread_id: "thread-1", + since: null, + until: null, + }, + ranked_items: [ + { + rank: 1, + id: "priority-1", + capture_event_id: "capture-1", + object_type: "NextAction" as const, + status: "active", + title: "Next Action: Ship dashboard", + priority_posture: "urgent" as const, + confidence_posture: "low" as const, + confidence: 1, + score: 640, + provenance: { + thread_id: "thread-1", + }, + created_at: "2026-03-31T10:00:00Z", + updated_at: "2026-03-31T10:00:00Z", + rationale: { + reasons: [ + "Marked urgent because this item is a deterministic immediate focus from resumption signals.", + "Confidence is explicitly downgraded by current memory trust posture.", + ], + ranking_inputs: { + posture: "urgent" as const, + open_loop_posture: "next_action" as const, + recency_rank: 1, + age_hours_relative_to_latest: 0, + recall_relevance: 120, + scope_match_count: 1, + query_term_match_count: 1, + freshness_posture: "fresh" as const, + provenance_posture: "strong" as const, + supersession_posture: "current" as const, + }, + provenance_references: [ + { + source_kind: "continuity_capture_event", + source_id: "capture-1", + }, + ], + trust_signals: { + quality_gate_status: "insufficient_sample" as const, + retrieval_status: "pass" as const, + trust_confidence_cap: "low" as const, + downgraded_by_trust: true, + reason: "Memory quality posture is weak.", + }, + }, + }, + ], + overdue_items: [], + stale_waiting_for_items: [], + slipped_commitments: [], + escalation_posture: { + posture: "watch" as const, + reason: "No active follow-through escalations are present.", + total_follow_through_count: 0, + nudge_count: 0, + defer_count: 0, + escalate_count: 0, + close_loop_candidate_count: 0, + }, + draft_follow_up: { + status: "none" as const, + mode: "draft_only" as const, + approval_required: true, + auto_send: false, + reason: "No follow-through targets are currently queued for drafting.", + target_metadata: { + continuity_object_id: null, + capture_event_id: null, + object_type: null, + priority_posture: null, + follow_through_posture: null, + recommendation_action: null, + thread_id: "thread-1", + }, + content: { + subject: "", + body: "", + }, + }, + recommended_next_action: { + action_type: "execute_next_action" as const, + title: "Next Action: Ship dashboard", + target_priority_id: "priority-1", + priority_posture: "urgent" as const, + confidence_posture: "low" as const, + reason: "Marked urgent because this item is a deterministic immediate focus from resumption signals.", + provenance_references: [ + { + source_kind: "continuity_capture_event", + source_id: "capture-1", + }, + ], + deterministic_rank_key: "1:priority-1:640.000000", + }, + preparation_brief: { + scope: { thread_id: "thread-1", since: null, until: null }, + context_items: [], + last_decision: null, + open_loops: [], + next_action: null, + confidence_posture: "low" as const, + confidence_reason: "Memory quality posture is weak.", + summary: { + limit: 6, + returned_count: 0, + total_count: 0, + order: ["rank_asc", "created_at_desc", "id_desc"], + }, + }, + what_changed_summary: { + items: [], + confidence_posture: "low" as const, + confidence_reason: "Memory quality posture is weak.", + summary: { + limit: 6, + returned_count: 0, + total_count: 0, + order: ["rank_asc", "created_at_desc", "id_desc"], + }, + }, + prep_checklist: { + items: [], + confidence_posture: "low" as const, + confidence_reason: "Memory quality posture is weak.", + summary: { + limit: 6, + returned_count: 0, + total_count: 0, + order: ["rank_asc", "created_at_desc", "id_desc"], + }, + }, + suggested_talking_points: { + items: [], + confidence_posture: "low" as const, + confidence_reason: "Memory quality posture is weak.", + summary: { + limit: 6, + returned_count: 0, + total_count: 0, + order: ["rank_asc", "created_at_desc", "id_desc"], + }, + }, + resumption_supervision: { + recommendations: [], + confidence_posture: "low" as const, + confidence_reason: "Memory quality posture is weak.", + summary: { + limit: 3, + returned_count: 0, + total_count: 0, + order: ["rank_asc"], + }, + }, + weekly_review_brief: { + scope: { thread_id: "thread-1", since: null, until: null }, + rollup: { + total_count: 0, + waiting_for_count: 0, + blocker_count: 0, + stale_count: 0, + correction_recurrence_count: 0, + freshness_drift_count: 0, + next_action_count: 0, + posture_order: ["waiting_for", "blocker", "stale", "next_action"] as const, + }, + guidance: [], + summary: { + guidance_order: ["close", "defer", "escalate"] as const, + guidance_item_order: ["signal_count_desc", "action_desc"], + }, + }, + recommendation_outcomes: { + items: [], + summary: { + returned_count: 0, + total_count: 0, + outcome_counts: { accept: 0, defer: 0, ignore: 0, rewrite: 0 }, + order: ["created_at_desc", "id_desc"], + }, + }, + priority_learning_summary: { + total_count: 0, + accept_count: 0, + defer_count: 0, + ignore_count: 0, + rewrite_count: 0, + acceptance_rate: 0, + override_rate: 0, + defer_hotspots: [], + ignore_hotspots: [], + priority_shift_explanation: + "No recommendation outcomes are captured yet; prioritization remains anchored to current continuity and trust signals.", + hotspot_order: ["count_desc", "key_asc"], + }, + pattern_drift_summary: { + posture: "insufficient_signal" as const, + reason: "No recommendation outcomes are available yet, so drift posture is informational only.", + supporting_signals: [], + }, + action_handoff_brief: { + summary: + "Prepared 1 deterministic handoff item from recommended_next_action signals. All task and approval drafts remain artifact-only and approval-bounded.", + confidence_posture: "low" as const, + non_autonomous_guarantee: + "No task, approval, connector send, or external side effect is executed by this endpoint.", + order: ["score_desc", "source_order_asc", "source_reference_id_asc"], + source_order: ["recommended_next_action", "follow_through", "prep_checklist", "weekly_review"] as const, + provenance_references: [], + }, + handoff_items: [], + task_draft: { + status: "draft" as const, + mode: "governed_request_draft" as const, + approval_required: true, + auto_execute: false, + source_handoff_item_id: "handoff-1-recommended_next_action-priority-1", + title: "Next Action: Ship dashboard", + summary: + "Draft-only governed request assembled from chief-of-staff handoff artifacts; requires explicit approval before any execution.", + target: { thread_id: "thread-1", task_id: null, project: null, person: null }, + request: { + action: "execute_next_action", + scope: "chief_of_staff_priority", + domain_hint: "planning", + risk_hint: "governed_handoff", + attributes: {}, + }, + rationale: "Marked urgent because this item is a deterministic immediate focus from resumption signals.", + provenance_references: [], + }, + approval_draft: { + status: "draft_only" as const, + mode: "approval_request_draft" as const, + decision: "approval_required" as const, + approval_required: true, + auto_submit: false, + source_handoff_item_id: "handoff-1-recommended_next_action-priority-1", + request: { + action: "execute_next_action", + scope: "chief_of_staff_priority", + domain_hint: "planning", + risk_hint: "governed_handoff", + attributes: {}, + }, + reason: + "Execution remains approval-bounded. This approval draft is artifact-only and must be explicitly submitted and resolved before any side effect.", + required_checks: [ + "operator_review_handoff_artifact", + "submit_governed_approval_request", + "explicit_approval_resolution", + ], + provenance_references: [], + }, + execution_posture: { + posture: "approval_bounded_artifact_only" as const, + approval_required: true, + autonomous_execution: false, + external_side_effects_allowed: false, + default_routing_decision: "approval_required" as const, + required_operator_actions: [ + "review_handoff_items", + "submit_task_or_approval_request", + "resolve_approval_before_execution", + ], + non_autonomous_guarantee: + "No task, approval, connector send, or external side effect is executed by this endpoint.", + reason: "Chief-of-staff handoff artifacts are deterministic execution-prep only in P8-S29.", + }, + summary: { + limit: 10, + returned_count: 1, + total_count: 1, + posture_order: ["urgent", "important", "waiting", "blocked", "stale", "defer"] as const, + order: ["score_desc", "created_at_desc", "id_desc"], + follow_through_posture_order: ["overdue", "stale_waiting_for", "slipped_commitment"] as const, + follow_through_item_order: [ + "recommendation_action_desc", + "age_hours_desc", + "created_at_desc", + "id_desc", + ], + follow_through_total_count: 0, + overdue_count: 0, + stale_waiting_for_count: 0, + slipped_commitment_count: 0, + trust_confidence_posture: "low" as const, + trust_confidence_reason: "Memory quality posture is weak.", + quality_gate_status: "insufficient_sample" as const, + retrieval_status: "pass" as const, + handoff_item_count: 0, + handoff_item_order: ["score_desc", "source_order_asc", "source_reference_id_asc"], + execution_posture_order: ["approval_bounded_artifact_only"] as const, + }, + sources: ["continuity_recall", "memory_trust_dashboard", "chief_of_staff_action_handoff"], +}; + +describe("ChiefOfStaffPriorityPanel", () => { + afterEach(() => { + cleanup(); + }); + + it("renders ranked priorities, rationale, and explicit low-trust confidence downgrade", () => { + render(<ChiefOfStaffPriorityPanel brief={briefFixture} source="live" />); + + expect(screen.getByText("Live chief-of-staff brief")).toBeInTheDocument(); + expect(screen.getByText("Recommended next action")).toBeInTheDocument(); + expect(screen.getAllByText("Next Action: Ship dashboard").length).toBeGreaterThan(0); + expect(screen.getByText("Action type: execute_next_action")).toBeInTheDocument(); + expect( + screen.getByText("Confidence is explicitly downgraded because memory trust posture is weak."), + ).toBeInTheDocument(); + expect(screen.getByText("Rank #1")).toBeInTheDocument(); + expect( + screen.getAllByText( + "Marked urgent because this item is a deterministic immediate focus from resumption signals.", + ).length, + ).toBeGreaterThan(0); + }); + + it("renders explicit fallback when brief payload is absent", () => { + render(<ChiefOfStaffPriorityPanel brief={null} source="fixture" />); + + expect(screen.getByText("Chief-of-staff brief unavailable")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/chief-of-staff-priority-panel.tsx b/apps/web/components/chief-of-staff-priority-panel.tsx new file mode 100644 index 0000000..4efca7c --- /dev/null +++ b/apps/web/components/chief-of-staff-priority-panel.tsx @@ -0,0 +1,122 @@ +import type { ApiSource, ChiefOfStaffPriorityBrief } from "../lib/api"; +import { EmptyState } from "./empty-state"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +type ChiefOfStaffPriorityPanelProps = { + brief: ChiefOfStaffPriorityBrief | null; + source: ApiSource | "unavailable"; + unavailableReason?: string; +}; + +function sourceLabel(source: ApiSource | "unavailable") { + if (source === "live") { + return "Live chief-of-staff brief"; + } + if (source === "fixture") { + return "Fixture chief-of-staff brief"; + } + return "Chief-of-staff brief unavailable"; +} + +export function ChiefOfStaffPriorityPanel({ + brief, + source, + unavailableReason, +}: ChiefOfStaffPriorityPanelProps) { + if (brief === null) { + return ( + <SectionCard + eyebrow="Chief of staff" + title="Priority dashboard" + description="Deterministic ranking, explicit rationale, and one recommended next action." + > + <EmptyState + title="Chief-of-staff brief unavailable" + description="Priority dashboard data is unavailable in this mode." + /> + </SectionCard> + ); + } + + return ( + <SectionCard + eyebrow="Chief of staff" + title="Priority dashboard" + description="What matters now, why it matters, and what to do next with trust-aware confidence posture." + > + <div className="detail-stack"> + <div className="cluster"> + <StatusBadge status={source} label={sourceLabel(source)} /> + <span className="meta-pill">{brief.summary.returned_count} ranked</span> + <span className="meta-pill">Quality: {brief.summary.quality_gate_status}</span> + <span className="meta-pill">Retrieval: {brief.summary.retrieval_status}</span> + </div> + + {unavailableReason ? ( + <p className="responsive-note">Live chief-of-staff read failed: {unavailableReason}</p> + ) : null} + + <div className="detail-group detail-group--muted"> + <div className="list-row__topline"> + <div className="detail-stack"> + <span className="list-row__eyebrow mono">Recommended next action</span> + <span className="list-row__title">{brief.recommended_next_action.title}</span> + </div> + <div className="cluster"> + <StatusBadge + status={brief.recommended_next_action.priority_posture ?? "unavailable"} + label={brief.recommended_next_action.priority_posture ?? "No target"} + /> + <StatusBadge + status={brief.recommended_next_action.confidence_posture} + label={`${brief.recommended_next_action.confidence_posture} confidence`} + /> + </div> + </div> + <p>{brief.recommended_next_action.reason}</p> + <p className="muted-copy">Action type: {brief.recommended_next_action.action_type}</p> + </div> + + {brief.summary.trust_confidence_posture === "low" ? ( + <p className="responsive-note"> + Confidence is explicitly downgraded because memory trust posture is weak. + </p> + ) : null} + + {brief.ranked_items.length === 0 ? ( + <p className="muted-copy">No ranked priorities are available for the selected scope.</p> + ) : ( + <ul className="detail-stack"> + {brief.ranked_items.map((item) => ( + <li key={item.id} className="list-row"> + <div className="list-row__topline"> + <div className="detail-stack"> + <span className="list-row__eyebrow mono">Rank #{item.rank}</span> + <span className="list-row__title">{item.title}</span> + </div> + <div className="cluster"> + <StatusBadge status={item.priority_posture} label={item.priority_posture} /> + <StatusBadge + status={item.confidence_posture} + label={`${item.confidence_posture} confidence`} + /> + </div> + </div> + + <p className="muted-copy">{item.object_type}</p> + <ul className="detail-stack"> + {item.rationale.reasons.map((reason, index) => ( + <li key={`${item.id}-reason-${index}`} className="muted-copy"> + {reason} + </li> + ))} + </ul> + </li> + ))} + </ul> + )} + </div> + </SectionCard> + ); +} diff --git a/apps/web/components/chief-of-staff-weekly-review-panel.test.tsx b/apps/web/components/chief-of-staff-weekly-review-panel.test.tsx new file mode 100644 index 0000000..d6fe990 --- /dev/null +++ b/apps/web/components/chief-of-staff-weekly-review-panel.test.tsx @@ -0,0 +1,329 @@ +import React from "react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ChiefOfStaffWeeklyReviewPanel } from "./chief-of-staff-weekly-review-panel"; + +const { captureChiefOfStaffRecommendationOutcomeMock } = vi.hoisted(() => ({ + captureChiefOfStaffRecommendationOutcomeMock: vi.fn(), +})); + +vi.mock("../lib/api", async () => { + const actual = await vi.importActual("../lib/api"); + return { + ...actual, + captureChiefOfStaffRecommendationOutcome: captureChiefOfStaffRecommendationOutcomeMock, + }; +}); + +const briefFixture = { + assembly_version: "chief_of_staff_priority_brief_v0", + scope: { thread_id: "thread-1", since: null, until: null }, + ranked_items: [], + overdue_items: [], + stale_waiting_for_items: [], + slipped_commitments: [], + escalation_posture: { + posture: "watch" as const, + reason: "No active follow-through escalations are present.", + total_follow_through_count: 0, + nudge_count: 0, + defer_count: 0, + escalate_count: 0, + close_loop_candidate_count: 0, + }, + draft_follow_up: { + status: "none" as const, + mode: "draft_only" as const, + approval_required: true, + auto_send: false, + reason: "No follow-through targets are currently queued for drafting.", + target_metadata: { + continuity_object_id: null, + capture_event_id: null, + object_type: null, + priority_posture: null, + follow_through_posture: null, + recommendation_action: null, + thread_id: "thread-1", + }, + content: { subject: "", body: "" }, + }, + recommended_next_action: { + action_type: "execute_next_action" as const, + title: "Next Action: Ship dashboard", + target_priority_id: "priority-1", + priority_posture: "urgent" as const, + confidence_posture: "low" as const, + reason: "Marked urgent because this item is a deterministic immediate focus from resumption signals.", + provenance_references: [], + deterministic_rank_key: "1:priority-1:640.000000", + }, + preparation_brief: { + scope: { thread_id: "thread-1", since: null, until: null }, + context_items: [], + last_decision: null, + open_loops: [], + next_action: null, + confidence_posture: "low" as const, + confidence_reason: "Memory quality posture is weak.", + summary: { limit: 6, returned_count: 0, total_count: 0, order: ["rank_asc", "created_at_desc", "id_desc"] }, + }, + what_changed_summary: { + items: [], + confidence_posture: "low" as const, + confidence_reason: "Memory quality posture is weak.", + summary: { limit: 6, returned_count: 0, total_count: 0, order: ["rank_asc", "created_at_desc", "id_desc"] }, + }, + prep_checklist: { + items: [], + confidence_posture: "low" as const, + confidence_reason: "Memory quality posture is weak.", + summary: { limit: 6, returned_count: 0, total_count: 0, order: ["rank_asc", "created_at_desc", "id_desc"] }, + }, + suggested_talking_points: { + items: [], + confidence_posture: "low" as const, + confidence_reason: "Memory quality posture is weak.", + summary: { limit: 6, returned_count: 0, total_count: 0, order: ["rank_asc", "created_at_desc", "id_desc"] }, + }, + resumption_supervision: { + recommendations: [], + confidence_posture: "low" as const, + confidence_reason: "Memory quality posture is weak.", + summary: { limit: 3, returned_count: 0, total_count: 0, order: ["rank_asc"] }, + }, + weekly_review_brief: { + scope: { thread_id: "thread-1", since: null, until: null }, + rollup: { + total_count: 2, + waiting_for_count: 1, + blocker_count: 1, + stale_count: 0, + correction_recurrence_count: 0, + freshness_drift_count: 0, + next_action_count: 1, + posture_order: ["waiting_for", "blocker", "stale", "next_action"] as const, + }, + guidance: [ + { + rank: 1, + action: "escalate" as const, + signal_count: 2, + rationale: "Escalate where blockers are concentrated.", + }, + { + rank: 2, + action: "close" as const, + signal_count: 1, + rationale: "Close loops where deterministic close candidates exist.", + }, + { + rank: 3, + action: "defer" as const, + signal_count: 1, + rationale: "Defer where stale load remains.", + }, + ], + summary: { + guidance_order: ["close", "defer", "escalate"] as const, + guidance_item_order: ["signal_count_desc", "action_desc"], + }, + }, + recommendation_outcomes: { + items: [], + summary: { + returned_count: 0, + total_count: 0, + outcome_counts: { accept: 0, defer: 0, ignore: 0, rewrite: 0 }, + order: ["created_at_desc", "id_desc"], + }, + }, + priority_learning_summary: { + total_count: 0, + accept_count: 0, + defer_count: 0, + ignore_count: 0, + rewrite_count: 0, + acceptance_rate: 0, + override_rate: 0, + defer_hotspots: [], + ignore_hotspots: [], + priority_shift_explanation: + "No recommendation outcomes are captured yet; prioritization remains anchored to current continuity and trust signals.", + hotspot_order: ["count_desc", "key_asc"], + }, + pattern_drift_summary: { + posture: "insufficient_signal" as const, + reason: "No recommendation outcomes are available yet, so drift posture is informational only.", + supporting_signals: ["Outcomes captured: 0"], + }, + action_handoff_brief: { + summary: + "Prepared 1 deterministic handoff item from recommended_next_action signals. All task and approval drafts remain artifact-only and approval-bounded.", + confidence_posture: "low" as const, + non_autonomous_guarantee: + "No task, approval, connector send, or external side effect is executed by this endpoint.", + order: ["score_desc", "source_order_asc", "source_reference_id_asc"], + source_order: ["recommended_next_action", "follow_through", "prep_checklist", "weekly_review"] as const, + provenance_references: [], + }, + handoff_items: [], + task_draft: { + status: "draft" as const, + mode: "governed_request_draft" as const, + approval_required: true, + auto_execute: false, + source_handoff_item_id: "handoff-1-recommended_next_action-priority-1", + title: "Next Action: Ship dashboard", + summary: + "Draft-only governed request assembled from chief-of-staff handoff artifacts; requires explicit approval before any execution.", + target: { thread_id: "thread-1", task_id: null, project: null, person: null }, + request: { + action: "execute_next_action", + scope: "chief_of_staff_priority", + domain_hint: "planning", + risk_hint: "governed_handoff", + attributes: {}, + }, + rationale: "Marked urgent because this item is a deterministic immediate focus from resumption signals.", + provenance_references: [], + }, + approval_draft: { + status: "draft_only" as const, + mode: "approval_request_draft" as const, + decision: "approval_required" as const, + approval_required: true, + auto_submit: false, + source_handoff_item_id: "handoff-1-recommended_next_action-priority-1", + request: { + action: "execute_next_action", + scope: "chief_of_staff_priority", + domain_hint: "planning", + risk_hint: "governed_handoff", + attributes: {}, + }, + reason: + "Execution remains approval-bounded. This approval draft is artifact-only and must be explicitly submitted and resolved before any side effect.", + required_checks: [ + "operator_review_handoff_artifact", + "submit_governed_approval_request", + "explicit_approval_resolution", + ], + provenance_references: [], + }, + execution_posture: { + posture: "approval_bounded_artifact_only" as const, + approval_required: true, + autonomous_execution: false, + external_side_effects_allowed: false, + default_routing_decision: "approval_required" as const, + required_operator_actions: [ + "review_handoff_items", + "submit_task_or_approval_request", + "resolve_approval_before_execution", + ], + non_autonomous_guarantee: + "No task, approval, connector send, or external side effect is executed by this endpoint.", + reason: "Chief-of-staff handoff artifacts are deterministic execution-prep only in P8-S29.", + }, + summary: { + limit: 10, + returned_count: 0, + total_count: 0, + posture_order: ["urgent", "important", "waiting", "blocked", "stale", "defer"] as const, + order: ["score_desc", "created_at_desc", "id_desc"], + follow_through_posture_order: ["overdue", "stale_waiting_for", "slipped_commitment"] as const, + follow_through_item_order: ["recommendation_action_desc", "age_hours_desc", "created_at_desc", "id_desc"], + follow_through_total_count: 0, + overdue_count: 0, + stale_waiting_for_count: 0, + slipped_commitment_count: 0, + trust_confidence_posture: "low" as const, + trust_confidence_reason: "Memory quality posture is weak.", + quality_gate_status: "insufficient_sample" as const, + retrieval_status: "pass" as const, + handoff_item_count: 0, + handoff_item_order: ["score_desc", "source_order_asc", "source_reference_id_asc"], + execution_posture_order: ["approval_bounded_artifact_only"] as const, + }, + sources: ["continuity_recall", "memory_trust_dashboard", "chief_of_staff_action_handoff"], +}; + +describe("ChiefOfStaffWeeklyReviewPanel", () => { + beforeEach(() => { + captureChiefOfStaffRecommendationOutcomeMock.mockReset(); + captureChiefOfStaffRecommendationOutcomeMock.mockResolvedValue({ + outcome: { + id: "outcome-1", + capture_event_id: "capture-outcome-1", + outcome: "accept", + recommendation_action_type: "execute_next_action", + recommendation_title: "Next Action: Ship dashboard", + rewritten_title: null, + target_priority_id: "priority-1", + rationale: "Captured from weekly review controls as accept.", + provenance_references: [], + created_at: "2026-03-31T12:00:00Z", + updated_at: "2026-03-31T12:00:00Z", + }, + recommendation_outcomes: briefFixture.recommendation_outcomes, + priority_learning_summary: briefFixture.priority_learning_summary, + pattern_drift_summary: briefFixture.pattern_drift_summary, + }); + }); + + afterEach(() => { + cleanup(); + }); + + it("renders weekly guidance and outcome-learning sections", () => { + render( + <ChiefOfStaffWeeklyReviewPanel + brief={briefFixture} + source="fixture" + />, + ); + + expect(screen.getByText("Fixture weekly review")).toBeInTheDocument(); + expect(screen.getByText("Close / Defer / Escalate guidance")).toBeInTheDocument(); + expect(screen.getByText("Outcome capture controls")).toBeInTheDocument(); + expect(screen.getByText("Pattern drift summary")).toBeInTheDocument(); + }); + + it("captures accept outcomes when live mode is ready", async () => { + render( + <ChiefOfStaffWeeklyReviewPanel + apiBaseUrl="https://api.example.com" + userId="user-1" + brief={briefFixture} + source="live" + />, + ); + + fireEvent.click(screen.getByRole("button", { name: "Accept" })); + + await waitFor(() => { + expect(captureChiefOfStaffRecommendationOutcomeMock).toHaveBeenCalledWith( + "https://api.example.com", + expect.objectContaining({ + user_id: "user-1", + outcome: "accept", + recommendation_action_type: "execute_next_action", + }), + ); + }); + + expect( + screen.getByText( + "accept captured. Refresh the page to see updated recommendation outcomes and learning summaries.", + ), + ).toBeInTheDocument(); + }); + + it("renders explicit fallback when brief payload is absent", () => { + render(<ChiefOfStaffWeeklyReviewPanel brief={null} source="fixture" />); + + expect(screen.getByText("Weekly review unavailable")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/chief-of-staff-weekly-review-panel.tsx b/apps/web/components/chief-of-staff-weekly-review-panel.tsx new file mode 100644 index 0000000..6f4a50b --- /dev/null +++ b/apps/web/components/chief-of-staff-weekly-review-panel.tsx @@ -0,0 +1,278 @@ +"use client"; + +import { useState } from "react"; + +import type { + ApiSource, + ChiefOfStaffPriorityBrief, + ChiefOfStaffRecommendationOutcome, +} from "../lib/api"; +import { captureChiefOfStaffRecommendationOutcome } from "../lib/api"; +import { EmptyState } from "./empty-state"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +type ChiefOfStaffWeeklyReviewPanelProps = { + apiBaseUrl?: string; + userId?: string; + brief: ChiefOfStaffPriorityBrief | null; + source: ApiSource | "unavailable"; + unavailableReason?: string; +}; + +const OUTCOME_OPTIONS: Array<{ outcome: ChiefOfStaffRecommendationOutcome; label: string }> = [ + { outcome: "accept", label: "Accept" }, + { outcome: "defer", label: "Defer" }, + { outcome: "ignore", label: "Ignore" }, + { outcome: "rewrite", label: "Rewrite" }, +]; + +function sourceLabel(source: ApiSource | "unavailable") { + if (source === "live") { + return "Live weekly review"; + } + if (source === "fixture") { + return "Fixture weekly review"; + } + return "Weekly review unavailable"; +} + +export function ChiefOfStaffWeeklyReviewPanel({ + apiBaseUrl, + userId, + brief, + source, + unavailableReason, +}: ChiefOfStaffWeeklyReviewPanelProps) { + const liveModeReady = Boolean(apiBaseUrl && userId && source === "live"); + const [isSubmitting, setIsSubmitting] = useState(false); + const [rewriteTitle, setRewriteTitle] = useState(""); + const [statusTone, setStatusTone] = useState<"info" | "success" | "danger">("info"); + const [statusText, setStatusText] = useState( + "Capture recommendation outcomes as accept/defer/ignore/rewrite to update learning rollups.", + ); + + async function captureOutcome(outcome: ChiefOfStaffRecommendationOutcome) { + if (!brief || !apiBaseUrl || !userId || !liveModeReady) { + setStatusTone("info"); + setStatusText("Outcome capture is available only when live API mode is configured."); + return; + } + + const normalizedRewriteTitle = rewriteTitle.trim(); + if (outcome === "rewrite" && normalizedRewriteTitle.length === 0) { + setStatusTone("danger"); + setStatusText("Rewrite outcome requires a rewritten recommendation title."); + return; + } + + setIsSubmitting(true); + setStatusTone("info"); + setStatusText(`Capturing ${outcome} outcome...`); + + try { + await captureChiefOfStaffRecommendationOutcome(apiBaseUrl, { + user_id: userId, + outcome, + recommendation_action_type: brief.recommended_next_action.action_type, + recommendation_title: brief.recommended_next_action.title, + rewritten_title: outcome === "rewrite" ? normalizedRewriteTitle : undefined, + target_priority_id: brief.recommended_next_action.target_priority_id, + thread_id: brief.scope.thread_id ?? null, + rationale: `Captured from weekly review controls as ${outcome}.`, + }); + setStatusTone("success"); + setStatusText( + `${outcome} captured. Refresh the page to see updated recommendation outcomes and learning summaries.`, + ); + if (outcome === "rewrite") { + setRewriteTitle(""); + } + } catch (error) { + setStatusTone("danger"); + setStatusText( + `Unable to capture outcome: ${error instanceof Error ? error.message : "Request failed"}`, + ); + } finally { + setIsSubmitting(false); + } + } + + if (brief === null) { + return ( + <SectionCard + eyebrow="Chief of staff" + title="Weekly review and learning" + description="Weekly review guidance and recommendation outcome learning are unavailable in this mode." + > + <EmptyState + title="Weekly review unavailable" + description="Weekly review and recommendation outcome-learning artifacts are unavailable in this mode." + /> + </SectionCard> + ); + } + + return ( + <SectionCard + eyebrow="Chief of staff" + title="Weekly review and learning" + description="Deterministic close/defer/escalate review guidance, explicit recommendation-outcome capture, and explainable priority-learning drift signals." + > + <div className="detail-stack"> + <div className="cluster"> + <StatusBadge status={source} label={sourceLabel(source)} /> + <StatusBadge + status={brief.pattern_drift_summary.posture} + label={`Drift: ${brief.pattern_drift_summary.posture}`} + /> + <span className="meta-pill"> + {brief.recommendation_outcomes.summary.total_count} outcomes + </span> + </div> + + {unavailableReason ? ( + <p className="responsive-note">Live weekly review read failed: {unavailableReason}</p> + ) : null} + + <div className="composer-status" aria-live="polite"> + <StatusBadge + status={ + isSubmitting + ? "submitting" + : statusTone === "success" + ? "success" + : statusTone === "danger" + ? "error" + : liveModeReady + ? "ready" + : "unavailable" + } + label={ + isSubmitting + ? "Submitting" + : statusTone === "success" + ? "Captured" + : statusTone === "danger" + ? "Attention" + : liveModeReady + ? "Ready" + : "Unavailable" + } + /> + <span>{statusText}</span> + </div> + + <div className="detail-group detail-group--muted"> + <h3>Weekly rollup</h3> + <div className="cluster"> + <span className="meta-pill">Total: {brief.weekly_review_brief.rollup.total_count}</span> + <span className="meta-pill">Waiting: {brief.weekly_review_brief.rollup.waiting_for_count}</span> + <span className="meta-pill">Blockers: {brief.weekly_review_brief.rollup.blocker_count}</span> + <span className="meta-pill">Stale: {brief.weekly_review_brief.rollup.stale_count}</span> + </div> + </div> + + <div className="detail-group"> + <h3>Close / Defer / Escalate guidance</h3> + <ul className="detail-stack"> + {brief.weekly_review_brief.guidance.map((item) => ( + <li key={`${item.action}-${item.rank}`} className="list-row"> + <div className="list-row__topline"> + <div className="detail-stack"> + <span className="list-row__eyebrow mono">Rank #{item.rank}</span> + <span className="list-row__title">{item.action}</span> + </div> + <span className="meta-pill">Signals: {item.signal_count}</span> + </div> + <p>{item.rationale}</p> + </li> + ))} + </ul> + </div> + + <div className="detail-group detail-group--muted"> + <h3>Outcome capture controls</h3> + <p className="muted-copy"> + Capture how you handled the current recommendation so future priority behavior changes remain visible and + auditable. + </p> + <label className="detail-stack" htmlFor="chief-of-staff-rewrite-title"> + <span className="list-row__eyebrow mono">Rewrite title (only for rewrite)</span> + <input + id="chief-of-staff-rewrite-title" + className="input" + value={rewriteTitle} + onChange={(event) => setRewriteTitle(event.target.value)} + placeholder="Rewrite: ..." + disabled={!liveModeReady || isSubmitting} + /> + </label> + <div className="composer-actions"> + {OUTCOME_OPTIONS.map((option) => ( + <button + key={option.outcome} + type="button" + className="button button--ghost" + onClick={() => captureOutcome(option.outcome)} + disabled={!liveModeReady || isSubmitting} + > + {option.label} + </button> + ))} + </div> + </div> + + <div className="detail-group"> + <h3>Recommendation outcomes</h3> + {brief.recommendation_outcomes.items.length === 0 ? ( + <p className="muted-copy">No recommendation outcomes captured for this scope.</p> + ) : ( + <ul className="detail-stack"> + {brief.recommendation_outcomes.items.map((item) => ( + <li key={item.id} className="list-row"> + <div className="list-row__topline"> + <div className="detail-stack"> + <span className="list-row__eyebrow mono">{item.created_at}</span> + <span className="list-row__title">{item.recommendation_title}</span> + </div> + <StatusBadge status={item.outcome} label={item.outcome} /> + </div> + <p className="muted-copy">Action type: {item.recommendation_action_type}</p> + {item.rationale ? <p>{item.rationale}</p> : null} + </li> + ))} + </ul> + )} + </div> + + <div className="detail-group detail-group--muted"> + <h3>Priority learning summary</h3> + <div className="cluster"> + <span className="meta-pill">Accept: {brief.priority_learning_summary.accept_count}</span> + <span className="meta-pill">Defer: {brief.priority_learning_summary.defer_count}</span> + <span className="meta-pill">Ignore: {brief.priority_learning_summary.ignore_count}</span> + <span className="meta-pill">Rewrite: {brief.priority_learning_summary.rewrite_count}</span> + </div> + <p>{brief.priority_learning_summary.priority_shift_explanation}</p> + <p className="muted-copy"> + Acceptance rate: {brief.priority_learning_summary.acceptance_rate.toFixed(6)} | Override rate:{" "} + {brief.priority_learning_summary.override_rate.toFixed(6)} + </p> + </div> + + <div className="detail-group detail-group--muted"> + <h3>Pattern drift summary</h3> + <p>{brief.pattern_drift_summary.reason}</p> + <ul className="detail-stack"> + {brief.pattern_drift_summary.supporting_signals.map((signal, index) => ( + <li key={`signal-${index}`} className="muted-copy"> + {signal} + </li> + ))} + </ul> + </div> + </div> + </SectionCard> + ); +} diff --git a/apps/web/components/continuity-capture-form.test.tsx b/apps/web/components/continuity-capture-form.test.tsx new file mode 100644 index 0000000..b574bf7 --- /dev/null +++ b/apps/web/components/continuity-capture-form.test.tsx @@ -0,0 +1,106 @@ +import React from "react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ContinuityCaptureForm } from "./continuity-capture-form"; + +const { createContinuityCaptureMock, refreshMock } = vi.hoisted(() => ({ + createContinuityCaptureMock: vi.fn(), + refreshMock: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + refresh: refreshMock, + }), +})); + +vi.mock("../lib/api", async () => { + const actual = await vi.importActual("../lib/api"); + return { + ...actual, + createContinuityCapture: createContinuityCaptureMock, + }; +}); + +describe("ContinuityCaptureForm", () => { + beforeEach(() => { + createContinuityCaptureMock.mockReset(); + refreshMock.mockReset(); + }); + + afterEach(() => { + cleanup(); + }); + + it("submits continuity capture through the shipped endpoint when live mode is available", async () => { + createContinuityCaptureMock.mockResolvedValue({ + capture: { + capture_event: { + id: "capture-1", + raw_content: "Finalize launch checklist", + explicit_signal: "task", + admission_posture: "DERIVED", + admission_reason: "explicit_signal_task", + created_at: "2026-03-29T09:00:00Z", + }, + derived_object: { + id: "object-1", + capture_event_id: "capture-1", + object_type: "NextAction", + status: "active", + title: "Next Action: Finalize launch checklist", + body: { + action_text: "Finalize launch checklist", + raw_content: "Finalize launch checklist", + explicit_signal: "task", + }, + provenance: { + capture_event_id: "capture-1", + source_kind: "continuity_capture_event", + }, + confidence: 1, + created_at: "2026-03-29T09:00:00Z", + updated_at: "2026-03-29T09:00:00Z", + }, + }, + }); + + render( + <ContinuityCaptureForm + apiBaseUrl="https://api.example.com" + userId="user-1" + source="live" + />, + ); + + fireEvent.change(screen.getByLabelText("Capture text"), { + target: { value: "Finalize launch checklist" }, + }); + fireEvent.change(screen.getByLabelText("Explicit signal (optional)"), { + target: { value: "task" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Capture" })); + + await waitFor(() => { + expect(createContinuityCaptureMock).toHaveBeenCalledWith("https://api.example.com", { + user_id: "user-1", + raw_content: "Finalize launch checklist", + explicit_signal: "task", + }); + }); + + expect(refreshMock).toHaveBeenCalled(); + expect(screen.getByText(/Derived NextAction with provenance/i)).toBeInTheDocument(); + }); + + it("keeps submission disabled when live mode is unavailable", () => { + render(<ContinuityCaptureForm source="fixture" />); + + expect(screen.getByRole("button", { name: "Capture" })).toBeDisabled(); + expect( + screen.getByText("Capture submission is unavailable until live API configuration is present."), + ).toBeInTheDocument(); + expect(createContinuityCaptureMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/components/continuity-capture-form.tsx b/apps/web/components/continuity-capture-form.tsx new file mode 100644 index 0000000..c923ed0 --- /dev/null +++ b/apps/web/components/continuity-capture-form.tsx @@ -0,0 +1,176 @@ +"use client"; + +import type { FormEvent } from "react"; +import { useState } from "react"; + +import { useRouter } from "next/navigation"; + +import type { ApiSource, ContinuityCaptureExplicitSignal, ContinuityCaptureInboxItem } from "../lib/api"; +import { createContinuityCapture } from "../lib/api"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +type ContinuityCaptureFormProps = { + apiBaseUrl?: string; + userId?: string; + source: ApiSource | "unavailable"; +}; + +const SIGNAL_OPTIONS: Array<{ + value: ContinuityCaptureExplicitSignal; + label: string; +}> = [ + { value: "remember_this", label: "Remember This" }, + { value: "task", label: "Task" }, + { value: "decision", label: "Decision" }, + { value: "commitment", label: "Commitment" }, + { value: "waiting_for", label: "Waiting For" }, + { value: "blocker", label: "Blocker" }, + { value: "next_action", label: "Next Action" }, + { value: "note", label: "Note" }, +]; + +function admissionMessage(item: ContinuityCaptureInboxItem) { + if (item.derived_object) { + return `Derived ${item.derived_object.object_type} with provenance from capture ${item.capture_event.id}.`; + } + return "Capture persisted with TRIAGE posture. No durable object was created."; +} + +export function ContinuityCaptureForm({ apiBaseUrl, userId, source }: ContinuityCaptureFormProps) { + const router = useRouter(); + const liveModeReady = Boolean(apiBaseUrl && userId && source === "live"); + + const [rawContent, setRawContent] = useState(""); + const [explicitSignal, setExplicitSignal] = useState<string>(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [statusTone, setStatusTone] = useState<"info" | "success" | "danger">("info"); + const [statusText, setStatusText] = useState( + liveModeReady + ? "Capture one note quickly. Explicit signals deterministically map to typed continuity objects." + : "Capture submission is unavailable until live API configuration is present.", + ); + + async function handleSubmit(event: FormEvent<HTMLFormElement>) { + event.preventDefault(); + + if (!liveModeReady || !apiBaseUrl || !userId) { + setStatusTone("info"); + setStatusText("Capture submission is unavailable until live API configuration is present."); + return; + } + + if (!rawContent.trim()) { + setStatusTone("danger"); + setStatusText("Enter capture text before submitting."); + return; + } + + setIsSubmitting(true); + setStatusTone("info"); + setStatusText("Submitting capture..."); + + try { + const payload = await createContinuityCapture(apiBaseUrl, { + user_id: userId, + raw_content: rawContent.trim(), + explicit_signal: explicitSignal + ? (explicitSignal as ContinuityCaptureExplicitSignal) + : null, + }); + + setRawContent(""); + setExplicitSignal(""); + setStatusTone("success"); + setStatusText(admissionMessage(payload.capture)); + router.refresh(); + } catch (error) { + setStatusTone("danger"); + setStatusText( + `Unable to submit capture: ${error instanceof Error ? error.message : "Request failed"}`, + ); + } finally { + setIsSubmitting(false); + } + } + + return ( + <SectionCard + eyebrow="Fast capture" + title="Continuity intake" + description="Every submit appends one immutable capture event. Durable objects are only promoted when explicit or high-confidence signals are present." + > + <form className="detail-stack" onSubmit={handleSubmit}> + <div className="form-field"> + <label htmlFor="continuity-capture-text">Capture text</label> + <textarea + id="continuity-capture-text" + name="continuity-capture-text" + value={rawContent} + onChange={(event) => setRawContent(event.target.value)} + placeholder="Capture something worth keeping..." + maxLength={4000} + disabled={!liveModeReady || isSubmitting} + /> + <p className="field-hint">{rawContent.length}/4000</p> + </div> + + <div className="form-field"> + <label htmlFor="continuity-capture-signal">Explicit signal (optional)</label> + <select + id="continuity-capture-signal" + name="continuity-capture-signal" + value={explicitSignal} + onChange={(event) => setExplicitSignal(event.target.value)} + disabled={!liveModeReady || isSubmitting} + > + <option value="">Auto triage (no explicit signal)</option> + {SIGNAL_OPTIONS.map((option) => ( + <option key={option.value} value={option.value}> + {option.label} + </option> + ))} + </select> + </div> + + <div className="composer-actions"> + <div className="composer-status" aria-live="polite"> + <StatusBadge + status={ + isSubmitting + ? "submitting" + : statusTone === "success" + ? "success" + : statusTone === "danger" + ? "error" + : liveModeReady + ? "ready" + : "unavailable" + } + label={ + isSubmitting + ? "Submitting" + : statusTone === "success" + ? "Saved" + : statusTone === "danger" + ? "Attention" + : liveModeReady + ? "Ready" + : "Unavailable" + } + /> + <span>{statusText}</span> + </div> + + <button + type="submit" + className="button" + disabled={!liveModeReady || !rawContent.trim() || isSubmitting} + > + {isSubmitting ? "Submitting..." : "Capture"} + </button> + </div> + </form> + </SectionCard> + ); +} diff --git a/apps/web/components/continuity-correction-form.test.tsx b/apps/web/components/continuity-correction-form.test.tsx new file mode 100644 index 0000000..6f180d3 --- /dev/null +++ b/apps/web/components/continuity-correction-form.test.tsx @@ -0,0 +1,117 @@ +import React from "react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ContinuityCorrectionForm } from "./continuity-correction-form"; + +const { applyContinuityCorrectionMock, refreshMock } = vi.hoisted(() => ({ + applyContinuityCorrectionMock: vi.fn(), + refreshMock: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + refresh: refreshMock, + }), +})); + +vi.mock("../lib/api", async () => { + const actual = await vi.importActual("../lib/api"); + return { + ...actual, + applyContinuityCorrection: applyContinuityCorrectionMock, + }; +}); + +const reviewFixture = { + continuity_object: { + id: "object-1", + capture_event_id: "capture-1", + object_type: "Decision" as const, + status: "active", + title: "Decision: Keep rollout phased", + body: { decision_text: "Keep rollout phased" }, + provenance: {}, + confidence: 0.95, + last_confirmed_at: null, + supersedes_object_id: null, + superseded_by_object_id: null, + created_at: "2026-03-30T10:00:00Z", + updated_at: "2026-03-30T10:00:00Z", + }, + correction_events: [], + supersession_chain: { + supersedes: null, + superseded_by: null, + }, +}; + +describe("ContinuityCorrectionForm", () => { + beforeEach(() => { + applyContinuityCorrectionMock.mockReset(); + refreshMock.mockReset(); + }); + + afterEach(() => { + cleanup(); + }); + + it("renders empty state when no review object is selected", () => { + render(<ContinuityCorrectionForm source="fixture" review={null} />); + + expect(screen.getByText("No continuity object selected")).toBeInTheDocument(); + }); + + it("submits confirm corrections through the shipped endpoint", async () => { + applyContinuityCorrectionMock.mockResolvedValue({ + continuity_object: { + ...reviewFixture.continuity_object, + last_confirmed_at: "2026-03-30T10:01:00Z", + }, + correction_event: { + id: "event-1", + continuity_object_id: "object-1", + action: "confirm", + reason: "Reviewed", + before_snapshot: {}, + after_snapshot: {}, + payload: {}, + created_at: "2026-03-30T10:01:00Z", + }, + replacement_object: null, + }); + + render( + <ContinuityCorrectionForm + apiBaseUrl="https://api.example.com" + userId="user-1" + source="live" + review={reviewFixture} + />, + ); + + fireEvent.change(screen.getByLabelText("Reason (optional)"), { + target: { value: "Reviewed" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Apply correction" })); + + await waitFor(() => { + expect(applyContinuityCorrectionMock).toHaveBeenCalledWith( + "https://api.example.com", + "object-1", + { + user_id: "user-1", + action: "confirm", + reason: "Reviewed", + title: undefined, + confidence: undefined, + replacement_title: undefined, + replacement_confidence: undefined, + }, + ); + }); + + expect(refreshMock).toHaveBeenCalled(); + expect(screen.getByText(/Correction applied/i)).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/continuity-correction-form.tsx b/apps/web/components/continuity-correction-form.tsx new file mode 100644 index 0000000..4f7b692 --- /dev/null +++ b/apps/web/components/continuity-correction-form.tsx @@ -0,0 +1,301 @@ +"use client"; + +import type { FormEvent } from "react"; +import { useMemo, useState } from "react"; + +import { useRouter } from "next/navigation"; + +import type { + ApiSource, + ContinuityCorrectionAction, + ContinuityReviewDetail, +} from "../lib/api"; +import { applyContinuityCorrection } from "../lib/api"; +import { EmptyState } from "./empty-state"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +type ContinuityCorrectionFormProps = { + apiBaseUrl?: string; + userId?: string; + source: ApiSource | "unavailable"; + review: ContinuityReviewDetail | null; +}; + +const ACTION_OPTIONS: Array<{ value: ContinuityCorrectionAction; label: string }> = [ + { value: "confirm", label: "Confirm" }, + { value: "edit", label: "Edit" }, + { value: "delete", label: "Delete" }, + { value: "supersede", label: "Supersede" }, + { value: "mark_stale", label: "Mark stale" }, +]; + +function parseOptionalNumber(value: string) { + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + const parsed = Number.parseFloat(trimmed); + if (!Number.isFinite(parsed)) { + return undefined; + } + return parsed; +} + +export function ContinuityCorrectionForm({ apiBaseUrl, userId, source, review }: ContinuityCorrectionFormProps) { + const router = useRouter(); + const liveModeReady = Boolean(apiBaseUrl && userId && source === "live" && review); + + const [action, setAction] = useState<ContinuityCorrectionAction>("confirm"); + const [reason, setReason] = useState(""); + const [title, setTitle] = useState(""); + const [confidence, setConfidence] = useState(""); + const [replacementTitle, setReplacementTitle] = useState(""); + const [replacementConfidence, setReplacementConfidence] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [statusTone, setStatusTone] = useState<"info" | "success" | "danger">("info"); + const [statusText, setStatusText] = useState( + "Select one review object, then apply deterministic continuity corrections.", + ); + + const selectedObject = review?.continuity_object ?? null; + + const chainSummary = useMemo(() => { + if (!review) { + return "No object selected."; + } + const supersedes = review.supersession_chain.supersedes; + const supersededBy = review.supersession_chain.superseded_by; + if (!supersedes && !supersededBy) { + return "No supersession chain links recorded."; + } + + const parts: string[] = []; + if (supersedes) { + parts.push(`Supersedes: ${supersedes.title}`); + } + if (supersededBy) { + parts.push(`Superseded by: ${supersededBy.title}`); + } + return parts.join(" | "); + }, [review]); + + async function handleSubmit(event: FormEvent<HTMLFormElement>) { + event.preventDefault(); + + if (!liveModeReady || !apiBaseUrl || !userId || !selectedObject) { + setStatusTone("info"); + setStatusText("Correction submit is unavailable until one live review object is selected."); + return; + } + + setIsSubmitting(true); + setStatusTone("info"); + setStatusText("Submitting correction..."); + + try { + await applyContinuityCorrection(apiBaseUrl, selectedObject.id, { + user_id: userId, + action, + reason: reason.trim() || undefined, + title: action === "edit" ? title.trim() || undefined : undefined, + confidence: action === "edit" ? parseOptionalNumber(confidence) : undefined, + replacement_title: action === "supersede" ? replacementTitle.trim() || undefined : undefined, + replacement_confidence: + action === "supersede" ? parseOptionalNumber(replacementConfidence) : undefined, + }); + + setStatusTone("success"); + setStatusText("Correction applied. Recall and resumption now reflect updated lifecycle state."); + setReason(""); + setTitle(""); + setConfidence(""); + setReplacementTitle(""); + setReplacementConfidence(""); + router.refresh(); + } catch (error) { + setStatusTone("danger"); + setStatusText( + `Unable to apply correction: ${error instanceof Error ? error.message : "Request failed"}`, + ); + } finally { + setIsSubmitting(false); + } + } + + if (!review) { + return ( + <SectionCard + eyebrow="Correction" + title="Correction actions" + description="Apply confirm/edit/delete/supersede/mark_stale actions to one selected continuity object." + > + <EmptyState + title="No continuity object selected" + description="Pick one item from the review queue to inspect supersession chain and submit corrections." + /> + </SectionCard> + ); + } + + return ( + <SectionCard + eyebrow="Correction" + title="Correction actions" + description="Corrections append immutable correction events before lifecycle mutation, then take effect in recall and resumption immediately." + > + <div className="detail-stack"> + <div className="cluster"> + <StatusBadge + status={source} + label={ + source === "live" + ? "Live correction" + : source === "fixture" + ? "Fixture correction" + : "Correction unavailable" + } + /> + <StatusBadge status={selectedObject?.status ?? "unknown"} label={selectedObject?.status ?? "Unknown"} /> + <span className="meta-pill">{selectedObject?.object_type}</span> + </div> + + <div className="detail-group"> + <h3>{selectedObject?.title}</h3> + <p className="muted-copy">{chainSummary}</p> + </div> + + <div className="detail-group detail-group--muted"> + <h3>Recent correction events</h3> + {review.correction_events.length === 0 ? ( + <p className="muted-copy">No corrections recorded yet.</p> + ) : ( + <ul className="detail-stack"> + {review.correction_events.slice(0, 5).map((eventItem) => ( + <li key={eventItem.id} className="cluster"> + <span className="meta-pill">{eventItem.action}</span> + <span>{eventItem.reason ?? "No reason provided"}</span> + </li> + ))} + </ul> + )} + </div> + + <form className="detail-stack" onSubmit={handleSubmit}> + <div className="grid grid--two"> + <div className="form-field"> + <label htmlFor="continuity-correction-action">Action</label> + <select + id="continuity-correction-action" + value={action} + onChange={(event) => setAction(event.target.value as ContinuityCorrectionAction)} + disabled={!liveModeReady || isSubmitting} + > + {ACTION_OPTIONS.map((option) => ( + <option key={option.value} value={option.value}> + {option.label} + </option> + ))} + </select> + </div> + + <div className="form-field"> + <label htmlFor="continuity-correction-reason">Reason (optional)</label> + <input + id="continuity-correction-reason" + value={reason} + onChange={(event) => setReason(event.target.value)} + maxLength={500} + disabled={!liveModeReady || isSubmitting} + /> + </div> + + {action === "edit" ? ( + <> + <div className="form-field"> + <label htmlFor="continuity-correction-title">Updated title (optional)</label> + <input + id="continuity-correction-title" + value={title} + onChange={(event) => setTitle(event.target.value)} + maxLength={280} + disabled={!liveModeReady || isSubmitting} + /> + </div> + <div className="form-field"> + <label htmlFor="continuity-correction-confidence">Updated confidence (optional)</label> + <input + id="continuity-correction-confidence" + value={confidence} + onChange={(event) => setConfidence(event.target.value)} + placeholder="0.0 to 1.0" + disabled={!liveModeReady || isSubmitting} + /> + </div> + </> + ) : null} + + {action === "supersede" ? ( + <> + <div className="form-field"> + <label htmlFor="continuity-correction-replacement-title">Replacement title (optional)</label> + <input + id="continuity-correction-replacement-title" + value={replacementTitle} + onChange={(event) => setReplacementTitle(event.target.value)} + maxLength={280} + disabled={!liveModeReady || isSubmitting} + /> + </div> + <div className="form-field"> + <label htmlFor="continuity-correction-replacement-confidence">Replacement confidence (optional)</label> + <input + id="continuity-correction-replacement-confidence" + value={replacementConfidence} + onChange={(event) => setReplacementConfidence(event.target.value)} + placeholder="0.0 to 1.0" + disabled={!liveModeReady || isSubmitting} + /> + </div> + </> + ) : null} + </div> + + <div className="composer-actions"> + <div className="composer-status" aria-live="polite"> + <StatusBadge + status={ + isSubmitting + ? "submitting" + : statusTone === "success" + ? "success" + : statusTone === "danger" + ? "error" + : liveModeReady + ? "ready" + : "unavailable" + } + label={ + isSubmitting + ? "Submitting" + : statusTone === "success" + ? "Applied" + : statusTone === "danger" + ? "Attention" + : liveModeReady + ? "Ready" + : "Unavailable" + } + /> + <span>{statusText}</span> + </div> + + <button type="submit" className="button" disabled={!liveModeReady || isSubmitting}> + {isSubmitting ? "Submitting..." : "Apply correction"} + </button> + </div> + </form> + </div> + </SectionCard> + ); +} diff --git a/apps/web/components/continuity-daily-brief.test.tsx b/apps/web/components/continuity-daily-brief.test.tsx new file mode 100644 index 0000000..5dea8cc --- /dev/null +++ b/apps/web/components/continuity-daily-brief.test.tsx @@ -0,0 +1,139 @@ +import React from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import { ContinuityDailyBriefPanel } from "./continuity-daily-brief"; + +const briefFixture = { + assembly_version: "continuity_daily_brief_v0", + scope: { + since: null, + until: null, + }, + waiting_for_highlights: { + items: [ + { + id: "waiting-1", + capture_event_id: "capture-1", + object_type: "WaitingFor" as const, + status: "active", + title: "Waiting For: Vendor quote", + body: { waiting_for_text: "Vendor quote" }, + provenance: {}, + confirmation_status: "unconfirmed" as const, + admission_posture: "DERIVED" as const, + confidence: 1, + relevance: 100, + last_confirmed_at: null, + supersedes_object_id: null, + superseded_by_object_id: null, + scope_matches: [], + provenance_references: [], + ordering: { + scope_match_count: 0, + query_term_match_count: 0, + confirmation_rank: 2, + posture_rank: 2, + lifecycle_rank: 4, + confidence: 1, + }, + created_at: "2026-03-30T09:00:00Z", + updated_at: "2026-03-30T09:00:00Z", + }, + ], + summary: { + limit: 3, + returned_count: 1, + total_count: 1, + order: ["created_at_desc", "id_desc"], + }, + empty_state: { + is_empty: false, + message: "No waiting-for highlights for today in the requested scope.", + }, + }, + blocker_highlights: { + items: [], + summary: { + limit: 3, + returned_count: 0, + total_count: 0, + order: ["created_at_desc", "id_desc"], + }, + empty_state: { + is_empty: true, + message: "No blocker highlights for today in the requested scope.", + }, + }, + stale_items: { + items: [], + summary: { + limit: 3, + returned_count: 0, + total_count: 0, + order: ["created_at_desc", "id_desc"], + }, + empty_state: { + is_empty: true, + message: "No stale items for today in the requested scope.", + }, + }, + next_suggested_action: { + item: { + id: "next-1", + capture_event_id: "capture-2", + object_type: "NextAction" as const, + status: "active", + title: "Next Action: Send follow-up", + body: { action_text: "Send follow-up" }, + provenance: {}, + confirmation_status: "unconfirmed" as const, + admission_posture: "DERIVED" as const, + confidence: 1, + relevance: 99, + last_confirmed_at: null, + supersedes_object_id: null, + superseded_by_object_id: null, + scope_matches: [], + provenance_references: [], + ordering: { + scope_match_count: 0, + query_term_match_count: 0, + confirmation_rank: 2, + posture_rank: 2, + lifecycle_rank: 4, + confidence: 1, + }, + created_at: "2026-03-30T09:05:00Z", + updated_at: "2026-03-30T09:05:00Z", + }, + empty_state: { + is_empty: false, + message: "No next suggested action in the requested scope.", + }, + }, + sources: ["continuity_capture_events", "continuity_objects", "continuity_correction_events"], +}; + +describe("ContinuityDailyBriefPanel", () => { + afterEach(() => { + cleanup(); + }); + + it("renders daily brief sections with explicit empty states", () => { + render(<ContinuityDailyBriefPanel brief={briefFixture} source="live" />); + + expect(screen.getByText("Live daily brief")).toBeInTheDocument(); + expect(screen.getByText("continuity_daily_brief_v0")).toBeInTheDocument(); + expect(screen.getByText("Waiting For: Vendor quote")).toBeInTheDocument(); + expect(screen.getByText("No blocker highlights for today in the requested scope.")).toBeInTheDocument(); + expect(screen.getByText("No stale items for today in the requested scope.")).toBeInTheDocument(); + expect(screen.getByText("Next Action: Send follow-up")).toBeInTheDocument(); + }); + + it("renders fallback empty state when payload is absent", () => { + render(<ContinuityDailyBriefPanel brief={null} source="fixture" />); + + expect(screen.getByText("Daily brief unavailable")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/continuity-daily-brief.tsx b/apps/web/components/continuity-daily-brief.tsx new file mode 100644 index 0000000..dace1dc --- /dev/null +++ b/apps/web/components/continuity-daily-brief.tsx @@ -0,0 +1,97 @@ +import type { ApiSource, ContinuityDailyBrief } from "../lib/api"; +import { EmptyState } from "./empty-state"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +type ContinuityDailyBriefProps = { + brief: ContinuityDailyBrief | null; + source: ApiSource | "unavailable"; + unavailableReason?: string; +}; + +function renderListSection( + heading: string, + section: + | ContinuityDailyBrief["waiting_for_highlights"] + | ContinuityDailyBrief["blocker_highlights"] + | ContinuityDailyBrief["stale_items"], +) { + return ( + <div className="detail-group"> + <h3>{heading}</h3> + {section.items.length === 0 ? ( + <p className="muted-copy">{section.empty_state.message}</p> + ) : ( + <ul className="detail-stack"> + {section.items.map((item) => ( + <li key={item.id} className="cluster"> + <span className="meta-pill">{item.object_type}</span> + <span>{item.title}</span> + </li> + ))} + </ul> + )} + </div> + ); +} + +export function ContinuityDailyBriefPanel({ brief, source, unavailableReason }: ContinuityDailyBriefProps) { + if (brief === null) { + return ( + <SectionCard + eyebrow="Daily" + title="Daily brief" + description="Compose deterministic waiting-for, blocker, stale, and next-action sections for daily continuity review." + > + <EmptyState + title="Daily brief unavailable" + description="Daily brief is not available in this mode yet." + /> + </SectionCard> + ); + } + + return ( + <SectionCard + eyebrow="Daily" + title="Daily brief" + description="Daily review composes deterministic open-loop highlights and one next suggested action with explicit empty states." + > + <div className="detail-stack"> + <div className="cluster"> + <StatusBadge + status={source} + label={ + source === "live" + ? "Live daily brief" + : source === "fixture" + ? "Fixture daily brief" + : "Daily brief unavailable" + } + /> + <span className="meta-pill mono">{brief.assembly_version}</span> + </div> + + {unavailableReason ? ( + <p className="responsive-note">Live daily brief read failed: {unavailableReason}</p> + ) : null} + + {renderListSection("Waiting for", brief.waiting_for_highlights)} + {renderListSection("Blockers", brief.blocker_highlights)} + {renderListSection("Stale", brief.stale_items)} + + <div className="detail-group"> + <h3>Next suggested action</h3> + {brief.next_suggested_action.item ? ( + <div className="cluster"> + <span className="meta-pill">{brief.next_suggested_action.item.object_type}</span> + <span>{brief.next_suggested_action.item.title}</span> + </div> + ) : ( + <p className="muted-copy">{brief.next_suggested_action.empty_state.message}</p> + )} + </div> + </div> + </SectionCard> + ); +} diff --git a/apps/web/components/continuity-inbox-list.test.tsx b/apps/web/components/continuity-inbox-list.test.tsx new file mode 100644 index 0000000..15d0770 --- /dev/null +++ b/apps/web/components/continuity-inbox-list.test.tsx @@ -0,0 +1,116 @@ +import React from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { ContinuityInboxList } from "./continuity-inbox-list"; + +vi.mock("next/link", () => ({ + default: ({ + href, + children, + className, + "aria-current": ariaCurrent, + }: { + href: string; + children: React.ReactNode; + className?: string; + "aria-current"?: string; + }) => ( + <a href={href} className={className} aria-current={ariaCurrent}> + {children} + </a> + ), +})); + +const inboxItems = [ + { + capture_event: { + id: "capture-1", + raw_content: "Finalize launch checklist", + explicit_signal: "task" as const, + admission_posture: "DERIVED" as const, + admission_reason: "explicit_signal_task", + created_at: "2026-03-29T09:00:00Z", + }, + derived_object: { + id: "object-1", + capture_event_id: "capture-1", + object_type: "NextAction" as const, + status: "active" as const, + title: "Next Action: Finalize launch checklist", + body: { + action_text: "Finalize launch checklist", + }, + provenance: { + capture_event_id: "capture-1", + }, + confidence: 1, + created_at: "2026-03-29T09:00:00Z", + updated_at: "2026-03-29T09:00:00Z", + }, + }, + { + capture_event: { + id: "capture-2", + raw_content: "Maybe revisit this next month", + explicit_signal: null, + admission_posture: "TRIAGE" as const, + admission_reason: "ambiguous_capture_requires_triage", + created_at: "2026-03-29T09:10:00Z", + }, + derived_object: null, + }, +]; + +describe("ContinuityInboxList", () => { + afterEach(() => { + cleanup(); + }); + + it("renders links, triage posture, and selected state", () => { + render( + <ContinuityInboxList + items={inboxItems} + selectedCaptureId="capture-2" + summary={{ + limit: 20, + returned_count: 2, + total_count: 2, + derived_count: 1, + triage_count: 1, + order: ["created_at_desc", "id_desc"], + }} + source="live" + />, + ); + + expect(screen.getByRole("link", { name: /Finalize launch checklist/i })).toHaveAttribute( + "href", + "/continuity?capture=capture-1", + ); + expect(screen.getByRole("link", { name: /Maybe revisit this next month/i })).toHaveAttribute( + "href", + "/continuity?capture=capture-2", + ); + expect(screen.getByRole("link", { name: /Maybe revisit this next month/i })).toHaveAttribute( + "aria-current", + "page", + ); + expect(screen.getByText("NextAction")).toBeInTheDocument(); + expect(screen.getByText("No durable object")).toBeInTheDocument(); + expect(screen.getByText("1 triage")).toBeInTheDocument(); + }); + + it("renders empty state when the inbox has no captures", () => { + render( + <ContinuityInboxList + items={[]} + summary={null} + source="fixture" + />, + ); + + expect(screen.getByText("No captures yet")).toBeInTheDocument(); + expect(screen.getByText("Inbox is empty")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/continuity-inbox-list.tsx b/apps/web/components/continuity-inbox-list.tsx new file mode 100644 index 0000000..ed328ed --- /dev/null +++ b/apps/web/components/continuity-inbox-list.tsx @@ -0,0 +1,126 @@ +import Link from "next/link"; + +import type { + ApiSource, + ContinuityCaptureInboxItem, + ContinuityCaptureInboxSummary, +} from "../lib/api"; +import { EmptyState } from "./empty-state"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +type ContinuityInboxListProps = { + items: ContinuityCaptureInboxItem[]; + selectedCaptureId?: string; + summary: ContinuityCaptureInboxSummary | null; + source: ApiSource | "unavailable"; + unavailableReason?: string; +}; + +function formatDate(value: string) { + return new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(value)); +} + +function previewContent(value: string) { + return value.length > 160 ? `${value.slice(0, 157)}...` : value; +} + +export function ContinuityInboxList({ + items, + selectedCaptureId, + summary, + source, + unavailableReason, +}: ContinuityInboxListProps) { + if (items.length === 0) { + return ( + <SectionCard + eyebrow="Capture inbox" + title="No captures yet" + description="Use fast capture to append immutable events. Ambiguous captures remain visible in triage posture." + > + <EmptyState + title="Inbox is empty" + description="Submit one capture to seed the continuity inbox and derived-object pipeline." + /> + </SectionCard> + ); + } + + return ( + <SectionCard + eyebrow="Capture inbox" + title="Recent captures" + description="Every row is an immutable capture event. Derived objects are shown only when admission is explicit or high confidence." + > + <div className="list-panel"> + <div className="list-panel__header"> + <div className="cluster"> + <StatusBadge + status={source} + label={ + source === "live" + ? "Live inbox" + : source === "fixture" + ? "Fixture inbox" + : "Inbox unavailable" + } + /> + {summary ? <span className="meta-pill">{summary.total_count} total</span> : null} + {summary ? <span className="meta-pill">{summary.triage_count} triage</span> : null} + </div> + </div> + + {unavailableReason ? <p className="responsive-note">Live inbox read failed: {unavailableReason}</p> : null} + + <div className="list-rows"> + {items.map((item) => { + const capture = item.capture_event; + const derived = item.derived_object; + const href = `/continuity?capture=${encodeURIComponent(capture.id)}`; + + return ( + <Link + key={capture.id} + href={href} + className={`list-row${capture.id === selectedCaptureId ? " is-selected" : ""}`} + aria-current={capture.id === selectedCaptureId ? "page" : undefined} + > + <div className="list-row__topline"> + <div className="detail-stack"> + <span className="list-row__eyebrow">{formatDate(capture.created_at)}</span> + <h3 className="list-row__title">{previewContent(capture.raw_content)}</h3> + </div> + <div className="cluster"> + <StatusBadge + status={capture.admission_posture} + label={capture.admission_posture === "TRIAGE" ? "Triage" : "Derived"} + /> + {capture.explicit_signal ? ( + <span className="meta-pill mono">{capture.explicit_signal}</span> + ) : null} + </div> + </div> + + <div className="list-row__meta"> + <span className="meta-pill mono">{capture.id}</span> + {derived ? ( + <span className="meta-pill">{derived.object_type}</span> + ) : ( + <span className="meta-pill">No durable object</span> + )} + <span className="meta-pill">{capture.admission_reason}</span> + </div> + </Link> + ); + })} + </div> + </div> + </SectionCard> + ); +} diff --git a/apps/web/components/continuity-open-loops-panel.test.tsx b/apps/web/components/continuity-open-loops-panel.test.tsx new file mode 100644 index 0000000..4828733 --- /dev/null +++ b/apps/web/components/continuity-open-loops-panel.test.tsx @@ -0,0 +1,199 @@ +import React from "react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ContinuityOpenLoopsPanel } from "./continuity-open-loops-panel"; + +const { applyContinuityOpenLoopReviewActionMock, refreshMock } = vi.hoisted(() => ({ + applyContinuityOpenLoopReviewActionMock: vi.fn(), + refreshMock: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + refresh: refreshMock, + }), +})); + +vi.mock("../lib/api", async () => { + const actual = await vi.importActual("../lib/api"); + return { + ...actual, + applyContinuityOpenLoopReviewAction: applyContinuityOpenLoopReviewActionMock, + }; +}); + +const dashboardFixture = { + scope: { + since: null, + until: null, + }, + waiting_for: { + items: [ + { + id: "object-1", + capture_event_id: "capture-1", + object_type: "WaitingFor" as const, + status: "active", + title: "Waiting For: Vendor quote", + body: { waiting_for_text: "Vendor quote" }, + provenance: {}, + confirmation_status: "unconfirmed" as const, + admission_posture: "DERIVED" as const, + confidence: 1, + relevance: 100, + last_confirmed_at: null, + supersedes_object_id: null, + superseded_by_object_id: null, + scope_matches: [], + provenance_references: [], + ordering: { + scope_match_count: 0, + query_term_match_count: 0, + confirmation_rank: 2, + posture_rank: 2, + lifecycle_rank: 4, + confidence: 1, + }, + created_at: "2026-03-30T10:00:00Z", + updated_at: "2026-03-30T10:00:00Z", + }, + ], + summary: { + limit: 10, + returned_count: 1, + total_count: 1, + order: ["created_at_desc", "id_desc"], + }, + empty_state: { + is_empty: false, + message: "none", + }, + }, + blocker: { + items: [], + summary: { + limit: 10, + returned_count: 0, + total_count: 0, + order: ["created_at_desc", "id_desc"], + }, + empty_state: { + is_empty: true, + message: "No blocker items in the requested scope.", + }, + }, + stale: { + items: [], + summary: { + limit: 10, + returned_count: 0, + total_count: 0, + order: ["created_at_desc", "id_desc"], + }, + empty_state: { + is_empty: true, + message: "No stale items in the requested scope.", + }, + }, + next_action: { + items: [], + summary: { + limit: 10, + returned_count: 0, + total_count: 0, + order: ["created_at_desc", "id_desc"], + }, + empty_state: { + is_empty: true, + message: "No next-action items in the requested scope.", + }, + }, + summary: { + limit: 10, + total_count: 1, + posture_order: ["waiting_for", "blocker", "stale", "next_action"] as const, + item_order: ["created_at_desc", "id_desc"], + }, + sources: ["continuity_capture_events", "continuity_objects", "continuity_correction_events"], +}; + +describe("ContinuityOpenLoopsPanel", () => { + beforeEach(() => { + applyContinuityOpenLoopReviewActionMock.mockReset(); + refreshMock.mockReset(); + }); + + afterEach(() => { + cleanup(); + }); + + it("renders posture groups and submits review actions", async () => { + applyContinuityOpenLoopReviewActionMock.mockResolvedValue({ + continuity_object: { + id: "object-1", + capture_event_id: "capture-1", + object_type: "WaitingFor", + status: "completed", + title: "Waiting For: Vendor quote", + body: { waiting_for_text: "Vendor quote" }, + provenance: {}, + confidence: 1, + last_confirmed_at: null, + supersedes_object_id: null, + superseded_by_object_id: null, + created_at: "2026-03-30T10:00:00Z", + updated_at: "2026-03-30T10:01:00Z", + }, + correction_event: { + id: "event-1", + continuity_object_id: "object-1", + action: "edit", + reason: null, + before_snapshot: {}, + after_snapshot: {}, + payload: { review_action: "done" }, + created_at: "2026-03-30T10:01:00Z", + }, + review_action: "done", + lifecycle_outcome: "completed", + }); + + render( + <ContinuityOpenLoopsPanel + apiBaseUrl="https://api.example.com" + userId="user-1" + dashboard={dashboardFixture} + source="live" + />, + ); + + expect(screen.getByText("Live open loops")).toBeInTheDocument(); + expect(screen.getByText("Waiting For: Vendor quote")).toBeInTheDocument(); + expect(screen.getByText("No blocker items in the requested scope.")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Done" })); + + await waitFor(() => { + expect(applyContinuityOpenLoopReviewActionMock).toHaveBeenCalledWith( + "https://api.example.com", + "object-1", + { + user_id: "user-1", + action: "done", + }, + ); + }); + + await waitFor(() => { + expect(refreshMock).toHaveBeenCalled(); + }); + expect(await screen.findByText(/Lifecycle is now completed/i)).toBeInTheDocument(); + }); + + it("renders explicit fallback when dashboard payload is absent", () => { + render(<ContinuityOpenLoopsPanel dashboard={null} source="fixture" />); + + expect(screen.getByText("Open-loop dashboard unavailable")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/continuity-open-loops-panel.tsx b/apps/web/components/continuity-open-loops-panel.tsx new file mode 100644 index 0000000..84cfd9a --- /dev/null +++ b/apps/web/components/continuity-open-loops-panel.tsx @@ -0,0 +1,202 @@ +"use client"; + +import { useState } from "react"; + +import { useRouter } from "next/navigation"; + +import type { + ApiSource, + ContinuityOpenLoopDashboard, + ContinuityOpenLoopPosture, + ContinuityOpenLoopReviewAction, +} from "../lib/api"; +import { applyContinuityOpenLoopReviewAction } from "../lib/api"; +import { EmptyState } from "./empty-state"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +type ContinuityOpenLoopsPanelProps = { + apiBaseUrl?: string; + userId?: string; + dashboard: ContinuityOpenLoopDashboard | null; + source: ApiSource | "unavailable"; + unavailableReason?: string; +}; + +const GROUPS: Array<{ key: ContinuityOpenLoopPosture; label: string }> = [ + { key: "waiting_for", label: "Waiting for" }, + { key: "blocker", label: "Blockers" }, + { key: "stale", label: "Stale" }, + { key: "next_action", label: "Next action" }, +]; + +const ACTION_LABELS: Array<{ action: ContinuityOpenLoopReviewAction; label: string }> = [ + { action: "done", label: "Done" }, + { action: "deferred", label: "Deferred" }, + { action: "still_blocked", label: "Still blocked" }, +]; + +export function ContinuityOpenLoopsPanel({ + apiBaseUrl, + userId, + dashboard, + source, + unavailableReason, +}: ContinuityOpenLoopsPanelProps) { + const router = useRouter(); + const liveModeReady = Boolean(apiBaseUrl && userId && source === "live"); + const [isSubmitting, setIsSubmitting] = useState(false); + const [statusTone, setStatusTone] = useState<"info" | "success" | "danger">("info"); + const [statusText, setStatusText] = useState( + "Review one open-loop item and apply done/deferred/still_blocked actions.", + ); + + async function handleAction( + continuityObjectId: string, + action: ContinuityOpenLoopReviewAction, + title: string, + ) { + if (!liveModeReady || !apiBaseUrl || !userId) { + setStatusTone("info"); + setStatusText("Review actions are available only when live continuity API access is configured."); + return; + } + + setIsSubmitting(true); + setStatusTone("info"); + setStatusText(`Applying ${action}...`); + + try { + const payload = await applyContinuityOpenLoopReviewAction(apiBaseUrl, continuityObjectId, { + user_id: userId, + action, + }); + setStatusTone("success"); + setStatusText( + `${action} applied to "${title}". Lifecycle is now ${payload.lifecycle_outcome}. Resumption has been refreshed.`, + ); + router.refresh(); + } catch (error) { + setStatusTone("danger"); + setStatusText( + `Unable to apply review action: ${error instanceof Error ? error.message : "Request failed"}`, + ); + } finally { + setIsSubmitting(false); + } + } + + if (dashboard === null) { + return ( + <SectionCard + eyebrow="Open loops" + title="Open-loop dashboard" + description="Review waiting-for, blocker, stale, and next-action posture with deterministic grouping and ordering." + > + <EmptyState + title="Open-loop dashboard unavailable" + description="Open-loop dashboard is not available in this mode yet." + /> + </SectionCard> + ); + } + + return ( + <SectionCard + eyebrow="Open loops" + title="Open-loop dashboard" + description="Deterministic posture groups support daily continuity review and explicit action handling." + > + <div className="detail-stack"> + <div className="cluster"> + <StatusBadge + status={source} + label={ + source === "live" + ? "Live open loops" + : source === "fixture" + ? "Fixture open loops" + : "Open-loop dashboard unavailable" + } + /> + <span className="meta-pill">{dashboard.summary.total_count} total</span> + </div> + + {unavailableReason ? ( + <p className="responsive-note">Live open-loop dashboard read failed: {unavailableReason}</p> + ) : null} + + <div className="composer-status" aria-live="polite"> + <StatusBadge + status={ + isSubmitting + ? "submitting" + : statusTone === "success" + ? "success" + : statusTone === "danger" + ? "error" + : liveModeReady + ? "ready" + : "unavailable" + } + label={ + isSubmitting + ? "Submitting" + : statusTone === "success" + ? "Applied" + : statusTone === "danger" + ? "Attention" + : liveModeReady + ? "Ready" + : "Unavailable" + } + /> + <span>{statusText}</span> + </div> + + {GROUPS.map((group) => { + const section = dashboard[group.key]; + return ( + <div key={group.key} className="detail-group"> + <h3>{group.label}</h3> + {section.items.length === 0 ? ( + <p className="muted-copy">{section.empty_state.message}</p> + ) : ( + <ul className="detail-stack"> + {section.items.map((item) => ( + <li key={item.id} className="list-row"> + <div className="list-row__topline"> + <div className="detail-stack"> + <span className="list-row__eyebrow mono">{item.id}</span> + <span className="list-row__title">{item.title}</span> + </div> + <div className="cluster"> + <span className="meta-pill">{item.object_type}</span> + <StatusBadge status={item.status} label={item.status} /> + </div> + </div> + + <div className="composer-actions"> + {ACTION_LABELS.map((option) => ( + <button + key={option.action} + type="button" + className="button button--ghost" + onClick={() => handleAction(item.id, option.action, item.title)} + disabled={!liveModeReady || isSubmitting} + > + {option.label} + </button> + ))} + </div> + </li> + ))} + </ul> + )} + </div> + ); + })} + </div> + </SectionCard> + ); +} diff --git a/apps/web/components/continuity-recall-panel.test.tsx b/apps/web/components/continuity-recall-panel.test.tsx new file mode 100644 index 0000000..da1d5fd --- /dev/null +++ b/apps/web/components/continuity-recall-panel.test.tsx @@ -0,0 +1,122 @@ +import React from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import { ContinuityRecallPanel } from "./continuity-recall-panel"; + +const recallItems = [ + { + id: "recall-1", + capture_event_id: "capture-1", + object_type: "Decision" as const, + status: "active", + title: "Decision: Keep rollout phased", + body: { decision_text: "Keep rollout phased" }, + provenance: { thread_id: "thread-1" }, + confirmation_status: "confirmed" as const, + admission_posture: "DERIVED" as const, + confidence: 0.95, + relevance: 130, + last_confirmed_at: "2026-03-29T10:00:00Z", + supersedes_object_id: null, + superseded_by_object_id: null, + scope_matches: [{ kind: "thread" as const, value: "thread-1" }], + provenance_references: [{ source_kind: "continuity_capture_event", source_id: "capture-1" }], + ordering: { + scope_match_count: 1, + query_term_match_count: 1, + confirmation_rank: 3, + freshness_posture: "fresh" as const, + freshness_rank: 4, + provenance_posture: "strong" as const, + provenance_rank: 3, + supersession_posture: "current" as const, + supersession_rank: 3, + posture_rank: 2, + lifecycle_rank: 4, + confidence: 0.95, + }, + created_at: "2026-03-29T10:00:00Z", + updated_at: "2026-03-29T10:00:00Z", + }, +]; + +describe("ContinuityRecallPanel", () => { + afterEach(() => { + cleanup(); + }); + + it("renders recall filters and provenance-backed result cards", () => { + render( + <ContinuityRecallPanel + results={recallItems} + summary={{ + query: "rollout", + filters: { + thread_id: "thread-1", + since: null, + until: null, + }, + limit: 20, + returned_count: 1, + total_count: 1, + order: ["relevance_desc", "created_at_desc", "id_desc"], + }} + source="live" + filters={{ + query: "rollout", + threadId: "thread-1", + taskId: "", + project: "", + person: "", + since: "", + until: "", + limit: 20, + }} + />, + ); + + expect(screen.getByText("Live recall")).toBeInTheDocument(); + expect(screen.getByText("1 matched")).toBeInTheDocument(); + expect(screen.getByLabelText("Query")).toHaveValue("rollout"); + expect(screen.getByLabelText("Thread ID")).toHaveValue("thread-1"); + expect(screen.getByText("Decision: Keep rollout phased")).toBeInTheDocument(); + expect(screen.getByText("1 provenance refs")).toBeInTheDocument(); + expect(screen.getByText("1 scope matches")).toBeInTheDocument(); + expect(screen.getByText("freshness fresh")).toBeInTheDocument(); + expect(screen.getByText("provenance strong")).toBeInTheDocument(); + expect(screen.getByText("supersession current")).toBeInTheDocument(); + }); + + it("renders explicit empty state when no recall results exist", () => { + render( + <ContinuityRecallPanel + results={[]} + summary={{ + query: null, + filters: { + since: null, + until: null, + }, + limit: 20, + returned_count: 0, + total_count: 0, + order: ["relevance_desc", "created_at_desc", "id_desc"], + }} + source="fixture" + filters={{ + query: "", + threadId: "", + taskId: "", + project: "", + person: "", + since: "", + until: "", + limit: 20, + }} + />, + ); + + expect(screen.getByText("No recall hits")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/continuity-recall-panel.tsx b/apps/web/components/continuity-recall-panel.tsx new file mode 100644 index 0000000..2912b73 --- /dev/null +++ b/apps/web/components/continuity-recall-panel.tsx @@ -0,0 +1,154 @@ +import type { ApiSource, ContinuityRecallResult, ContinuityRecallSummary } from "../lib/api"; +import { EmptyState } from "./empty-state"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +type ContinuityRecallPanelProps = { + results: ContinuityRecallResult[]; + summary: ContinuityRecallSummary | null; + source: ApiSource | "unavailable"; + unavailableReason?: string; + filters: { + query: string; + threadId: string; + taskId: string; + project: string; + person: string; + since: string; + until: string; + limit: number; + }; +}; + +function formatDate(value: string) { + return new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(value)); +} + +function toFixedRelevance(value: number) { + if (!Number.isFinite(value)) { + return "0.00"; + } + return value.toFixed(2); +} + +export function ContinuityRecallPanel({ + results, + summary, + source, + unavailableReason, + filters, +}: ContinuityRecallPanelProps) { + return ( + <SectionCard + eyebrow="Recall" + title="Continuity recall" + description="Query typed continuity objects with scoped filters and provenance-backed ranking." + > + <div className="detail-stack"> + <form method="get" className="detail-stack"> + <div className="cluster"> + <StatusBadge + status={source} + label={ + source === "live" + ? "Live recall" + : source === "fixture" + ? "Fixture recall" + : "Recall unavailable" + } + /> + {summary ? <span className="meta-pill">{summary.total_count} matched</span> : null} + </div> + + {unavailableReason ? ( + <p className="responsive-note">Live recall read failed: {unavailableReason}</p> + ) : null} + + <div className="grid grid--two"> + <div className="form-field"> + <label htmlFor="continuity-recall-query">Query</label> + <input id="continuity-recall-query" name="recall_query" defaultValue={filters.query} maxLength={4000} /> + </div> + <div className="form-field"> + <label htmlFor="continuity-recall-limit">Limit</label> + <input + id="continuity-recall-limit" + name="recall_limit" + type="number" + min={1} + max={100} + defaultValue={String(filters.limit)} + /> + </div> + <div className="form-field"> + <label htmlFor="continuity-recall-thread">Thread ID</label> + <input id="continuity-recall-thread" name="recall_thread" defaultValue={filters.threadId} /> + </div> + <div className="form-field"> + <label htmlFor="continuity-recall-task">Task ID</label> + <input id="continuity-recall-task" name="recall_task" defaultValue={filters.taskId} /> + </div> + <div className="form-field"> + <label htmlFor="continuity-recall-project">Project</label> + <input id="continuity-recall-project" name="recall_project" defaultValue={filters.project} /> + </div> + <div className="form-field"> + <label htmlFor="continuity-recall-person">Person</label> + <input id="continuity-recall-person" name="recall_person" defaultValue={filters.person} /> + </div> + <div className="form-field"> + <label htmlFor="continuity-recall-since">Since (ISO datetime)</label> + <input id="continuity-recall-since" name="recall_since" defaultValue={filters.since} /> + </div> + <div className="form-field"> + <label htmlFor="continuity-recall-until">Until (ISO datetime)</label> + <input id="continuity-recall-until" name="recall_until" defaultValue={filters.until} /> + </div> + </div> + + <div className="composer-actions"> + <button type="submit" className="button">Run recall</button> + </div> + </form> + + {results.length === 0 ? ( + <EmptyState + title="No recall hits" + description="Try broader filters or a less specific query to retrieve continuity evidence." + /> + ) : ( + <div className="list-rows"> + {results.map((item) => ( + <article key={item.id} className="list-row"> + <div className="list-row__topline"> + <div className="detail-stack"> + <span className="list-row__eyebrow">{formatDate(item.created_at)}</span> + <h3 className="list-row__title">{item.title}</h3> + </div> + <div className="cluster"> + <StatusBadge status={item.admission_posture} label={item.admission_posture} /> + <span className="meta-pill">{item.object_type}</span> + <span className="meta-pill">{item.confirmation_status}</span> + </div> + </div> + <div className="list-row__meta"> + <span className="meta-pill mono">score {toFixedRelevance(item.relevance)}</span> + <span className="meta-pill">freshness {item.ordering.freshness_posture}</span> + <span className="meta-pill">provenance {item.ordering.provenance_posture}</span> + <span className="meta-pill">supersession {item.ordering.supersession_posture}</span> + <span className="meta-pill">{item.provenance_references.length} provenance refs</span> + <span className="meta-pill">{item.scope_matches.length} scope matches</span> + </div> + </article> + ))} + </div> + )} + </div> + </SectionCard> + ); +} diff --git a/apps/web/components/continuity-review-queue.test.tsx b/apps/web/components/continuity-review-queue.test.tsx new file mode 100644 index 0000000..b9eeefc --- /dev/null +++ b/apps/web/components/continuity-review-queue.test.tsx @@ -0,0 +1,116 @@ +import React from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { ContinuityReviewQueue } from "./continuity-review-queue"; + +vi.mock("next/link", () => ({ + default: ({ + href, + children, + className, + "aria-current": ariaCurrent, + }: { + href: string; + children: React.ReactNode; + className?: string; + "aria-current"?: string; + }) => ( + <a href={href} className={className} aria-current={ariaCurrent}> + {children} + </a> + ), +})); + +const queueItems = [ + { + id: "object-1", + capture_event_id: "capture-1", + object_type: "Decision" as const, + status: "active", + title: "Decision: Keep rollout phased", + body: { decision_text: "Keep rollout phased" }, + provenance: {}, + confidence: 0.95, + last_confirmed_at: "2026-03-30T10:00:00Z", + supersedes_object_id: null, + superseded_by_object_id: null, + created_at: "2026-03-30T10:00:00Z", + updated_at: "2026-03-30T10:00:00Z", + }, + { + id: "object-2", + capture_event_id: "capture-2", + object_type: "Decision" as const, + status: "stale", + title: "Decision: Recheck next week", + body: { decision_text: "Recheck next week" }, + provenance: {}, + confidence: 0.75, + last_confirmed_at: null, + supersedes_object_id: null, + superseded_by_object_id: null, + created_at: "2026-03-30T11:00:00Z", + updated_at: "2026-03-30T11:00:00Z", + }, +]; + +describe("ContinuityReviewQueue", () => { + afterEach(() => { + cleanup(); + }); + + it("renders review queue filters and selectable items", () => { + render( + <ContinuityReviewQueue + items={queueItems} + summary={{ + status: "correction_ready", + limit: 20, + returned_count: 2, + total_count: 2, + order: ["updated_at_desc", "created_at_desc", "id_desc"], + }} + selectedObjectId="object-1" + source="live" + filters={{ + status: "correction_ready", + limit: 20, + }} + />, + ); + + expect(screen.getByText("Live review queue")).toBeInTheDocument(); + expect(screen.getByText("2 queued")).toBeInTheDocument(); + expect(screen.getByLabelText("Status filter")).toHaveValue("correction_ready"); + expect(screen.getByLabelText("Limit")).toHaveValue(20); + expect(screen.getByText("Decision: Keep rollout phased")).toBeInTheDocument(); + expect(screen.getByText("Decision: Recheck next week")).toBeInTheDocument(); + + const selectedLink = screen.getByRole("link", { name: "Selected" }); + expect(selectedLink).toHaveAttribute("aria-current", "true"); + }); + + it("renders explicit empty state when queue is empty", () => { + render( + <ContinuityReviewQueue + items={[]} + summary={{ + status: "correction_ready", + limit: 20, + returned_count: 0, + total_count: 0, + order: ["updated_at_desc", "created_at_desc", "id_desc"], + }} + selectedObjectId="" + source="fixture" + filters={{ + status: "correction_ready", + limit: 20, + }} + />, + ); + + expect(screen.getByText("No review items")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/continuity-review-queue.tsx b/apps/web/components/continuity-review-queue.tsx new file mode 100644 index 0000000..d6a8055 --- /dev/null +++ b/apps/web/components/continuity-review-queue.tsx @@ -0,0 +1,155 @@ +import Link from "next/link"; + +import type { + ApiSource, + ContinuityReviewObject, + ContinuityReviewQueueSummary, + ContinuityReviewStatusFilter, +} from "../lib/api"; +import { EmptyState } from "./empty-state"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +type ContinuityReviewQueueProps = { + items: ContinuityReviewObject[]; + summary: ContinuityReviewQueueSummary | null; + selectedObjectId: string; + source: ApiSource | "unavailable"; + unavailableReason?: string; + filters: { + status: ContinuityReviewStatusFilter; + limit: number; + }; +}; + +const FILTER_OPTIONS: Array<{ value: ContinuityReviewStatusFilter; label: string }> = [ + { value: "correction_ready", label: "Correction ready" }, + { value: "active", label: "Active" }, + { value: "stale", label: "Stale" }, + { value: "superseded", label: "Superseded" }, + { value: "deleted", label: "Deleted" }, + { value: "all", label: "All" }, +]; + +function formatTimestamp(value: string | null) { + if (!value) { + return "Never"; + } + return new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(value)); +} + +export function ContinuityReviewQueue({ + items, + summary, + selectedObjectId, + source, + unavailableReason, + filters, +}: ContinuityReviewQueueProps) { + return ( + <SectionCard + eyebrow="Review" + title="Continuity review queue" + description="Inspect correction-ready continuity objects, filter posture, and choose one object to apply deterministic correction actions." + > + <div className="detail-stack"> + <form method="get" className="detail-stack"> + <div className="cluster"> + <StatusBadge + status={source} + label={ + source === "live" + ? "Live review queue" + : source === "fixture" + ? "Fixture review queue" + : "Review queue unavailable" + } + /> + {summary ? <span className="meta-pill">{summary.total_count} queued</span> : null} + </div> + + {unavailableReason ? ( + <p className="responsive-note">Live review queue read failed: {unavailableReason}</p> + ) : null} + + <div className="grid grid--two"> + <div className="form-field"> + <label htmlFor="continuity-review-status">Status filter</label> + <select id="continuity-review-status" name="review_status" defaultValue={filters.status}> + {FILTER_OPTIONS.map((option) => ( + <option key={option.value} value={option.value}> + {option.label} + </option> + ))} + </select> + </div> + <div className="form-field"> + <label htmlFor="continuity-review-limit">Limit</label> + <input + id="continuity-review-limit" + name="review_limit" + type="number" + min={1} + max={100} + defaultValue={String(filters.limit)} + /> + </div> + </div> + + <div className="composer-actions"> + <button type="submit" className="button">Refresh queue</button> + </div> + </form> + + {items.length === 0 ? ( + <EmptyState + title="No review items" + description="No continuity objects match the current review filter." + /> + ) : ( + <div className="list-rows"> + {items.map((item) => { + const isSelected = item.id === selectedObjectId; + const href = `?review_object=${encodeURIComponent(item.id)}&review_status=${encodeURIComponent(filters.status)}&review_limit=${filters.limit}`; + + return ( + <article key={item.id} className="list-row"> + <div className="list-row__topline"> + <div className="detail-stack"> + <span className="list-row__eyebrow mono">{item.id}</span> + <h3 className="list-row__title">{item.title}</h3> + </div> + <div className="cluster"> + <StatusBadge status={item.status} label={item.status} /> + <span className="meta-pill">{item.object_type}</span> + </div> + </div> + + <div className="list-row__meta"> + <span className="meta-pill">Confirmed: {formatTimestamp(item.last_confirmed_at)}</span> + <span className="meta-pill mono">confidence {item.confidence.toFixed(2)}</span> + </div> + + <div className="composer-actions"> + <Link + href={href} + className="button button--ghost" + aria-current={isSelected ? "true" : undefined} + > + {isSelected ? "Selected" : "Review"} + </Link> + </div> + </article> + ); + })} + </div> + )} + </div> + </SectionCard> + ); +} diff --git a/apps/web/components/continuity-weekly-review.test.tsx b/apps/web/components/continuity-weekly-review.test.tsx new file mode 100644 index 0000000..2991742 --- /dev/null +++ b/apps/web/components/continuity-weekly-review.test.tsx @@ -0,0 +1,156 @@ +import React from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import { ContinuityWeeklyReviewPanel } from "./continuity-weekly-review"; + +const reviewFixture = { + assembly_version: "continuity_weekly_review_v0", + scope: { + since: null, + until: null, + }, + rollup: { + total_count: 2, + waiting_for_count: 1, + blocker_count: 0, + stale_count: 1, + next_action_count: 0, + posture_order: ["waiting_for", "blocker", "stale", "next_action"] as const, + }, + waiting_for: { + items: [ + { + id: "waiting-1", + capture_event_id: "capture-1", + object_type: "WaitingFor" as const, + status: "active", + title: "Waiting For: Vendor quote", + body: { waiting_for_text: "Vendor quote" }, + provenance: {}, + confirmation_status: "unconfirmed" as const, + admission_posture: "DERIVED" as const, + confidence: 1, + relevance: 100, + last_confirmed_at: null, + supersedes_object_id: null, + superseded_by_object_id: null, + scope_matches: [], + provenance_references: [], + ordering: { + scope_match_count: 0, + query_term_match_count: 0, + confirmation_rank: 2, + posture_rank: 2, + lifecycle_rank: 4, + confidence: 1, + }, + created_at: "2026-03-30T09:00:00Z", + updated_at: "2026-03-30T09:00:00Z", + }, + ], + summary: { + limit: 5, + returned_count: 1, + total_count: 1, + order: ["created_at_desc", "id_desc"], + }, + empty_state: { + is_empty: false, + message: "No waiting-for items in the requested scope.", + }, + }, + blocker: { + items: [], + summary: { + limit: 5, + returned_count: 0, + total_count: 0, + order: ["created_at_desc", "id_desc"], + }, + empty_state: { + is_empty: true, + message: "No blocker items in the requested scope.", + }, + }, + stale: { + items: [ + { + id: "stale-1", + capture_event_id: "capture-2", + object_type: "WaitingFor" as const, + status: "stale", + title: "Waiting For: Stale invoice response", + body: { waiting_for_text: "Stale invoice response" }, + provenance: {}, + confirmation_status: "unconfirmed" as const, + admission_posture: "DERIVED" as const, + confidence: 1, + relevance: 90, + last_confirmed_at: null, + supersedes_object_id: null, + superseded_by_object_id: null, + scope_matches: [], + provenance_references: [], + ordering: { + scope_match_count: 0, + query_term_match_count: 0, + confirmation_rank: 2, + posture_rank: 2, + lifecycle_rank: 3, + confidence: 1, + }, + created_at: "2026-03-30T09:05:00Z", + updated_at: "2026-03-30T09:05:00Z", + }, + ], + summary: { + limit: 5, + returned_count: 1, + total_count: 1, + order: ["created_at_desc", "id_desc"], + }, + empty_state: { + is_empty: false, + message: "No stale items in the requested scope.", + }, + }, + next_action: { + items: [], + summary: { + limit: 5, + returned_count: 0, + total_count: 0, + order: ["created_at_desc", "id_desc"], + }, + empty_state: { + is_empty: true, + message: "No next-action items in the requested scope.", + }, + }, + sources: ["continuity_capture_events", "continuity_objects", "continuity_correction_events"], +}; + +describe("ContinuityWeeklyReviewPanel", () => { + afterEach(() => { + cleanup(); + }); + + it("renders weekly rollup and grouped posture sections", () => { + render(<ContinuityWeeklyReviewPanel review={reviewFixture} source="live" />); + + expect(screen.getByText("Live weekly review")).toBeInTheDocument(); + expect(screen.getByText("continuity_weekly_review_v0")).toBeInTheDocument(); + expect(screen.getByText("2 total")).toBeInTheDocument(); + expect(screen.getByText("Waiting For: Vendor quote")).toBeInTheDocument(); + expect(screen.getByText("Waiting For: Stale invoice response")).toBeInTheDocument(); + expect(screen.getByText("No blocker items in the requested scope.")).toBeInTheDocument(); + expect(screen.getByText("No next-action items in the requested scope.")).toBeInTheDocument(); + }); + + it("renders fallback empty state when payload is absent", () => { + render(<ContinuityWeeklyReviewPanel review={null} source="fixture" />); + + expect(screen.getByText("Weekly review unavailable")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/continuity-weekly-review.tsx b/apps/web/components/continuity-weekly-review.tsx new file mode 100644 index 0000000..b2d384d --- /dev/null +++ b/apps/web/components/continuity-weekly-review.tsx @@ -0,0 +1,103 @@ +import type { ApiSource, ContinuityWeeklyReview } from "../lib/api"; +import { EmptyState } from "./empty-state"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +type ContinuityWeeklyReviewProps = { + review: ContinuityWeeklyReview | null; + source: ApiSource | "unavailable"; + unavailableReason?: string; +}; + +function renderSection( + heading: string, + section: ContinuityWeeklyReview["waiting_for"] | ContinuityWeeklyReview["blocker"] | ContinuityWeeklyReview["stale"] | ContinuityWeeklyReview["next_action"], +) { + return ( + <div className="detail-group"> + <h3>{heading}</h3> + {section.items.length === 0 ? ( + <p className="muted-copy">{section.empty_state.message}</p> + ) : ( + <ul className="detail-stack"> + {section.items.map((item) => ( + <li key={item.id} className="cluster"> + <span className="meta-pill">{item.status}</span> + <span>{item.title}</span> + </li> + ))} + </ul> + )} + </div> + ); +} + +export function ContinuityWeeklyReviewPanel({ review, source, unavailableReason }: ContinuityWeeklyReviewProps) { + if (review === null) { + return ( + <SectionCard + eyebrow="Weekly" + title="Weekly review" + description="Compile deterministic weekly posture rollups over waiting, blocker, stale, and next-action continuity seams." + > + <EmptyState + title="Weekly review unavailable" + description="Weekly review is not available in this mode yet." + /> + </SectionCard> + ); + } + + return ( + <SectionCard + eyebrow="Weekly" + title="Weekly review" + description="Weekly review keeps posture counts and grouped continuity sections deterministic and auditable." + > + <div className="detail-stack"> + <div className="cluster"> + <StatusBadge + status={source} + label={ + source === "live" + ? "Live weekly review" + : source === "fixture" + ? "Fixture weekly review" + : "Weekly review unavailable" + } + /> + <span className="meta-pill mono">{review.assembly_version}</span> + <span className="meta-pill">{review.rollup.total_count} total</span> + </div> + + {unavailableReason ? ( + <p className="responsive-note">Live weekly review read failed: {unavailableReason}</p> + ) : null} + + <dl className="key-value-grid key-value-grid--compact"> + <div> + <dt>Waiting for</dt> + <dd>{review.rollup.waiting_for_count}</dd> + </div> + <div> + <dt>Blockers</dt> + <dd>{review.rollup.blocker_count}</dd> + </div> + <div> + <dt>Stale</dt> + <dd>{review.rollup.stale_count}</dd> + </div> + <div> + <dt>Next action</dt> + <dd>{review.rollup.next_action_count}</dd> + </div> + </dl> + + {renderSection("Waiting for", review.waiting_for)} + {renderSection("Blockers", review.blocker)} + {renderSection("Stale", review.stale)} + {renderSection("Next action", review.next_action)} + </div> + </SectionCard> + ); +} diff --git a/apps/web/components/empty-state.tsx b/apps/web/components/empty-state.tsx new file mode 100644 index 0000000..92c7ea0 --- /dev/null +++ b/apps/web/components/empty-state.tsx @@ -0,0 +1,29 @@ +import Link from "next/link"; + +type EmptyStateProps = { + title: string; + description: string; + actionHref?: string; + actionLabel?: string; + className?: string; +}; + +export function EmptyState({ + title, + description, + actionHref, + actionLabel, + className, +}: EmptyStateProps) { + return ( + <div className={["empty-state", className].filter(Boolean).join(" ")}> + <h3 className="empty-state__title">{title}</h3> + <p className="empty-state__description">{description}</p> + {actionHref && actionLabel ? ( + <Link href={actionHref} className="button-secondary"> + {actionLabel} + </Link> + ) : null} + </div> + ); +} diff --git a/apps/web/components/entity-detail.tsx b/apps/web/components/entity-detail.tsx new file mode 100644 index 0000000..1668a57 --- /dev/null +++ b/apps/web/components/entity-detail.tsx @@ -0,0 +1,101 @@ +import type { ApiSource, EntityRecord } from "../lib/api"; +import { EmptyState } from "./empty-state"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +type EntityDetailProps = { + entity: EntityRecord | null; + source: ApiSource | "unavailable" | null; + unavailableReason?: string; +}; + +function formatDate(value: string) { + return new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(value)); +} + +export function EntityDetail({ entity, source, unavailableReason }: EntityDetailProps) { + if (!entity) { + return ( + <SectionCard + eyebrow="Selected entity" + title="No entity selected" + description="Choose one entity from the list to review type, provenance memories, and timestamps." + > + <EmptyState + title="Entity inspector is idle" + description="Select one tracked entity to open the bounded review detail panel." + /> + </SectionCard> + ); + } + + return ( + <SectionCard + eyebrow="Selected entity" + title={entity.name} + description="Entity detail keeps identity, source-memory context, and timestamps explicit before edge review." + > + <div className="detail-grid"> + <div className="detail-summary"> + <StatusBadge status={entity.entity_type} /> + <StatusBadge + status={source ?? "unavailable"} + label={ + source === "live" + ? "Live detail" + : source === "fixture" + ? "Fixture detail" + : "Detail unavailable" + } + /> + </div> + + {unavailableReason ? ( + <div className="execution-summary__note execution-summary__note--danger"> + <p className="execution-summary__label">Detail read</p> + <p>{unavailableReason}</p> + </div> + ) : null} + + <dl className="key-value-grid key-value-grid--compact"> + <div> + <dt>Entity ID</dt> + <dd className="mono">{entity.id}</dd> + </div> + <div> + <dt>Created</dt> + <dd>{formatDate(entity.created_at)}</dd> + </div> + <div> + <dt>Entity type</dt> + <dd>{entity.entity_type}</dd> + </div> + <div> + <dt>Source memories</dt> + <dd>{entity.source_memory_ids.length}</dd> + </div> + </dl> + + <div className="detail-group detail-group--muted"> + <h3>Source memory references</h3> + {entity.source_memory_ids.length === 0 ? ( + <p className="muted-copy">No source-memory references were returned for this entity.</p> + ) : ( + <div className="attribute-list"> + {entity.source_memory_ids.map((memoryId) => ( + <span key={memoryId} className="attribute-item mono"> + {memoryId} + </span> + ))} + </div> + )} + </div> + </div> + </SectionCard> + ); +} diff --git a/apps/web/components/entity-edge-list.test.tsx b/apps/web/components/entity-edge-list.test.tsx new file mode 100644 index 0000000..40abdb1 --- /dev/null +++ b/apps/web/components/entity-edge-list.test.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import { EntityEdgeList } from "./entity-edge-list"; + +describe("EntityEdgeList", () => { + afterEach(() => { + cleanup(); + }); + + it("renders idle state when no entity is selected", () => { + render( + <EntityEdgeList + entityId={null} + edges={[]} + summary={null} + source={null} + />, + ); + + expect(screen.getByText("Edge review is idle")).toBeInTheDocument(); + }); + + it("renders ordered edge rows with direction and source memories", () => { + render( + <EntityEdgeList + entityId="entity-1" + edges={[ + { + id: "edge-1", + from_entity_id: "entity-1", + to_entity_id: "entity-2", + relationship_type: "prefers_merchant", + valid_from: "2026-03-18T00:00:00Z", + valid_to: null, + source_memory_ids: ["memory-1", "memory-2"], + created_at: "2026-03-18T00:01:00Z", + }, + ]} + summary={{ + entity_id: "entity-1", + total_count: 1, + order: ["created_at_asc", "id_asc"], + }} + source="fixture" + />, + ); + + expect(screen.getByText("prefers_merchant")).toBeInTheDocument(); + expect(screen.getByText("entity-1 to entity-2")).toBeInTheDocument(); + expect(screen.getByText("memory-1")).toBeInTheDocument(); + expect(screen.getByText("memory-2")).toBeInTheDocument(); + expect(screen.getByText("Fixture edges")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/entity-edge-list.tsx b/apps/web/components/entity-edge-list.tsx new file mode 100644 index 0000000..0e368ab --- /dev/null +++ b/apps/web/components/entity-edge-list.tsx @@ -0,0 +1,153 @@ +import type { ApiSource, EntityEdgeListSummary, EntityEdgeRecord } from "../lib/api"; +import { EmptyState } from "./empty-state"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +type EntityEdgeListProps = { + entityId: string | null; + edges: EntityEdgeRecord[]; + summary: EntityEdgeListSummary | null; + source: ApiSource | "unavailable" | null; + unavailableReason?: string; +}; + +function formatDate(value: string) { + return new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(value)); +} + +function formatRange(validFrom: string | null, validTo: string | null) { + if (!validFrom && !validTo) { + return "Open range"; + } + + const from = validFrom ? formatDate(validFrom) : "Unbounded start"; + const to = validTo ? formatDate(validTo) : "Open-ended"; + return `${from} to ${to}`; +} + +export function EntityEdgeList({ + entityId, + edges, + summary, + source, + unavailableReason, +}: EntityEdgeListProps) { + if (!entityId) { + return ( + <SectionCard + eyebrow="Related edges" + title="No entity selected" + description="Select one entity to inspect ordered relationship edges and source-memory context." + > + <EmptyState + title="Edge review is idle" + description="Choose one tracked entity from the list to open relationship review." + /> + </SectionCard> + ); + } + + if (source === "unavailable") { + return ( + <SectionCard + eyebrow="Related edges" + title="Edge review unavailable" + description="The selected entity loaded, but related edges could not be read from live or fixture sources." + > + <div className="detail-stack"> + <StatusBadge status="unavailable" label="Edges unavailable" /> + {unavailableReason ? ( + <div className="execution-summary__note execution-summary__note--danger"> + <p className="execution-summary__label">Edge read</p> + <p>{unavailableReason}</p> + </div> + ) : null} + </div> + </SectionCard> + ); + } + + if (edges.length === 0) { + return ( + <SectionCard + eyebrow="Related edges" + title="No related edges" + description="No relationship edges are currently linked to the selected entity." + > + <EmptyState + title="Edge list is empty" + description="Relationship records will appear here once entity edges are persisted." + /> + </SectionCard> + ); + } + + return ( + <SectionCard + eyebrow="Related edges" + title="Ordered relationship review" + description="Review each edge in order with explicit direction, validity window, and source-memory references." + > + <div className="list-panel"> + <div className="list-panel__header"> + <div className="cluster"> + <StatusBadge + status={source ?? "unavailable"} + label={ + source === "live" + ? "Live edges" + : source === "fixture" + ? "Fixture edges" + : "Edges unavailable" + } + /> + {summary ? <span className="meta-pill">{summary.total_count} total</span> : null} + </div> + </div> + + {unavailableReason ? <p className="responsive-note">Live edge read failed: {unavailableReason}</p> : null} + + <div className="list-rows"> + {edges.map((edge) => ( + <article key={edge.id} className="list-row" aria-label={`${edge.relationship_type} edge`}> + <div className="list-row__topline"> + <h3 className="list-row__title mono">{edge.relationship_type}</h3> + <StatusBadge status="info" label="Edge" /> + </div> + + <p className="mono"> + {edge.from_entity_id} to {edge.to_entity_id} + </p> + + <div className="list-row__meta"> + <span className="meta-pill">Created {formatDate(edge.created_at)}</span> + <span className="meta-pill">{formatRange(edge.valid_from, edge.valid_to)}</span> + <span className="meta-pill mono">{edge.id}</span> + </div> + + <div className="detail-group detail-group--muted"> + <h3>Source memories</h3> + {edge.source_memory_ids.length === 0 ? ( + <p className="muted-copy">No source-memory references were returned for this edge.</p> + ) : ( + <div className="attribute-list"> + {edge.source_memory_ids.map((memoryId) => ( + <span key={memoryId} className="attribute-item mono"> + {memoryId} + </span> + ))} + </div> + )} + </div> + </article> + ))} + </div> + </div> + </SectionCard> + ); +} diff --git a/apps/web/components/entity-list.test.tsx b/apps/web/components/entity-list.test.tsx new file mode 100644 index 0000000..4a98b28 --- /dev/null +++ b/apps/web/components/entity-list.test.tsx @@ -0,0 +1,76 @@ +import React from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { EntityList } from "./entity-list"; + +vi.mock("next/link", () => ({ + default: ({ + href, + children, + className, + "aria-current": ariaCurrent, + }: { + href: string; + children: React.ReactNode; + className?: string; + "aria-current"?: string; + }) => ( + <a href={href} className={className} aria-current={ariaCurrent}> + {children} + </a> + ), +})); + +const baseEntities = [ + { + id: "entity-1", + entity_type: "person" as const, + name: "Alice", + source_memory_ids: ["memory-1"], + created_at: "2026-03-18T10:00:00Z", + }, + { + id: "entity-2", + entity_type: "merchant" as const, + name: "Thorne", + source_memory_ids: ["memory-2"], + created_at: "2026-03-18T11:00:00Z", + }, +]; + +describe("EntityList", () => { + afterEach(() => { + cleanup(); + }); + + it("renders entity links that preserve selected entity state", () => { + render( + <EntityList + entities={baseEntities} + selectedEntityId="entity-2" + summary={null} + source="live" + />, + ); + + expect(screen.getByRole("link", { name: /alice/i })).toHaveAttribute( + "href", + "/entities?entity=entity-1", + ); + expect(screen.getByRole("link", { name: /thorne/i })).toHaveAttribute( + "href", + "/entities?entity=entity-2", + ); + expect(screen.getByRole("link", { name: /thorne/i })).toHaveAttribute( + "aria-current", + "page", + ); + }); + + it("renders empty state when entity list is empty", () => { + render(<EntityList entities={[]} selectedEntityId="" summary={null} source="fixture" />); + + expect(screen.getByText("No tracked entities")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/entity-list.tsx b/apps/web/components/entity-list.tsx new file mode 100644 index 0000000..a0fbb10 --- /dev/null +++ b/apps/web/components/entity-list.tsx @@ -0,0 +1,102 @@ +import Link from "next/link"; + +import type { ApiSource, EntityListSummary, EntityRecord } from "../lib/api"; +import { EmptyState } from "./empty-state"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +type EntityListProps = { + entities: EntityRecord[]; + selectedEntityId?: string; + summary: EntityListSummary | null; + source: ApiSource | "unavailable"; + unavailableReason?: string; +}; + +function formatDate(value: string) { + return new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(value)); +} + +function entityHref(entityId: string) { + return `/entities?entity=${encodeURIComponent(entityId)}`; +} + +export function EntityList({ + entities, + selectedEntityId, + summary, + source, + unavailableReason, +}: EntityListProps) { + if (entities.length === 0) { + return ( + <SectionCard + eyebrow="Entity list" + title="No entities available" + description="The bounded entity list is currently empty for this operator workspace." + > + <EmptyState + title="No tracked entities" + description="Entities will appear here once entity records are available for review." + /> + </SectionCard> + ); + } + + return ( + <SectionCard + eyebrow="Entity list" + title="Tracked entities" + description="Select one entity to inspect full provenance and related edges without leaving this route." + > + <div className="list-panel"> + <div className="list-panel__header"> + <div className="cluster"> + <StatusBadge + status={source} + label={ + source === "live" + ? "Live list" + : source === "fixture" + ? "Fixture list" + : "List unavailable" + } + /> + {summary ? <span className="meta-pill">{summary.total_count} total</span> : null} + </div> + </div> + + {unavailableReason ? <p className="responsive-note">Live list read failed: {unavailableReason}</p> : null} + + <div className="list-rows"> + {entities.map((entity) => ( + <Link + key={entity.id} + href={entityHref(entity.id)} + className={`list-row${entity.id === selectedEntityId ? " is-selected" : ""}`} + aria-current={entity.id === selectedEntityId ? "page" : undefined} + > + <div className="list-row__topline"> + <div className="detail-stack"> + <span className="list-row__eyebrow">{formatDate(entity.created_at)}</span> + <h3 className="list-row__title">{entity.name}</h3> + </div> + <StatusBadge status={entity.entity_type} /> + </div> + + <div className="list-row__meta"> + <span className="meta-pill mono">{entity.id}</span> + <span className="meta-pill">{entity.source_memory_ids.length} source memories</span> + </div> + </Link> + ))} + </div> + </div> + </SectionCard> + ); +} diff --git a/apps/web/components/execution-summary.test.tsx b/apps/web/components/execution-summary.test.tsx new file mode 100644 index 0000000..8072559 --- /dev/null +++ b/apps/web/components/execution-summary.test.tsx @@ -0,0 +1,166 @@ +import React from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import { ExecutionSummary } from "./execution-summary"; + +const execution = { + id: "execution-1", + approval_id: "approval-1", + task_step_id: "step-1", + thread_id: "thread-1", + tool_id: "tool-1", + trace_id: "trace-1", + request_event_id: "event-1", + result_event_id: "event-2", + status: "completed", + handler_key: "proxy.echo", + request: { + thread_id: "thread-1", + tool_id: "tool-1", + action: "place_order", + scope: "supplements", + domain_hint: "ecommerce", + risk_hint: "purchase", + attributes: { + item: "Magnesium", + }, + }, + tool: { + id: "tool-1", + tool_key: "proxy.echo", + name: "Merchant Proxy", + description: "Proxy", + version: "0.1.0", + metadata_version: "tool_metadata_v0", + active: true, + tags: [], + action_hints: [], + scope_hints: [], + domain_hints: [], + risk_hints: [], + metadata: {}, + created_at: "2026-03-17T00:00:00Z", + }, + result: { + handler_key: "proxy.echo", + status: "completed", + output: { + ok: true, + mode: "no_side_effect", + }, + reason: null, + }, + executed_at: "2026-03-17T00:10:00Z", +}; + +const executionPreview = { + request: { + approval_id: "approval-1", + task_step_id: "step-1", + }, + approval: { + id: "approval-1", + thread_id: "thread-1", + task_step_id: "step-1", + status: "approved", + request: execution.request, + tool: execution.tool, + routing: { + decision: "require_approval", + reasons: [], + trace: { + trace_id: "trace-route-1", + trace_event_count: 3, + }, + }, + created_at: "2026-03-17T00:00:00Z", + resolution: { + resolved_at: "2026-03-17T00:05:00Z", + resolved_by_user_id: "user-1", + }, + }, + tool: execution.tool, + result: execution.result, + events: { + request_event_id: "event-1", + request_sequence_no: 1, + result_event_id: "event-2", + result_sequence_no: 2, + }, + trace: { + trace_id: "trace-1", + trace_event_count: 9, + }, +}; + +describe("ExecutionSummary", () => { + afterEach(() => { + cleanup(); + }); + + it("renders the empty execution state", () => { + render( + <ExecutionSummary + execution={null} + emptyTitle="Task has not executed yet" + emptyDescription="Execution detail will appear here once the request runs." + />, + ); + + expect(screen.getByText("Task has not executed yet")).toBeInTheDocument(); + expect(screen.getByText(/Execution detail will appear here/i)).toBeInTheDocument(); + expect(screen.getByText("Not executed")).toBeInTheDocument(); + }); + + it("renders a persisted execution record", () => { + render( + <ExecutionSummary + execution={execution} + source="live" + emptyTitle="unused" + emptyDescription="unused" + />, + ); + + expect(screen.getByText("Execution record in review")).toBeInTheDocument(); + expect(screen.getByText("Live execution detail")).toBeInTheDocument(); + expect(screen.getByText("Merchant Proxy")).toBeInTheDocument(); + expect(screen.getByText("Request event")).toBeInTheDocument(); + expect(screen.getByText("Result event")).toBeInTheDocument(); + expect(screen.getByText("event-1")).toBeInTheDocument(); + expect(screen.getByText("event-2")).toBeInTheDocument(); + expect(screen.getByText(/"mode": "no_side_effect"/i)).toBeInTheDocument(); + }); + + it("renders an unavailable message instead of implying no execution", () => { + render( + <ExecutionSummary + execution={null} + unavailableMessage="The execution API did not return a usable record." + emptyTitle="unused" + emptyDescription="unused" + />, + ); + + expect(screen.getByText("Execution review could not be loaded")).toBeInTheDocument(); + expect(screen.getByText(/did not return a usable record/i)).toBeInTheDocument(); + }); + + it("prefers a fresh execute preview over a stale unavailable message", () => { + render( + <ExecutionSummary + execution={null} + preview={executionPreview} + unavailableMessage="The execution API did not return a usable record." + emptyTitle="unused" + emptyDescription="unused" + />, + ); + + expect(screen.getByText("Latest execution result")).toBeInTheDocument(); + expect(screen.getByText("Latest execute response")).toBeInTheDocument(); + expect(screen.getByText("Result event")).toBeInTheDocument(); + expect(screen.queryByText("Execution review could not be loaded")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/execution-summary.tsx b/apps/web/components/execution-summary.tsx new file mode 100644 index 0000000..05013e7 --- /dev/null +++ b/apps/web/components/execution-summary.tsx @@ -0,0 +1,163 @@ +import type { ApiSource, ApprovalExecutionResponse, ToolExecutionItem } from "../lib/api"; +import { StatusBadge } from "./status-badge"; + +type ExecutionSummaryProps = { + execution: ToolExecutionItem | null; + preview?: ApprovalExecutionResponse | null; + source?: ApiSource | null; + unavailableMessage?: string | null; + emptyTitle: string; + emptyDescription: string; +}; + +function formatDate(value: string) { + return new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(value)); +} + +function formatJson(value: unknown) { + return JSON.stringify(value, null, 2); +} + +export function ExecutionSummary({ + execution, + preview, + source, + unavailableMessage, + emptyTitle, + emptyDescription, +}: ExecutionSummaryProps) { + if (!execution && !preview) { + if (unavailableMessage) { + return ( + <div className="execution-summary execution-summary--unavailable"> + <div className="execution-summary__topline"> + <div className="detail-stack"> + <StatusBadge status="warning" label="Execution unavailable" /> + <h4 className="execution-summary__title">Execution review could not be loaded</h4> + </div> + </div> + <p className="muted-copy">{unavailableMessage}</p> + </div> + ); + } + + return ( + <div className="execution-summary execution-summary--empty"> + <div className="execution-summary__topline"> + <div className="detail-stack"> + <StatusBadge status="pending" label="Not executed" /> + <h4 className="execution-summary__title">{emptyTitle}</h4> + </div> + </div> + <p className="muted-copy">{emptyDescription}</p> + </div> + ); + } + + const result = execution?.result ?? preview?.result ?? null; + const tool = execution?.tool ?? preview?.tool ?? null; + const traceId = execution?.trace_id ?? preview?.trace.trace_id ?? null; + const requestEventId = execution?.request_event_id ?? preview?.events?.request_event_id ?? null; + const resultEventId = execution?.result_event_id ?? preview?.events?.result_event_id ?? null; + const taskRunId = execution?.task_run_id ?? preview?.request.task_run_id ?? null; + const idempotencyKey = execution?.idempotency_key ?? null; + const executedAt = execution?.executed_at ?? null; + const reviewSource = execution ? (source === "live" ? "Live execution detail" : "Fixture execution detail") : "Latest execute response"; + + return ( + <div className="execution-summary"> + <div className="execution-summary__topline"> + <div className="detail-stack"> + <StatusBadge status={execution?.status ?? result?.status ?? "info"} /> + <h4 className="execution-summary__title"> + {execution ? "Execution record in review" : "Latest execution result"} + </h4> + </div> + <span className="meta-pill">{reviewSource}</span> + </div> + + <div className="key-value-grid key-value-grid--compact"> + {execution ? ( + <div> + <dt>Execution</dt> + <dd className="mono">{execution.id}</dd> + </div> + ) : null} + <div> + <dt>Handler</dt> + <dd>{result?.handler_key ?? "Unavailable"}</dd> + </div> + <div> + <dt>Status</dt> + <dd>{result?.status ?? execution?.status ?? "Unknown"}</dd> + </div> + <div> + <dt>Executed</dt> + <dd>{executedAt ? formatDate(executedAt) : "Just returned from execute"}</dd> + </div> + {requestEventId ? ( + <div> + <dt>Request event</dt> + <dd className="mono">{requestEventId}</dd> + </div> + ) : null} + {resultEventId ? ( + <div> + <dt>Result event</dt> + <dd className="mono">{resultEventId}</dd> + </div> + ) : null} + {traceId ? ( + <div> + <dt>Trace</dt> + <dd className="mono">{traceId}</dd> + </div> + ) : null} + {taskRunId ? ( + <div> + <dt>Task run</dt> + <dd className="mono">{taskRunId}</dd> + </div> + ) : null} + {idempotencyKey ? ( + <div> + <dt>Idempotency</dt> + <dd className="mono">{idempotencyKey}</dd> + </div> + ) : null} + {tool ? ( + <div> + <dt>Tool</dt> + <dd>{tool.name}</dd> + </div> + ) : null} + </div> + + {result?.reason ? ( + <div className="execution-summary__note execution-summary__note--danger"> + <p className="execution-summary__label">Execution reason</p> + <p>{result.reason}</p> + </div> + ) : null} + + {result?.budget_decision ? ( + <div className="execution-summary__note"> + <p className="execution-summary__label">Budget decision</p> + <pre className="execution-summary__code">{formatJson(result.budget_decision)}</pre> + </div> + ) : null} + + {result?.output ? ( + <div className="execution-summary__note"> + <p className="execution-summary__label">Output snapshot</p> + <pre className="execution-summary__code">{formatJson(result.output)}</pre> + </div> + ) : null} + </div> + ); +} diff --git a/apps/web/components/gmail-account-connect-form.tsx b/apps/web/components/gmail-account-connect-form.tsx new file mode 100644 index 0000000..88eab2b --- /dev/null +++ b/apps/web/components/gmail-account-connect-form.tsx @@ -0,0 +1,288 @@ +"use client"; + +import type { FormEvent } from "react"; +import { useState } from "react"; + +import { useRouter } from "next/navigation"; + +import { connectGmailAccount } from "../lib/api"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +type GmailAccountConnectFormProps = { + apiBaseUrl?: string; + userId?: string; +}; + +const GMAIL_READONLY_SCOPE = "https://www.googleapis.com/auth/gmail.readonly" as const; + +export function GmailAccountConnectForm({ apiBaseUrl, userId }: GmailAccountConnectFormProps) { + const router = useRouter(); + const liveModeReady = Boolean(apiBaseUrl && userId); + + const [providerAccountId, setProviderAccountId] = useState(""); + const [emailAddress, setEmailAddress] = useState(""); + const [displayName, setDisplayName] = useState(""); + const [accessToken, setAccessToken] = useState(""); + const [refreshToken, setRefreshToken] = useState(""); + const [clientId, setClientId] = useState(""); + const [clientSecret, setClientSecret] = useState(""); + const [accessTokenExpiresAt, setAccessTokenExpiresAt] = useState(""); + + const [isSubmitting, setIsSubmitting] = useState(false); + const [statusTone, setStatusTone] = useState<"info" | "success" | "danger">("info"); + const [statusText, setStatusText] = useState( + liveModeReady + ? "Enter one account at a time, including secret-bearing credentials, then connect." + : "Gmail connect is unavailable until live API configuration is present.", + ); + + const canSubmit = liveModeReady && !isSubmitting; + + async function handleSubmit(event: FormEvent<HTMLFormElement>) { + event.preventDefault(); + + if (!apiBaseUrl || !userId) { + setStatusTone("info"); + setStatusText("Gmail connect is unavailable until live API configuration is present."); + return; + } + + const normalizedRefreshToken = refreshToken.trim(); + const normalizedClientId = clientId.trim(); + const normalizedClientSecret = clientSecret.trim(); + const normalizedExpiresAt = accessTokenExpiresAt.trim(); + const refreshFields = [ + normalizedRefreshToken, + normalizedClientId, + normalizedClientSecret, + normalizedExpiresAt, + ]; + const hasAnyRefreshField = refreshFields.some(Boolean); + const hasFullRefreshBundle = refreshFields.every(Boolean); + + if (hasAnyRefreshField && !hasFullRefreshBundle) { + setStatusTone("danger"); + setStatusText( + "Refresh credentials must include refresh token, client id, client secret, and access-token expiry.", + ); + return; + } + + const expiresTimestamp = hasFullRefreshBundle ? Date.parse(normalizedExpiresAt) : null; + if (hasFullRefreshBundle && Number.isNaN(expiresTimestamp)) { + setStatusTone("danger"); + setStatusText("Access-token expiry must be a valid date and time."); + return; + } + const normalizedExpiresAtIso = + hasFullRefreshBundle && expiresTimestamp !== null + ? new Date(expiresTimestamp).toISOString() + : null; + + setIsSubmitting(true); + setStatusTone("info"); + setStatusText("Connecting Gmail account..."); + + try { + const payload = await connectGmailAccount(apiBaseUrl, { + user_id: userId, + provider_account_id: providerAccountId.trim(), + email_address: emailAddress.trim(), + display_name: displayName.trim() ? displayName.trim() : null, + scope: GMAIL_READONLY_SCOPE, + access_token: accessToken.trim(), + refresh_token: hasFullRefreshBundle ? normalizedRefreshToken : null, + client_id: hasFullRefreshBundle ? normalizedClientId : null, + client_secret: hasFullRefreshBundle ? normalizedClientSecret : null, + access_token_expires_at: hasFullRefreshBundle ? normalizedExpiresAtIso : null, + }); + + setStatusTone("success"); + setStatusText(`Connected ${payload.account.email_address}.`); + setAccessToken(""); + setRefreshToken(""); + setClientSecret(""); + setAccessTokenExpiresAt(""); + router.push(`/gmail?account=${encodeURIComponent(payload.account.id)}`); + router.refresh(); + } catch (error) { + const detail = error instanceof Error ? error.message : "Connection failed"; + setStatusTone("danger"); + setStatusText(`Unable to connect account: ${detail}`); + } finally { + setIsSubmitting(false); + } + } + + return ( + <SectionCard + eyebrow="Connect account" + title="Add Gmail account" + description="Connection is explicit and bounded to the shipped read-only scope with secret-bearing credential fields." + > + <form className="detail-stack" onSubmit={handleSubmit}> + <div className="form-field-group form-field-group--two-up"> + <div className="form-field"> + <label htmlFor="gmail-provider-account-id">Provider account ID</label> + <input + id="gmail-provider-account-id" + name="gmail-provider-account-id" + value={providerAccountId} + onChange={(event) => setProviderAccountId(event.target.value)} + placeholder="acct-owner-001" + required + disabled={!liveModeReady || isSubmitting} + /> + </div> + <div className="form-field"> + <label htmlFor="gmail-email-address">Email address</label> + <input + id="gmail-email-address" + name="gmail-email-address" + value={emailAddress} + onChange={(event) => setEmailAddress(event.target.value)} + placeholder="owner@gmail.example" + required + disabled={!liveModeReady || isSubmitting} + /> + </div> + </div> + + <div className="form-field-group form-field-group--two-up"> + <div className="form-field"> + <label htmlFor="gmail-display-name">Display name (optional)</label> + <input + id="gmail-display-name" + name="gmail-display-name" + value={displayName} + onChange={(event) => setDisplayName(event.target.value)} + placeholder="Owner" + disabled={!liveModeReady || isSubmitting} + /> + </div> + <div className="form-field"> + <label htmlFor="gmail-scope">Scope</label> + <input + id="gmail-scope" + name="gmail-scope" + value={GMAIL_READONLY_SCOPE} + readOnly + className="mono" + disabled + /> + </div> + </div> + + <div className="governance-banner"> + <strong>Credential handling</strong> + <span> + Secret-bearing fields are submitted only through the shipped connect endpoint and are not + surfaced in account metadata reads. + </span> + </div> + + <div className="form-field"> + <label htmlFor="gmail-access-token">Access token</label> + <input + id="gmail-access-token" + name="gmail-access-token" + type="password" + value={accessToken} + onChange={(event) => setAccessToken(event.target.value)} + placeholder="Enter Gmail OAuth access token" + autoComplete="off" + required + disabled={!liveModeReady || isSubmitting} + /> + </div> + + <div className="form-field-group form-field-group--two-up"> + <div className="form-field"> + <label htmlFor="gmail-refresh-token">Refresh token (optional bundle)</label> + <input + id="gmail-refresh-token" + name="gmail-refresh-token" + type="password" + value={refreshToken} + onChange={(event) => setRefreshToken(event.target.value)} + placeholder="Required only when supplying full refresh bundle" + autoComplete="off" + disabled={!liveModeReady || isSubmitting} + /> + </div> + <div className="form-field"> + <label htmlFor="gmail-access-token-expires-at">Access token expires at</label> + <input + id="gmail-access-token-expires-at" + name="gmail-access-token-expires-at" + type="datetime-local" + value={accessTokenExpiresAt} + onChange={(event) => setAccessTokenExpiresAt(event.target.value)} + disabled={!liveModeReady || isSubmitting} + /> + </div> + </div> + + <div className="form-field-group form-field-group--two-up"> + <div className="form-field"> + <label htmlFor="gmail-client-id">Client ID (optional bundle)</label> + <input + id="gmail-client-id" + name="gmail-client-id" + value={clientId} + onChange={(event) => setClientId(event.target.value)} + autoComplete="off" + disabled={!liveModeReady || isSubmitting} + /> + </div> + <div className="form-field"> + <label htmlFor="gmail-client-secret">Client secret (optional bundle)</label> + <input + id="gmail-client-secret" + name="gmail-client-secret" + type="password" + value={clientSecret} + onChange={(event) => setClientSecret(event.target.value)} + autoComplete="off" + disabled={!liveModeReady || isSubmitting} + /> + </div> + </div> + + <div className="composer-actions"> + <div className="composer-status" aria-live="polite"> + <StatusBadge + status={ + isSubmitting + ? "submitting" + : statusTone === "success" + ? "success" + : statusTone === "danger" + ? "error" + : liveModeReady + ? "info" + : "unavailable" + } + label={ + isSubmitting + ? "Connecting" + : statusTone === "success" + ? "Connected" + : statusTone === "danger" + ? "Attention" + : liveModeReady + ? "Ready" + : "Unavailable" + } + /> + <span>{statusText}</span> + </div> + <button type="submit" className="button" disabled={!canSubmit}> + {isSubmitting ? "Connecting..." : "Connect Gmail account"} + </button> + </div> + </form> + </SectionCard> + ); +} diff --git a/apps/web/components/gmail-account-detail.tsx b/apps/web/components/gmail-account-detail.tsx new file mode 100644 index 0000000..ec9d4d7 --- /dev/null +++ b/apps/web/components/gmail-account-detail.tsx @@ -0,0 +1,124 @@ +import type { ApiSource, GmailAccountRecord } from "../lib/api"; +import { EmptyState } from "./empty-state"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +type GmailAccountDetailProps = { + account: GmailAccountRecord | null; + source: ApiSource | "unavailable" | null; + unavailableReason?: string; +}; + +function formatDate(value: string) { + return new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(value)); +} + +export function GmailAccountDetail({ + account, + source, + unavailableReason, +}: GmailAccountDetailProps) { + if (source === "unavailable") { + return ( + <SectionCard + eyebrow="Selected account" + title="Account detail unavailable" + description="The account list loaded, but selected account detail is currently unavailable." + > + <div className="detail-stack"> + <StatusBadge status="unavailable" label="Detail unavailable" /> + {unavailableReason ? ( + <div className="execution-summary__note execution-summary__note--danger"> + <p className="execution-summary__label">Account detail read</p> + <p>{unavailableReason}</p> + </div> + ) : null} + </div> + </SectionCard> + ); + } + + if (!account) { + return ( + <SectionCard + eyebrow="Selected account" + title="No account selected" + description="Select one connected Gmail account to inspect metadata and scope summary." + > + <EmptyState + title="Account detail is idle" + description="Choose one account from the list to open the bounded detail panel." + /> + </SectionCard> + ); + } + + return ( + <SectionCard + eyebrow="Selected account" + title={account.email_address} + description="Account detail stays bounded to connector metadata and scope without expanding into mailbox views." + > + <div className="detail-grid"> + <div className="detail-summary"> + <StatusBadge status={account.provider} /> + <StatusBadge status={account.auth_kind} /> + <StatusBadge + status={source ?? "unavailable"} + label={ + source === "live" + ? "Live detail" + : source === "fixture" + ? "Fixture detail" + : "Detail unavailable" + } + /> + </div> + + {unavailableReason ? ( + <p className="responsive-note">Live account detail read failed: {unavailableReason}</p> + ) : null} + + <dl className="key-value-grid key-value-grid--compact"> + <div> + <dt>Account ID</dt> + <dd className="mono">{account.id}</dd> + </div> + <div> + <dt>Provider account ID</dt> + <dd className="mono">{account.provider_account_id}</dd> + </div> + <div> + <dt>Email address</dt> + <dd>{account.email_address}</dd> + </div> + <div> + <dt>Display name</dt> + <dd>{account.display_name ?? "None"}</dd> + </div> + <div> + <dt>Scope</dt> + <dd className="mono">{account.scope}</dd> + </div> + <div> + <dt>Updated</dt> + <dd>{formatDate(account.updated_at)}</dd> + </div> + </dl> + + <div className="detail-group detail-group--muted"> + <h3>Scope summary</h3> + <p className="muted-copy"> + This route uses the shipped read-only Gmail account seam and explicit single-message ingestion + seam only. It does not expand into mailbox search, sync, or write actions. + </p> + </div> + </div> + </SectionCard> + ); +} diff --git a/apps/web/components/gmail-account-list.test.tsx b/apps/web/components/gmail-account-list.test.tsx new file mode 100644 index 0000000..8836aa9 --- /dev/null +++ b/apps/web/components/gmail-account-list.test.tsx @@ -0,0 +1,84 @@ +import React from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { GmailAccountList } from "./gmail-account-list"; + +vi.mock("next/link", () => ({ + default: ({ + href, + children, + className, + "aria-current": ariaCurrent, + }: { + href: string; + children: React.ReactNode; + className?: string; + "aria-current"?: string; + }) => ( + <a href={href} className={className} aria-current={ariaCurrent}> + {children} + </a> + ), +})); + +const baseAccounts = [ + { + id: "gmail-account-1", + provider: "gmail", + auth_kind: "oauth_access_token", + provider_account_id: "acct-owner-001", + email_address: "owner@gmail.example", + display_name: "Owner", + scope: "https://www.googleapis.com/auth/gmail.readonly" as const, + created_at: "2026-03-18T10:00:00Z", + updated_at: "2026-03-18T10:00:00Z", + }, + { + id: "gmail-account-2", + provider: "gmail", + auth_kind: "oauth_access_token", + provider_account_id: "acct-ops-002", + email_address: "ops@gmail.example", + display_name: "Ops", + scope: "https://www.googleapis.com/auth/gmail.readonly" as const, + created_at: "2026-03-18T11:00:00Z", + updated_at: "2026-03-18T11:00:00Z", + }, +]; + +describe("GmailAccountList", () => { + afterEach(() => { + cleanup(); + }); + + it("renders account links that preserve selected account state", () => { + render( + <GmailAccountList + accounts={baseAccounts} + selectedAccountId="gmail-account-2" + summary={null} + source="live" + />, + ); + + expect(screen.getByRole("link", { name: /owner@gmail.example/i })).toHaveAttribute( + "href", + "/gmail?account=gmail-account-1", + ); + expect(screen.getByRole("link", { name: /ops@gmail.example/i })).toHaveAttribute( + "href", + "/gmail?account=gmail-account-2", + ); + expect(screen.getByRole("link", { name: /ops@gmail.example/i })).toHaveAttribute( + "aria-current", + "page", + ); + }); + + it("renders empty state when no Gmail accounts are available", () => { + render(<GmailAccountList accounts={[]} selectedAccountId="" summary={null} source="fixture" />); + + expect(screen.getByText("No connected accounts")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/gmail-account-list.tsx b/apps/web/components/gmail-account-list.tsx new file mode 100644 index 0000000..da1e3a0 --- /dev/null +++ b/apps/web/components/gmail-account-list.tsx @@ -0,0 +1,122 @@ +import Link from "next/link"; + +import type { ApiSource, GmailAccountListSummary, GmailAccountRecord } from "../lib/api"; +import { EmptyState } from "./empty-state"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +type GmailAccountListProps = { + accounts: GmailAccountRecord[]; + selectedAccountId?: string; + summary: GmailAccountListSummary | null; + source: ApiSource | "unavailable"; + unavailableReason?: string; +}; + +function formatDate(value: string) { + return new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(value)); +} + +function gmailAccountHref(gmailAccountId: string) { + return `/gmail?account=${encodeURIComponent(gmailAccountId)}`; +} + +export function GmailAccountList({ + accounts, + selectedAccountId, + summary, + source, + unavailableReason, +}: GmailAccountListProps) { + if (source === "unavailable" && accounts.length === 0) { + return ( + <SectionCard + eyebrow="Account list" + title="Gmail account list unavailable" + description="Connected Gmail accounts could not be loaded in the current workspace state." + > + <div className="detail-stack"> + <StatusBadge status="unavailable" label="List unavailable" /> + {unavailableReason ? ( + <p className="responsive-note">Gmail account list read failed: {unavailableReason}</p> + ) : null} + </div> + </SectionCard> + ); + } + + if (accounts.length === 0) { + return ( + <SectionCard + eyebrow="Account list" + title="No Gmail accounts connected" + description="Connect one Gmail account to enable bounded account review and selected-message ingestion." + > + <EmptyState + title="No connected accounts" + description="Use the connect form to add one Gmail account through the shipped read-only connector seam." + /> + </SectionCard> + ); + } + + return ( + <SectionCard + eyebrow="Account list" + title="Connected Gmail accounts" + description="Select one account to inspect metadata, scope, and bounded ingestion controls." + > + <div className="list-panel"> + <div className="list-panel__header"> + <div className="cluster"> + <StatusBadge + status={source} + label={ + source === "live" + ? "Live list" + : source === "fixture" + ? "Fixture list" + : "List unavailable" + } + /> + {summary ? <span className="meta-pill">{summary.total_count} total</span> : null} + </div> + </div> + + {unavailableReason ? ( + <p className="responsive-note">Live account list read failed: {unavailableReason}</p> + ) : null} + + <div className="list-rows"> + {accounts.map((account) => ( + <Link + key={account.id} + href={gmailAccountHref(account.id)} + className={`list-row${account.id === selectedAccountId ? " is-selected" : ""}`} + aria-current={account.id === selectedAccountId ? "page" : undefined} + > + <div className="list-row__topline"> + <div className="detail-stack"> + <span className="list-row__eyebrow">{formatDate(account.updated_at)}</span> + <h3 className="list-row__title">{account.email_address}</h3> + </div> + <StatusBadge status={account.provider} /> + </div> + + <div className="list-row__meta"> + <span className="meta-pill mono">{account.id}</span> + <span className="meta-pill mono">{account.provider_account_id}</span> + <span className="meta-pill">{account.display_name ?? "No display name"}</span> + </div> + </Link> + ))} + </div> + </div> + </SectionCard> + ); +} diff --git a/apps/web/components/gmail-ingestion-summary.tsx b/apps/web/components/gmail-ingestion-summary.tsx new file mode 100644 index 0000000..a551350 --- /dev/null +++ b/apps/web/components/gmail-ingestion-summary.tsx @@ -0,0 +1,102 @@ +import type { ApiSource, GmailMessageIngestionResponse } from "../lib/api"; +import { EmptyState } from "./empty-state"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +type GmailIngestionSummaryProps = { + result: GmailMessageIngestionResponse | null; + source: ApiSource | "unavailable" | null; + unavailableReason?: string; +}; + +export function GmailIngestionSummary({ + result, + source, + unavailableReason, +}: GmailIngestionSummaryProps) { + if (source === "unavailable") { + return ( + <SectionCard + eyebrow="Ingestion summary" + title="Latest ingestion unavailable" + description="No ingestion result is available because the latest request failed." + > + <div className="detail-stack"> + <StatusBadge status="unavailable" label="Result unavailable" /> + {unavailableReason ? ( + <div className="execution-summary__note execution-summary__note--danger"> + <p className="execution-summary__label">Ingestion result</p> + <p>{unavailableReason}</p> + </div> + ) : null} + </div> + </SectionCard> + ); + } + + if (!result) { + return ( + <SectionCard + eyebrow="Ingestion summary" + title="No message ingested yet" + description="Run one selected-message ingestion to review artifact linkage and media metadata." + > + <EmptyState + title="Summary is idle" + description="A successful ingestion will appear here with the resulting artifact path and media type." + /> + </SectionCard> + ); + } + + return ( + <SectionCard + eyebrow="Ingestion summary" + title="Selected-message ingestion result" + description="Review the resulting artifact path, media type, and linked workspace target." + > + <div className="detail-grid"> + <div className="detail-summary"> + <StatusBadge status={result.artifact.ingestion_status} /> + <StatusBadge + status={source ?? "unavailable"} + label={ + source === "live" + ? "Live result" + : source === "fixture" + ? "Fixture result" + : "Result unavailable" + } + /> + </div> + + <dl className="key-value-grid key-value-grid--compact"> + <div> + <dt>Provider message ID</dt> + <dd className="mono">{result.message.provider_message_id}</dd> + </div> + <div> + <dt>Account email</dt> + <dd>{result.account.email_address}</dd> + </div> + <div> + <dt>Artifact path</dt> + <dd className="mono">{result.message.artifact_relative_path}</dd> + </div> + <div> + <dt>Linked artifact target</dt> + <dd className="mono">{result.artifact.relative_path}</dd> + </div> + <div> + <dt>Task workspace ID</dt> + <dd className="mono">{result.artifact.task_workspace_id}</dd> + </div> + <div> + <dt>Media type</dt> + <dd className="mono">{result.message.media_type}</dd> + </div> + </dl> + </div> + </SectionCard> + ); +} diff --git a/apps/web/components/gmail-message-ingest-form.test.tsx b/apps/web/components/gmail-message-ingest-form.test.tsx new file mode 100644 index 0000000..6f9378d --- /dev/null +++ b/apps/web/components/gmail-message-ingest-form.test.tsx @@ -0,0 +1,137 @@ +import React from "react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { GmailMessageIngestForm } from "./gmail-message-ingest-form"; + +const { ingestGmailMessageMock, refreshMock } = vi.hoisted(() => ({ + ingestGmailMessageMock: vi.fn(), + refreshMock: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + refresh: refreshMock, + }), +})); + +vi.mock("../lib/api", async () => { + const actual = await vi.importActual("../lib/api"); + return { + ...actual, + ingestGmailMessage: ingestGmailMessageMock, + }; +}); + +const baseAccount = { + id: "gmail-account-1", + provider: "gmail", + auth_kind: "oauth_access_token", + provider_account_id: "acct-owner-001", + email_address: "owner@gmail.example", + display_name: "Owner", + scope: "https://www.googleapis.com/auth/gmail.readonly" as const, + created_at: "2026-03-18T10:00:00Z", + updated_at: "2026-03-18T10:00:00Z", +}; + +const baseWorkspaces = [ + { + id: "workspace-1", + task_id: "task-1", + status: "active" as const, + local_path: "/tmp/task-workspaces/task-1", + created_at: "2026-03-18T10:00:00Z", + updated_at: "2026-03-18T10:00:00Z", + }, +]; + +describe("GmailMessageIngestForm", () => { + beforeEach(() => { + ingestGmailMessageMock.mockReset(); + refreshMock.mockReset(); + }); + + afterEach(() => { + cleanup(); + }); + + it("submits selected-message ingestion through the shipped endpoint when live mode is available", async () => { + ingestGmailMessageMock.mockResolvedValue({ + account: baseAccount, + message: { + provider_message_id: "msg-001", + artifact_relative_path: "gmail/acct-owner-001/msg-001.eml", + media_type: "message/rfc822", + }, + artifact: { + id: "artifact-1", + task_id: "task-1", + task_workspace_id: "workspace-1", + status: "registered", + ingestion_status: "ingested", + relative_path: "gmail/acct-owner-001/msg-001.eml", + media_type_hint: "message/rfc822", + created_at: "2026-03-18T10:10:00Z", + updated_at: "2026-03-18T10:11:00Z", + }, + summary: { + total_count: 1, + total_characters: 128, + media_type: "message/rfc822", + chunking_rule: "normalized_utf8_text_fixed_window_1000_chars_v1", + order: ["sequence_no_asc", "id_asc"], + }, + }); + + render( + <GmailMessageIngestForm + account={baseAccount} + accountSource="live" + taskWorkspaces={baseWorkspaces} + taskWorkspaceSource="live" + apiBaseUrl="https://api.example.com" + userId="user-1" + />, + ); + + fireEvent.change(screen.getByLabelText("Provider message ID"), { + target: { value: "msg-001" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Ingest selected message" })); + + await waitFor(() => { + expect(ingestGmailMessageMock).toHaveBeenCalledWith( + "https://api.example.com", + "gmail-account-1", + "msg-001", + { + user_id: "user-1", + task_workspace_id: "workspace-1", + }, + ); + }); + + expect(refreshMock).toHaveBeenCalled(); + expect(screen.getByText(/Ingestion completed\./i)).toBeInTheDocument(); + }); + + it("keeps ingestion disabled when live prerequisites are unavailable", () => { + render( + <GmailMessageIngestForm + account={baseAccount} + accountSource="fixture" + taskWorkspaces={baseWorkspaces} + taskWorkspaceSource="fixture" + />, + ); + + expect(screen.getByRole("button", { name: "Ingest selected message" })).toBeDisabled(); + expect( + screen.getByText( + "Message ingestion is unavailable until live API configuration, live account detail, and live task workspace list are present.", + ), + ).toBeInTheDocument(); + expect(ingestGmailMessageMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/components/gmail-message-ingest-form.tsx b/apps/web/components/gmail-message-ingest-form.tsx new file mode 100644 index 0000000..cb5670e --- /dev/null +++ b/apps/web/components/gmail-message-ingest-form.tsx @@ -0,0 +1,287 @@ +"use client"; + +import type { FormEvent } from "react"; +import { useEffect, useMemo, useState } from "react"; + +import { useRouter } from "next/navigation"; + +import type { + ApiSource, + GmailAccountRecord, + GmailMessageIngestionResponse, + TaskWorkspaceRecord, +} from "../lib/api"; +import { ingestGmailMessage } from "../lib/api"; +import { EmptyState } from "./empty-state"; +import { GmailIngestionSummary } from "./gmail-ingestion-summary"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +type GmailMessageIngestFormProps = { + account: GmailAccountRecord | null; + accountSource: ApiSource | "unavailable" | null; + taskWorkspaces: TaskWorkspaceRecord[]; + taskWorkspaceSource: ApiSource | "unavailable"; + apiBaseUrl?: string; + userId?: string; +}; + +export function GmailMessageIngestForm({ + account, + accountSource, + taskWorkspaces, + taskWorkspaceSource, + apiBaseUrl, + userId, +}: GmailMessageIngestFormProps) { + const router = useRouter(); + + const [providerMessageId, setProviderMessageId] = useState(""); + const [taskWorkspaceId, setTaskWorkspaceId] = useState(taskWorkspaces[0]?.id ?? ""); + const [result, setResult] = useState<GmailMessageIngestionResponse | null>(null); + const [resultSource, setResultSource] = useState<ApiSource | "unavailable" | null>(null); + const [resultUnavailableReason, setResultUnavailableReason] = useState<string | undefined>(undefined); + const [isSubmitting, setIsSubmitting] = useState(false); + const [statusTone, setStatusTone] = useState<"info" | "success" | "danger">("info"); + + const liveModeReady = useMemo( + () => + Boolean( + account && + accountSource === "live" && + apiBaseUrl && + userId && + taskWorkspaceSource === "live" && + taskWorkspaces.length > 0, + ), + [account, accountSource, apiBaseUrl, userId, taskWorkspaceSource, taskWorkspaces.length], + ); + + const [statusText, setStatusText] = useState( + !account + ? "Select a Gmail account to enable single-message ingestion." + : taskWorkspaces.length === 0 + ? "No task workspace is available for ingestion target selection." + : liveModeReady + ? "Enter one provider message ID and select one task workspace." + : "Message ingestion is unavailable until live API configuration, live account detail, and live task workspace list are present.", + ); + + useEffect(() => { + const hasWorkspace = taskWorkspaces.some((workspace) => workspace.id === taskWorkspaceId); + if (!hasWorkspace) { + setTaskWorkspaceId(taskWorkspaces[0]?.id ?? ""); + } + }, [taskWorkspaceId, taskWorkspaces]); + + useEffect(() => { + if (!account) { + setStatusTone("info"); + setStatusText("Select a Gmail account to enable single-message ingestion."); + return; + } + + if (taskWorkspaces.length === 0) { + setStatusTone("info"); + setStatusText("No task workspace is available for ingestion target selection."); + return; + } + + if (!liveModeReady) { + setStatusTone("info"); + setStatusText( + "Message ingestion is unavailable until live API configuration, live account detail, and live task workspace list are present.", + ); + return; + } + + setStatusTone("info"); + setStatusText("Enter one provider message ID and select one task workspace."); + }, [account, liveModeReady, taskWorkspaces.length]); + + async function handleSubmit(event: FormEvent<HTMLFormElement>) { + event.preventDefault(); + + if (!account) { + setStatusTone("danger"); + setStatusText("Select a Gmail account before submitting ingestion."); + return; + } + + if (!taskWorkspaceId) { + setStatusTone("danger"); + setStatusText("Select a task workspace before submitting ingestion."); + return; + } + + if (!apiBaseUrl || !userId || !liveModeReady) { + setStatusTone("info"); + setStatusText( + "Message ingestion is unavailable until live API configuration, live account detail, and live task workspace list are present.", + ); + return; + } + + setIsSubmitting(true); + setStatusTone("info"); + setStatusText("Submitting message ingestion..."); + setResultUnavailableReason(undefined); + + try { + const payload = await ingestGmailMessage( + apiBaseUrl, + account.id, + providerMessageId.trim(), + { + user_id: userId, + task_workspace_id: taskWorkspaceId, + }, + ); + + setResult(payload); + setResultSource("live"); + setStatusTone("success"); + setStatusText(`Ingestion completed. Artifact path: ${payload.message.artifact_relative_path}`); + router.refresh(); + } catch (error) { + const detail = error instanceof Error ? error.message : "Ingestion failed"; + setResult(null); + setResultSource("unavailable"); + setResultUnavailableReason(detail); + setStatusTone("danger"); + setStatusText(`Unable to ingest message: ${detail}`); + } finally { + setIsSubmitting(false); + } + } + + const canSubmit = Boolean( + liveModeReady && taskWorkspaceId && providerMessageId.trim() && !isSubmitting, + ); + + if (!account) { + return ( + <div className="stack"> + <SectionCard + eyebrow="Ingest message" + title="No account selected" + description="Select one Gmail account before ingesting one provider message into a task workspace." + > + <EmptyState + title="Ingestion form is disabled" + description="Choose one account from the list to activate this bounded ingestion action." + /> + </SectionCard> + <GmailIngestionSummary result={null} source={null} /> + </div> + ); + } + + return ( + <div className="stack"> + <SectionCard + eyebrow="Ingest message" + title="Single-message ingestion" + description="Ingest one provider message id into one selected task workspace through the shipped RFC822 artifact seam." + > + <form className="detail-stack" onSubmit={handleSubmit}> + <div className="cluster"> + <StatusBadge + status={accountSource ?? "unavailable"} + label={ + accountSource === "live" + ? "Live account" + : accountSource === "fixture" + ? "Fixture account" + : "Account unavailable" + } + /> + <StatusBadge + status={taskWorkspaceSource} + label={ + taskWorkspaceSource === "live" + ? "Live workspaces" + : taskWorkspaceSource === "fixture" + ? "Fixture workspaces" + : "Workspaces unavailable" + } + /> + </div> + + <div className="form-field"> + <label htmlFor="gmail-provider-message-id">Provider message ID</label> + <input + id="gmail-provider-message-id" + name="gmail-provider-message-id" + value={providerMessageId} + onChange={(event) => setProviderMessageId(event.target.value)} + placeholder="msg-001" + required + disabled={!liveModeReady || isSubmitting} + /> + </div> + + <div className="form-field"> + <label htmlFor="gmail-task-workspace-id">Task workspace</label> + <select + id="gmail-task-workspace-id" + name="gmail-task-workspace-id" + value={taskWorkspaceId} + onChange={(event) => setTaskWorkspaceId(event.target.value)} + disabled={!liveModeReady || isSubmitting || taskWorkspaces.length === 0} + > + {taskWorkspaces.length === 0 ? ( + <option value="">No task workspace available</option> + ) : ( + taskWorkspaces.map((workspace) => ( + <option key={workspace.id} value={workspace.id}> + {workspace.id} · {workspace.local_path} + </option> + )) + )} + </select> + </div> + + <div className="composer-actions"> + <div className="composer-status" aria-live="polite"> + <StatusBadge + status={ + isSubmitting + ? "submitting" + : statusTone === "success" + ? "success" + : statusTone === "danger" + ? "error" + : liveModeReady + ? "info" + : "unavailable" + } + label={ + isSubmitting + ? "Submitting" + : statusTone === "success" + ? "Ingested" + : statusTone === "danger" + ? "Attention" + : liveModeReady + ? "Ready" + : "Unavailable" + } + /> + <span>{statusText}</span> + </div> + <button type="submit" className="button" disabled={!canSubmit}> + {isSubmitting ? "Submitting..." : "Ingest selected message"} + </button> + </div> + </form> + </SectionCard> + + <GmailIngestionSummary + result={result} + source={resultSource} + unavailableReason={resultUnavailableReason} + /> + </div> + ); +} diff --git a/apps/web/components/hosted-admin-panel.test.tsx b/apps/web/components/hosted-admin-panel.test.tsx new file mode 100644 index 0000000..5eb55cb --- /dev/null +++ b/apps/web/components/hosted-admin-panel.test.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { HostedAdminPanel } from "./hosted-admin-panel"; + +describe("HostedAdminPanel", () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + fetchMock.mockReset(); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("renders hosted admin scope and OSS-versus-hosted boundary copy", () => { + render(<HostedAdminPanel />); + + expect(screen.getByText("Hosted Beta Operations")).toBeInTheDocument(); + expect(screen.getByText(/alice connect hosted beta operations/i)).toBeInTheDocument(); + expect(screen.getByText(/alice core oss runtime/i)).toBeInTheDocument(); + expect(screen.getByText("Flag Posture")).toBeInTheDocument(); + }); + + it("loads hosted admin datasets with bearer auth", async () => { + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ window_hours: 24, workspaces: { total_count: 1, ready_count: 1, pending_count: 0, linked_telegram_workspace_count: 1 }, delivery_receipts: { total_count: 2, failed_count: 0, suppressed_count: 0 }, chat_telemetry: { total_count: 3, failed_count: 0, rollout_blocked_count: 0, rate_limited_count: 0, abuse_blocked_count: 0 }, incidents: { open_count: 0 } }))) + .mockResolvedValueOnce(new Response(JSON.stringify({ items: [{}] }))) + .mockResolvedValueOnce(new Response(JSON.stringify({ items: [{}, {}] }))) + .mockResolvedValueOnce(new Response(JSON.stringify({ items: [] }))) + .mockResolvedValueOnce(new Response(JSON.stringify({ items: [{ flag_key: "hosted_chat_handle_enabled", enabled: true, source_scope: "cohort" }] }))) + .mockResolvedValueOnce(new Response(JSON.stringify({ analytics: { total_events: 3 } }))) + .mockResolvedValueOnce(new Response(JSON.stringify({ items: [] }))); + + render(<HostedAdminPanel apiBaseUrl="https://api.example.com" />); + + fireEvent.change(screen.getByLabelText(/Hosted session token/i), { + target: { value: "admin-session-token" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Load admin datasets" })); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(7); + }); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toContain("/v1/admin/hosted/overview"); + expect((init.headers as Record<string, string>).Authorization).toBe("Bearer admin-session-token"); + expect(screen.getByText("Hosted admin datasets loaded.")).toBeInTheDocument(); + expect(screen.getByText("hosted_chat_handle_enabled")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/hosted-admin-panel.tsx b/apps/web/components/hosted-admin-panel.tsx new file mode 100644 index 0000000..0014fd5 --- /dev/null +++ b/apps/web/components/hosted-admin-panel.tsx @@ -0,0 +1,280 @@ +"use client"; + +import type { FormEvent } from "react"; +import { useState } from "react"; + +import { getApiConfig } from "../lib/api"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +type HostedOverview = { + window_hours: number; + workspaces: { + total_count: number; + ready_count: number; + pending_count: number; + linked_telegram_workspace_count: number; + }; + delivery_receipts: { + total_count: number; + failed_count: number; + suppressed_count: number; + }; + chat_telemetry: { + total_count: number; + failed_count: number; + rollout_blocked_count: number; + rate_limited_count: number; + abuse_blocked_count: number; + }; + incidents: { + open_count: number; + }; +}; + +type RolloutFlag = { + flag_key: string; + enabled: boolean; + source_scope: string; + source_cohort_key?: string | null; +}; + +type HostedAdminPanelProps = { + apiBaseUrl?: string; +}; + +function formatTimestamp(value: string | undefined) { + if (!value) { + return "-"; + } + + try { + return new Date(value).toLocaleString("en"); + } catch { + return value; + } +} + +export function HostedAdminPanel({ apiBaseUrl }: HostedAdminPanelProps) { + const apiConfig = getApiConfig(); + const resolvedApiBaseUrl = (apiBaseUrl ?? apiConfig.apiBaseUrl).trim(); + const liveModeReady = resolvedApiBaseUrl !== ""; + + const [sessionToken, setSessionToken] = useState(""); + const [isWorking, setIsWorking] = useState(false); + const [statusTone, setStatusTone] = useState<"info" | "success" | "danger">("info"); + const [statusText, setStatusText] = useState( + liveModeReady + ? "Load hosted admin datasets to inspect workspace, delivery, incidents, rollout flags, analytics, and rate-limit posture." + : "Admin datasets are unavailable until NEXT_PUBLIC_ALICEBOT_API_BASE_URL is configured.", + ); + + const [overview, setOverview] = useState<HostedOverview | null>(null); + const [workspacesCount, setWorkspacesCount] = useState<number | null>(null); + const [deliveryCount, setDeliveryCount] = useState<number | null>(null); + const [incidentCount, setIncidentCount] = useState<number | null>(null); + const [rolloutFlags, setRolloutFlags] = useState<RolloutFlag[]>([]); + const [analyticsTotal, setAnalyticsTotal] = useState<number | null>(null); + const [rateLimitedTotal, setRateLimitedTotal] = useState<number | null>(null); + const [lastLoadedAt, setLastLoadedAt] = useState<string | undefined>(undefined); + + async function requestAdminJson<T>(path: string, init?: RequestInit): Promise<T> { + if (!liveModeReady) { + throw new Error("NEXT_PUBLIC_ALICEBOT_API_BASE_URL must be configured for hosted admin controls."); + } + + const token = sessionToken.trim(); + if (token === "") { + throw new Error("Hosted session token is required."); + } + + const response = await fetch(new URL(path, `${resolvedApiBaseUrl.replace(/\/$/, "")}/`).toString(), { + cache: "no-store", + ...init, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + ...(init?.headers ?? {}), + }, + }); + + const payload = (await response.json().catch(() => null)) as { detail?: string } | null; + if (!response.ok) { + throw new Error(payload?.detail ?? "Request failed"); + } + + return payload as T; + } + + async function runOperation(operation: () => Promise<void>, loadingText: string) { + setIsWorking(true); + setStatusTone("info"); + setStatusText(loadingText); + + try { + await operation(); + } catch (error) { + setStatusTone("danger"); + setStatusText(error instanceof Error ? error.message : "Request failed"); + } finally { + setIsWorking(false); + } + } + + async function loadAdminDatasets() { + await runOperation(async () => { + const [overviewPayload, workspacesPayload, deliveryPayload, incidentsPayload, rolloutPayload, analyticsPayload, rateLimitsPayload] = + await Promise.all([ + requestAdminJson<{ overview?: HostedOverview } | HostedOverview>("/v1/admin/hosted/overview"), + requestAdminJson<{ items: unknown[] }>("/v1/admin/hosted/workspaces"), + requestAdminJson<{ items: unknown[] }>("/v1/admin/hosted/delivery-receipts"), + requestAdminJson<{ items: unknown[] }>("/v1/admin/hosted/incidents?status=open"), + requestAdminJson<{ items: RolloutFlag[] }>("/v1/admin/hosted/rollout-flags"), + requestAdminJson<{ analytics: { total_events: number } }>("/v1/admin/hosted/analytics"), + requestAdminJson<{ items: unknown[] }>("/v1/admin/hosted/rate-limits"), + ]); + + const resolvedOverview = "overview" in overviewPayload ? overviewPayload.overview ?? null : overviewPayload; + setOverview(resolvedOverview); + setWorkspacesCount(workspacesPayload.items.length); + setDeliveryCount(deliveryPayload.items.length); + setIncidentCount(incidentsPayload.items.length); + setRolloutFlags(rolloutPayload.items); + setAnalyticsTotal(analyticsPayload.analytics.total_events); + setRateLimitedTotal(rateLimitsPayload.items.length); + setLastLoadedAt(new Date().toISOString()); + setStatusTone("success"); + setStatusText("Hosted admin datasets loaded."); + }, "Loading hosted admin datasets..."); + } + + async function patchRolloutFlag(flagKey: string, enabled: boolean) { + await runOperation(async () => { + const currentFlag = rolloutFlags.find((flag) => flag.flag_key === flagKey); + const payload = await requestAdminJson<{ items: RolloutFlag[] }>("/v1/admin/hosted/rollout-flags", { + method: "PATCH", + body: JSON.stringify({ + updates: [ + { + flag_key: flagKey, + enabled, + cohort_key: currentFlag?.source_cohort_key ?? undefined, + }, + ], + }), + }); + + setRolloutFlags(payload.items); + setStatusTone("success"); + setStatusText(`Rollout flag ${flagKey} set to ${enabled ? "enabled" : "disabled"}.`); + }, `Updating ${flagKey}...`); + } + + function handleLoadSubmit(event: FormEvent<HTMLFormElement>) { + event.preventDefault(); + void loadAdminDatasets(); + } + + return ( + <div className="stack"> + <SectionCard + eyebrow="P10-S5 Admin" + title="Hosted Beta Operations" + description="Operator-only support visibility for workspace posture, delivery receipts, incidents, rollout flags, analytics, and rate limits." + > + <p className="muted-copy"> + This panel is for <strong>Alice Connect hosted beta operations</strong>. Alice Core OSS runtime and + deterministic CLI/MCP semantics remain unchanged. + </p> + <form className="stack" onSubmit={handleLoadSubmit}> + <label className="field-label" htmlFor="hosted-admin-session-token"> + Hosted session token + </label> + <input + id="hosted-admin-session-token" + className="text-input" + placeholder="Paste Bearer token" + value={sessionToken} + onChange={(event) => setSessionToken(event.target.value)} + /> + <div className="button-row"> + <button type="submit" className="button button--primary" disabled={isWorking || !liveModeReady}> + Load admin datasets + </button> + <button + type="button" + className="button button--secondary" + disabled={isWorking || !liveModeReady} + onClick={() => patchRolloutFlag("hosted_chat_handle_enabled", false)} + > + Disable chat rollout + </button> + <button + type="button" + className="button button--secondary" + disabled={isWorking || !liveModeReady} + onClick={() => patchRolloutFlag("hosted_chat_handle_enabled", true)} + > + Enable chat rollout + </button> + </div> + </form> + + <div className="status-row"> + <StatusBadge status={statusTone === "danger" ? "failed" : statusTone === "success" ? "active" : "pending_approval"} /> + <p className="muted-copy" role="status"> + {statusText} + </p> + </div> + </SectionCard> + + <SectionCard + eyebrow="Overview" + title="Launch Readiness Snapshot" + description="Cross-workspace operational summary for launch-gate checks." + > + <dl className="key-value-grid"> + <div> + <dt>Workspaces (window)</dt> + <dd>{overview?.workspaces.total_count ?? workspacesCount ?? 0}</dd> + </div> + <div> + <dt>Open incidents</dt> + <dd>{overview?.incidents.open_count ?? incidentCount ?? 0}</dd> + </div> + <div> + <dt>Delivery receipts</dt> + <dd>{overview?.delivery_receipts.total_count ?? deliveryCount ?? 0}</dd> + </div> + <div> + <dt>Telemetry events</dt> + <dd>{overview?.chat_telemetry.total_count ?? analyticsTotal ?? 0}</dd> + </div> + <div> + <dt>Rate-limit events</dt> + <dd>{rateLimitedTotal ?? 0}</dd> + </div> + <div> + <dt>Last refreshed</dt> + <dd>{formatTimestamp(lastLoadedAt)}</dd> + </div> + </dl> + </SectionCard> + + <SectionCard + eyebrow="Rollout" + title="Flag Posture" + description="Current hosted rollout flags visible to the authenticated admin operator." + > + <ul className="bullet-list"> + {rolloutFlags.length === 0 ? <li>No rollout flags loaded yet.</li> : null} + {rolloutFlags.map((flag) => ( + <li key={flag.flag_key}> + <strong>{flag.flag_key}</strong>: {flag.enabled ? "enabled" : "disabled"} ({flag.source_scope}) + </li> + ))} + </ul> + </SectionCard> + </div> + ); +} diff --git a/apps/web/components/hosted-onboarding-panel.tsx b/apps/web/components/hosted-onboarding-panel.tsx new file mode 100644 index 0000000..56dc2ae --- /dev/null +++ b/apps/web/components/hosted-onboarding-panel.tsx @@ -0,0 +1,50 @@ +import { SectionCard } from "./section-card"; + +const readinessChecklist = [ + "Request magic-link sign-in and verify the challenge token.", + "Create one hosted workspace and pin it as current.", + "Run workspace bootstrap and confirm readiness for next-phase Telegram linkage.", + "Set timezone and brief/quiet-hour preference scaffolding for future scheduling.", + "Escalate onboarding failures through hosted admin incident visibility instead of direct database inspection.", +]; + +export function HostedOnboardingPanel() { + return ( + <div className="stack"> + <SectionCard + eyebrow="Hosted Entry" + title="Magic-link Identity" + description="Use magic-link only for Phase 10 Sprint 1 hosted entry." + > + <ul className="bullet-list"> + {readinessChecklist.map((item) => ( + <li key={item}>{item}</li> + ))} + </ul> + </SectionCard> + + <SectionCard + eyebrow="Scope Guard" + title="Telegram State" + description="Bootstrap indicates readiness only. Telegram linking is intentionally deferred." + > + <p className="muted-copy"> + Telegram channel linkage is <strong>not available in P10-S1</strong>. This screen only + confirms that hosted identity, workspace bootstrap, devices, and preferences are ready for + a later Telegram sprint. + </p> + </SectionCard> + + <SectionCard + eyebrow="Support Posture" + title="Onboarding Failure Visibility" + description="P10-S5 keeps onboarding failures visible for support without reopening bootstrap semantics." + > + <p className="muted-copy"> + When onboarding fails, operators should inspect hosted admin incidents and workspace support + posture before retrying. This keeps support workflows explicit and deterministic. + </p> + </SectionCard> + </div> + ); +} diff --git a/apps/web/components/hosted-settings-panel.test.tsx b/apps/web/components/hosted-settings-panel.test.tsx new file mode 100644 index 0000000..649ac69 --- /dev/null +++ b/apps/web/components/hosted-settings-panel.test.tsx @@ -0,0 +1,65 @@ +import React from "react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { HostedSettingsPanel } from "./hosted-settings-panel"; + +describe("HostedSettingsPanel", () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + fetchMock.mockReset(); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("shows telegram link, status, and receipt controls without continuity claims", () => { + render(<HostedSettingsPanel />); + + expect(screen.getByText("Telegram Channel Settings")).toBeInTheDocument(); + expect(screen.getByText(/Telegram Link Start/i)).toBeInTheDocument(); + expect(screen.getByText(/Daily Brief \+ Notification Preferences/i)).toBeInTheDocument(); + expect(screen.getByText(/Open-Loop Prompts \+ Scheduler/i)).toBeInTheDocument(); + expect(screen.getAllByText(/Messages, Threads, Receipts/i).length).toBeGreaterThan(0); + expect(screen.getByText(/does not claim beta admin dashboards/i)).toBeInTheDocument(); + }); + + it("starts telegram link challenge from hosted controls", async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + challenge: { + challenge_token: "telegram-test-challenge-token", + link_code: "CODE2026", + status: "pending", + expires_at: "2026-04-08T18:45:00Z", + }, + instructions: { + bot_username: "alicebot", + command: "/link CODE2026", + }, + }), + ), + ); + + render(<HostedSettingsPanel apiBaseUrl="https://api.example.com" />); + + fireEvent.change(screen.getByLabelText(/Hosted session token/i), { + target: { value: "session-token-123" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Start Telegram link" })); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toContain("/v1/channels/telegram/link/start"); + expect((init.headers as Record<string, string>).Authorization).toBe("Bearer session-token-123"); + expect(screen.getAllByText(/\/link CODE2026/).length).toBeGreaterThan(0); + }); +}); diff --git a/apps/web/components/hosted-settings-panel.tsx b/apps/web/components/hosted-settings-panel.tsx new file mode 100644 index 0000000..035ee72 --- /dev/null +++ b/apps/web/components/hosted-settings-panel.tsx @@ -0,0 +1,840 @@ +"use client"; + +import type { FormEvent } from "react"; +import { useState } from "react"; + +import { getApiConfig } from "../lib/api"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +const settingItems = [ + { + title: "Telegram Link Start", + detail: "Issue a deterministic link challenge bound to the active hosted workspace.", + }, + { + title: "Telegram Link Confirm", + detail: "Confirm linkage only after webhook-observed link code proof from the Telegram chat identity.", + }, + { + title: "Telegram Status + Unlink", + detail: "Inspect current transport readiness and remove Telegram linkage without local tooling.", + }, + { + title: "Daily Brief + Notification Preferences", + detail: "Inspect notification posture, quiet-hours policy, and daily brief preview/delivery controls.", + }, + { + title: "Open-Loop Prompts + Scheduler", + detail: "Surface waiting-for/stale prompt candidates and scheduled job posture without admin tooling claims.", + }, + { + title: "Messages, Threads, Receipts", + detail: "Expose normalized inbound traffic, deterministic routing state, and outbound delivery evidence.", + }, +]; + +type TelegramLinkChallenge = { + challenge_token?: string; + link_code: string; + status: string; + expires_at: string; +}; + +type TelegramIdentity = { + id: string; + workspace_id: string; + external_chat_id: string; + external_username: string | null; + status: string; +}; + +type TelegramStatusPayload = { + workspace_id: string; + linked: boolean; + identity: TelegramIdentity | null; + latest_challenge: { + link_code: string; + status: string; + expires_at: string; + } | null; + recent_transport: { + message_id: string; + direction: string; + route_status: string; + observed_at: string; + } | null; +}; + +type TelegramMessage = { + id: string; + direction: string; + route_status: string; + message_text: string | null; + provider_update_id: string | null; + created_at: string; +}; + +type TelegramThread = { + id: string; + external_thread_key: string; + last_message_at: string | null; + updated_at: string; +}; + +type TelegramReceipt = { + id: string; + status: string; + channel_message_id: string; + recorded_at: string; + failure_code: string | null; + scheduler_job_kind?: string | null; +}; + +type TelegramNotificationPreferences = { + notifications_enabled: boolean; + daily_brief_enabled: boolean; + daily_brief_window_start: string; + open_loop_prompts_enabled: boolean; + waiting_for_prompts_enabled: boolean; + stale_prompts_enabled: boolean; + timezone: string; + quiet_hours: { + enabled: boolean; + start: string; + end: string; + active_now?: boolean; + }; +}; + +type TelegramDailyBriefPreview = { + preview_message_text: string; + delivery_policy: { + allowed: boolean; + suppression_status: string | null; + reason: string; + }; + brief: { + assembly_version: string; + waiting_for_highlights: { summary: { total_count: number } }; + blocker_highlights: { summary: { total_count: number } }; + stale_items: { summary: { total_count: number } }; + }; +}; + +type TelegramOpenLoopPrompt = { + prompt_id: string; + prompt_kind: string; + title: string; + latest_job_status: string | null; + already_delivered_today: boolean; +}; + +type TelegramSchedulerJob = { + id: string; + job_kind: string; + status: string; + due_at: string; + is_due: boolean; +}; + +type HostedSettingsPanelProps = { + apiBaseUrl?: string; +}; + +function formatOptionalDate(value: string | null | undefined) { + if (!value) { + return "-"; + } + + try { + return new Date(value).toLocaleString("en"); + } catch { + return value; + } +} + +function trimOrNull(value: string) { + const normalized = value.trim(); + return normalized === "" ? null : normalized; +} + +export function HostedSettingsPanel({ apiBaseUrl }: HostedSettingsPanelProps) { + const apiConfig = getApiConfig(); + const resolvedApiBaseUrl = (apiBaseUrl ?? apiConfig.apiBaseUrl).trim(); + const liveModeReady = resolvedApiBaseUrl !== ""; + + const [sessionToken, setSessionToken] = useState(""); + const [workspaceId, setWorkspaceId] = useState(""); + const [challengeToken, setChallengeToken] = useState(""); + + const [isWorking, setIsWorking] = useState(false); + const [statusTone, setStatusTone] = useState<"info" | "success" | "danger">("info"); + const [statusText, setStatusText] = useState( + liveModeReady + ? "Provide a hosted session token, then run Telegram link start/confirm and status controls." + : "Telegram controls are unavailable until NEXT_PUBLIC_ALICEBOT_API_BASE_URL is configured.", + ); + + const [latestChallenge, setLatestChallenge] = useState<TelegramLinkChallenge | null>(null); + const [latestIdentity, setLatestIdentity] = useState<TelegramIdentity | null>(null); + const [latestStatus, setLatestStatus] = useState<TelegramStatusPayload | null>(null); + const [messages, setMessages] = useState<TelegramMessage[]>([]); + const [threads, setThreads] = useState<TelegramThread[]>([]); + const [receipts, setReceipts] = useState<TelegramReceipt[]>([]); + const [notificationPreferences, setNotificationPreferences] = useState<TelegramNotificationPreferences | null>(null); + const [dailyBriefPreview, setDailyBriefPreview] = useState<TelegramDailyBriefPreview | null>(null); + const [openLoopPrompts, setOpenLoopPrompts] = useState<TelegramOpenLoopPrompt[]>([]); + const [schedulerJobs, setSchedulerJobs] = useState<TelegramSchedulerJob[]>([]); + + async function requestTelegramJson<T>( + path: string, + init?: RequestInit, + query?: Record<string, string | undefined>, + ): Promise<T> { + if (!liveModeReady) { + throw new Error("NEXT_PUBLIC_ALICEBOT_API_BASE_URL must be configured for live Telegram controls."); + } + + const token = sessionToken.trim(); + if (token === "") { + throw new Error("Hosted session token is required."); + } + + const url = new URL(path, `${resolvedApiBaseUrl.replace(/\/$/, "")}/`); + for (const [key, value] of Object.entries(query ?? {})) { + if (value) { + url.searchParams.set(key, value); + } + } + + const response = await fetch(url.toString(), { + cache: "no-store", + ...init, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + ...(init?.headers ?? {}), + }, + }); + + const payload = (await response.json().catch(() => null)) as { detail?: string } | null; + if (!response.ok) { + throw new Error(payload?.detail ?? "Request failed"); + } + + return payload as T; + } + + async function runOperation(operation: () => Promise<void>, loadingText: string) { + setIsWorking(true); + setStatusTone("info"); + setStatusText(loadingText); + + try { + await operation(); + } catch (error) { + setStatusTone("danger"); + setStatusText(error instanceof Error ? error.message : "Request failed"); + } finally { + setIsWorking(false); + } + } + + async function handleStartLink(event: FormEvent<HTMLFormElement>) { + event.preventDefault(); + + await runOperation(async () => { + const payload = await requestTelegramJson<{ + challenge: TelegramLinkChallenge; + instructions: { + bot_username: string; + command: string; + }; + }>("/v1/channels/telegram/link/start", { + method: "POST", + body: JSON.stringify({ workspace_id: trimOrNull(workspaceId) }), + }); + + setLatestChallenge(payload.challenge); + if (payload.challenge.challenge_token) { + setChallengeToken(payload.challenge.challenge_token); + } + setStatusTone("success"); + setStatusText( + `Link challenge issued. Send ${payload.instructions.command} to @${payload.instructions.bot_username}, then confirm.`, + ); + }, "Issuing Telegram link challenge..."); + } + + async function handleConfirmLink() { + await runOperation(async () => { + const token = challengeToken.trim(); + if (token === "") { + throw new Error("Challenge token is required for link confirm."); + } + + const payload = await requestTelegramJson<{ + identity: TelegramIdentity; + challenge: TelegramLinkChallenge; + }>("/v1/channels/telegram/link/confirm", { + method: "POST", + body: JSON.stringify({ challenge_token: token }), + }); + + setLatestIdentity(payload.identity); + setLatestChallenge(payload.challenge); + setStatusTone("success"); + setStatusText("Telegram link confirmed for the current hosted workspace."); + }, "Confirming Telegram link challenge..."); + } + + async function handleLoadStatus() { + await runOperation(async () => { + const payload = await requestTelegramJson<TelegramStatusPayload>( + "/v1/channels/telegram/status", + undefined, + { workspace_id: trimOrNull(workspaceId) ?? undefined }, + ); + + setLatestStatus(payload); + setLatestIdentity(payload.identity); + setStatusTone("success"); + setStatusText( + payload.linked + ? "Telegram is linked and ready for transport operations." + : "Telegram is not linked for the selected workspace.", + ); + }, "Loading Telegram status..."); + } + + async function handleUnlink() { + await runOperation(async () => { + const payload = await requestTelegramJson<{ identity: TelegramIdentity }>( + "/v1/channels/telegram/unlink", + { + method: "POST", + body: JSON.stringify({ workspace_id: trimOrNull(workspaceId) }), + }, + ); + + setLatestIdentity(payload.identity); + setStatusTone("success"); + setStatusText("Telegram identity unlinked for the selected workspace."); + }, "Unlinking Telegram identity..."); + } + + async function handleRefreshTransportRecords() { + await runOperation(async () => { + const [messagePayload, threadPayload, receiptPayload] = await Promise.all([ + requestTelegramJson<{ items: TelegramMessage[] }>("/v1/channels/telegram/messages"), + requestTelegramJson<{ items: TelegramThread[] }>("/v1/channels/telegram/threads"), + requestTelegramJson<{ items: TelegramReceipt[] }>("/v1/channels/telegram/delivery-receipts"), + ]); + + setMessages(messagePayload.items); + setThreads(threadPayload.items); + setReceipts(receiptPayload.items); + setStatusTone("success"); + setStatusText("Loaded latest Telegram messages, threads, and delivery receipts."); + }, "Loading Telegram transport records..."); + } + + async function handleLoadNotificationPreferences() { + await runOperation(async () => { + const payload = await requestTelegramJson<{ notification_preferences: TelegramNotificationPreferences }>( + "/v1/channels/telegram/notification-preferences", + ); + setNotificationPreferences(payload.notification_preferences); + setStatusTone("success"); + setStatusText("Loaded Telegram notification preference posture."); + }, "Loading Telegram notification preferences..."); + } + + async function handleEnableDailyLoop() { + await runOperation(async () => { + const payload = await requestTelegramJson<{ notification_preferences: TelegramNotificationPreferences }>( + "/v1/channels/telegram/notification-preferences", + { + method: "PATCH", + body: JSON.stringify({ + notifications_enabled: true, + daily_brief_enabled: true, + open_loop_prompts_enabled: true, + waiting_for_prompts_enabled: true, + stale_prompts_enabled: true, + quiet_hours_enabled: false, + daily_brief_window_start: "07:00", + }), + }, + ); + setNotificationPreferences(payload.notification_preferences); + setStatusTone("success"); + setStatusText("Enabled daily brief + open-loop prompt delivery for Telegram."); + }, "Enabling Telegram daily loop..."); + } + + async function handlePreviewDailyBrief() { + await runOperation(async () => { + const payload = await requestTelegramJson<TelegramDailyBriefPreview>("/v1/channels/telegram/daily-brief"); + setDailyBriefPreview(payload); + setStatusTone("success"); + setStatusText("Loaded current daily brief preview and delivery policy."); + }, "Loading Telegram daily brief preview..."); + } + + async function handleDeliverDailyBrief() { + await runOperation(async () => { + const payload = await requestTelegramJson<{ job: TelegramSchedulerJob; idempotent_replay: boolean }>( + "/v1/channels/telegram/daily-brief/deliver", + { method: "POST", body: JSON.stringify({}) }, + ); + setStatusTone("success"); + setStatusText( + payload.idempotent_replay + ? "Daily brief delivery reused existing idempotent job." + : "Daily brief delivery job recorded for Telegram.", + ); + await loadSchedulerJobs(); + }, "Delivering Telegram daily brief..."); + } + + async function loadOpenLoopPrompts() { + const payload = await requestTelegramJson<{ items: TelegramOpenLoopPrompt[] }>( + "/v1/channels/telegram/open-loop-prompts", + ); + setOpenLoopPrompts(payload.items); + } + + async function handleLoadOpenLoopPrompts() { + await runOperation(async () => { + await loadOpenLoopPrompts(); + setStatusTone("success"); + setStatusText("Loaded scheduled waiting-for and stale open-loop prompts."); + }, "Loading Telegram open-loop prompts..."); + } + + async function handleDeliverPrompt(promptId: string) { + await runOperation(async () => { + const payload = await requestTelegramJson<{ idempotent_replay: boolean }>( + `/v1/channels/telegram/open-loop-prompts/${encodeURIComponent(promptId)}/deliver`, + { method: "POST", body: JSON.stringify({}) }, + ); + setStatusTone("success"); + setStatusText( + payload.idempotent_replay + ? "Prompt delivery reused existing idempotent job." + : "Prompt delivery job recorded for Telegram.", + ); + await loadOpenLoopPrompts(); + await loadSchedulerJobs(); + }, "Delivering Telegram open-loop prompt..."); + } + + async function loadSchedulerJobs() { + const payload = await requestTelegramJson<{ items: TelegramSchedulerJob[] }>( + "/v1/channels/telegram/scheduler/jobs", + ); + setSchedulerJobs(payload.items); + } + + async function handleLoadSchedulerJobs() { + await runOperation(async () => { + await loadSchedulerJobs(); + setStatusTone("success"); + setStatusText("Loaded Telegram scheduler job posture."); + }, "Loading Telegram scheduler jobs..."); + } + + return ( + <div className="stack"> + <SectionCard + eyebrow="Hosted Settings" + title="Telegram Channel Settings" + description="Hosted controls cover link start/confirm, operational status, and deterministic transport records for Telegram." + > + <ul className="bullet-list"> + {settingItems.map((item) => ( + <li key={item.title}> + <strong>{item.title}:</strong> {item.detail} + </li> + ))} + </ul> + </SectionCard> + + <SectionCard + eyebrow="Hosted Controls" + title="Session + Workspace Context" + description="Provide a hosted session token and optional workspace UUID before issuing Telegram channel operations." + > + <div className="detail-stack"> + <div className="form-field"> + <label htmlFor="telegram-session-token">Hosted session token</label> + <input + id="telegram-session-token" + name="telegram-session-token" + type="password" + value={sessionToken} + onChange={(event) => setSessionToken(event.target.value)} + placeholder="Paste Bearer token" + disabled={isWorking || !liveModeReady} + /> + </div> + + <div className="form-field"> + <label htmlFor="telegram-workspace-id">Workspace ID (optional)</label> + <input + id="telegram-workspace-id" + name="telegram-workspace-id" + value={workspaceId} + onChange={(event) => setWorkspaceId(event.target.value)} + placeholder="Use current session workspace when empty" + disabled={isWorking || !liveModeReady} + /> + </div> + + <div className="composer-status" aria-live="polite"> + <StatusBadge + status={ + isWorking + ? "submitting" + : statusTone === "success" + ? "success" + : statusTone === "danger" + ? "error" + : liveModeReady + ? "ready" + : "unavailable" + } + label={ + isWorking + ? "Working" + : statusTone === "success" + ? "Ready" + : statusTone === "danger" + ? "Attention" + : liveModeReady + ? "Configured" + : "Unavailable" + } + /> + <span>{statusText}</span> + </div> + </div> + </SectionCard> + + <SectionCard + eyebrow="Telegram Link" + title="Link Start + Confirm" + description="Start a deterministic link challenge, send the code in Telegram, then confirm from hosted settings." + > + <div className="detail-stack"> + <form onSubmit={handleStartLink}> + <button + type="submit" + className="button" + disabled={isWorking || !liveModeReady || sessionToken.trim() === ""} + > + {isWorking ? "Working..." : "Start Telegram link"} + </button> + </form> + + <div className="form-field"> + <label htmlFor="telegram-challenge-token">Challenge token</label> + <input + id="telegram-challenge-token" + name="telegram-challenge-token" + value={challengeToken} + onChange={(event) => setChallengeToken(event.target.value)} + placeholder="Challenge token returned by link start" + disabled={isWorking || !liveModeReady} + /> + <p className="field-hint">Use this token to confirm after Telegram webhook link proof is observed.</p> + </div> + + <button + type="button" + className="button-secondary" + onClick={handleConfirmLink} + disabled={isWorking || !liveModeReady || sessionToken.trim() === "" || challengeToken.trim() === ""} + > + {isWorking ? "Working..." : "Confirm Telegram link"} + </button> + + {latestChallenge ? ( + <div className="detail-stack"> + <p> + <strong>Latest link code:</strong> <span className="mono">{latestChallenge.link_code}</span> + </p> + <p> + <strong>Challenge status:</strong> {latestChallenge.status} + </p> + <p> + <strong>Expires:</strong> {formatOptionalDate(latestChallenge.expires_at)} + </p> + <p> + <strong>Telegram command:</strong> <span className="mono">/link {latestChallenge.link_code}</span> + </p> + </div> + ) : null} + </div> + </SectionCard> + + <SectionCard + eyebrow="Transport Readiness" + title="Status + Unlink" + description="Inspect Telegram linkage state and remove channel identity binding for the selected workspace." + > + <div className="composer-actions"> + <button + type="button" + className="button" + onClick={handleLoadStatus} + disabled={isWorking || !liveModeReady || sessionToken.trim() === ""} + > + {isWorking ? "Working..." : "Load Telegram status"} + </button> + <button + type="button" + className="button-secondary" + onClick={handleUnlink} + disabled={isWorking || !liveModeReady || sessionToken.trim() === ""} + > + {isWorking ? "Working..." : "Unlink Telegram"} + </button> + </div> + + {latestStatus ? ( + <div className="detail-stack"> + <p> + <strong>Workspace:</strong> <span className="mono">{latestStatus.workspace_id}</span> + </p> + <p> + <strong>Linked:</strong> {latestStatus.linked ? "yes" : "no"} + </p> + <p> + <strong>Recent route status:</strong> {latestStatus.recent_transport?.route_status ?? "-"} + </p> + <p> + <strong>Recent transport observed:</strong>{" "} + {formatOptionalDate(latestStatus.recent_transport?.observed_at)} + </p> + </div> + ) : null} + + {latestIdentity ? ( + <div className="detail-stack"> + <p> + <strong>Identity status:</strong> {latestIdentity.status} + </p> + <p> + <strong>External chat:</strong> <span className="mono">{latestIdentity.external_chat_id}</span> + </p> + <p> + <strong>External username:</strong> {latestIdentity.external_username ?? "-"} + </p> + </div> + ) : null} + </SectionCard> + + <SectionCard + eyebrow="Notification Posture" + title="Daily Brief + Prompt Scheduler" + description="Inspect and control Telegram notification preferences, preview the daily brief, and deliver scheduled open-loop prompts." + > + <div className="detail-stack"> + <div className="composer-actions"> + <button + type="button" + className="button" + onClick={handleLoadNotificationPreferences} + disabled={isWorking || !liveModeReady || sessionToken.trim() === ""} + > + {isWorking ? "Working..." : "Load notification posture"} + </button> + <button + type="button" + className="button-secondary" + onClick={handleEnableDailyLoop} + disabled={isWorking || !liveModeReady || sessionToken.trim() === ""} + > + {isWorking ? "Working..." : "Enable daily loop"} + </button> + </div> + + {notificationPreferences ? ( + <div className="detail-stack"> + <p> + <strong>Notifications enabled:</strong> {notificationPreferences.notifications_enabled ? "yes" : "no"} + </p> + <p> + <strong>Daily brief enabled:</strong> {notificationPreferences.daily_brief_enabled ? "yes" : "no"} + </p> + <p> + <strong>Open-loop prompts enabled:</strong>{" "} + {notificationPreferences.open_loop_prompts_enabled ? "yes" : "no"} + </p> + <p> + <strong>Timezone:</strong> {notificationPreferences.timezone} + </p> + <p> + <strong>Quiet hours:</strong>{" "} + {notificationPreferences.quiet_hours.enabled + ? `${notificationPreferences.quiet_hours.start}–${notificationPreferences.quiet_hours.end}` + : "disabled"} + </p> + </div> + ) : null} + + <div className="composer-actions"> + <button + type="button" + className="button" + onClick={handlePreviewDailyBrief} + disabled={isWorking || !liveModeReady || sessionToken.trim() === ""} + > + {isWorking ? "Working..." : "Preview daily brief"} + </button> + <button + type="button" + className="button-secondary" + onClick={handleDeliverDailyBrief} + disabled={isWorking || !liveModeReady || sessionToken.trim() === ""} + > + {isWorking ? "Working..." : "Deliver daily brief"} + </button> + </div> + + {dailyBriefPreview ? ( + <div className="detail-stack"> + <p> + <strong>Policy allows delivery now:</strong> {dailyBriefPreview.delivery_policy.allowed ? "yes" : "no"} + </p> + <p> + <strong>Policy reason:</strong> {dailyBriefPreview.delivery_policy.reason} + </p> + <p> + <strong>Brief counts:</strong> waiting-for {dailyBriefPreview.brief.waiting_for_highlights.summary.total_count} + , blockers {dailyBriefPreview.brief.blocker_highlights.summary.total_count}, stale{" "} + {dailyBriefPreview.brief.stale_items.summary.total_count} + </p> + </div> + ) : null} + + <div className="composer-actions"> + <button + type="button" + className="button" + onClick={handleLoadOpenLoopPrompts} + disabled={isWorking || !liveModeReady || sessionToken.trim() === ""} + > + {isWorking ? "Working..." : "Load open-loop prompts"} + </button> + <button + type="button" + className="button-secondary" + onClick={handleLoadSchedulerJobs} + disabled={isWorking || !liveModeReady || sessionToken.trim() === ""} + > + {isWorking ? "Working..." : "Load scheduler jobs"} + </button> + </div> + + <p> + <strong>Prompt candidates:</strong> {openLoopPrompts.length} + </p> + <ul className="bullet-list"> + {openLoopPrompts.slice(0, 5).map((prompt) => ( + <li key={prompt.prompt_id}> + <span className="mono">{prompt.prompt_kind}</span> · {prompt.title} · latest {prompt.latest_job_status ?? "none"} + <button + type="button" + className="button-secondary" + onClick={() => void handleDeliverPrompt(prompt.prompt_id)} + disabled={isWorking || !liveModeReady || sessionToken.trim() === ""} + style={{ marginLeft: "0.75rem" }} + > + Deliver + </button> + </li> + ))} + </ul> + + <p> + <strong>Scheduler jobs:</strong> {schedulerJobs.length} + </p> + <ul className="bullet-list"> + {schedulerJobs.slice(0, 5).map((job) => ( + <li key={job.id}> + <span className="mono">{job.job_kind}</span> · {job.status} · due {formatOptionalDate(job.due_at)} + </li> + ))} + </ul> + </div> + </SectionCard> + + <SectionCard + eyebrow="Transport Records" + title="Messages, Threads, Receipts" + description="Load deterministic inbound routing artifacts and outbound delivery posture for Telegram transport." + > + <div className="detail-stack"> + <button + type="button" + className="button" + onClick={handleRefreshTransportRecords} + disabled={isWorking || !liveModeReady || sessionToken.trim() === ""} + > + {isWorking ? "Working..." : "Refresh transport records"} + </button> + + <p> + <strong>Messages:</strong> {messages.length} + </p> + <ul className="bullet-list"> + {messages.slice(0, 5).map((message) => ( + <li key={message.id}> + <span className="mono">{message.id}</span> · {message.direction} · {message.route_status} ·{" "} + {message.message_text ?? "(no text)"} + </li> + ))} + </ul> + + <p> + <strong>Threads:</strong> {threads.length} + </p> + <ul className="bullet-list"> + {threads.slice(0, 5).map((thread) => ( + <li key={thread.id}> + <span className="mono">{thread.external_thread_key}</span> · updated {formatOptionalDate(thread.updated_at)} + </li> + ))} + </ul> + + <p> + <strong>Delivery receipts:</strong> {receipts.length} + </p> + <ul className="bullet-list"> + {receipts.slice(0, 5).map((receipt) => ( + <li key={receipt.id}> + <span className="mono">{receipt.channel_message_id}</span> · {receipt.status} + {receipt.failure_code ? ` · ${receipt.failure_code}` : ""} + </li> + ))} + </ul> + </div> + </SectionCard> + + <SectionCard + eyebrow="Control Truth" + title="P10-S4 Boundaries" + description="Daily brief, notification policy, and scheduled prompt controls are active without claiming admin/support tooling." + > + <p className="muted-copy"> + This surface does not claim beta admin dashboards, support consoles, channel expansion beyond Telegram, + or launch hardening as already active. It stays bounded to hosted Telegram brief + notification control. + </p> + </SectionCard> + </div> + ); +} diff --git a/apps/web/components/memory-detail.tsx b/apps/web/components/memory-detail.tsx new file mode 100644 index 0000000..e2ad034 --- /dev/null +++ b/apps/web/components/memory-detail.tsx @@ -0,0 +1,119 @@ +import type { ApiSource, MemoryReviewRecord } from "../lib/api"; +import { EmptyState } from "./empty-state"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +type MemoryDetailProps = { + memory: MemoryReviewRecord | null; + source: ApiSource | "unavailable" | null; + unavailableReason?: string; +}; + +function formatDate(value: string | null) { + if (!value) { + return "Not set"; + } + + return new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(value)); +} + +function formatValue(value: unknown) { + if (typeof value === "string") { + return value; + } + + const stringified = JSON.stringify(value, null, 2); + return stringified ?? "null"; +} + +export function MemoryDetail({ memory, source, unavailableReason }: MemoryDetailProps) { + if (!memory) { + return ( + <SectionCard + eyebrow="Selected memory" + title="No memory selected" + description="Choose a memory from the list to inspect full value, source-event references, and timestamps." + > + <EmptyState + title="Memory inspector is idle" + description="Select one active memory record to open the bounded detail panel." + /> + </SectionCard> + ); + } + + return ( + <SectionCard + eyebrow="Selected memory" + title={memory.memory_key} + description="The detail panel keeps value shape, provenance, and update time legible before revisions or labels are applied." + > + <div className="detail-grid"> + <div className="detail-summary"> + <StatusBadge status={memory.status} /> + <StatusBadge + status={source ?? "unavailable"} + label={ + source === "live" + ? "Live detail" + : source === "fixture" + ? "Fixture detail" + : "Detail unavailable" + } + /> + </div> + + {unavailableReason ? ( + <div className="execution-summary__note execution-summary__note--danger"> + <p className="execution-summary__label">Detail read</p> + <p>{unavailableReason}</p> + </div> + ) : null} + + <dl className="key-value-grid key-value-grid--compact"> + <div> + <dt>Memory ID</dt> + <dd className="mono">{memory.id}</dd> + </div> + <div> + <dt>Created</dt> + <dd>{formatDate(memory.created_at)}</dd> + </div> + <div> + <dt>Updated</dt> + <dd>{formatDate(memory.updated_at)}</dd> + </div> + <div> + <dt>Deleted</dt> + <dd>{formatDate(memory.deleted_at)}</dd> + </div> + </dl> + + <div className="detail-group"> + <h3>Memory value</h3> + <pre className="execution-summary__code">{formatValue(memory.value)}</pre> + </div> + + <div className="detail-group detail-group--muted"> + <h3>Source events</h3> + {memory.source_event_ids.length === 0 ? ( + <p className="muted-copy">No source-event references were returned for this memory.</p> + ) : ( + <div className="attribute-list"> + {memory.source_event_ids.map((eventId) => ( + <span key={eventId} className="attribute-item mono"> + {eventId} + </span> + ))} + </div> + )} + </div> + </div> + </SectionCard> + ); +} diff --git a/apps/web/components/memory-label-form.test.tsx b/apps/web/components/memory-label-form.test.tsx new file mode 100644 index 0000000..d7c4a67 --- /dev/null +++ b/apps/web/components/memory-label-form.test.tsx @@ -0,0 +1,182 @@ +import React from "react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { MemoryLabelForm } from "./memory-label-form"; + +const { submitMemoryLabelMock, refreshMock, pushMock } = vi.hoisted(() => ({ + submitMemoryLabelMock: vi.fn(), + refreshMock: vi.fn(), + pushMock: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + refresh: refreshMock, + push: pushMock, + }), +})); + +vi.mock("../lib/api", async () => { + const actual = await vi.importActual("../lib/api"); + return { + ...actual, + submitMemoryLabel: submitMemoryLabelMock, + }; +}); + +describe("MemoryLabelForm", () => { + beforeEach(() => { + submitMemoryLabelMock.mockReset(); + refreshMock.mockReset(); + pushMock.mockReset(); + }); + + afterEach(() => { + cleanup(); + }); + + it("submits memory labels through the shipped endpoint when live mode is available", async () => { + submitMemoryLabelMock.mockResolvedValue({ + label: { + id: "label-1", + memory_id: "memory-1", + reviewer_user_id: "user-1", + label: "incorrect", + note: "Conflicts with newer thread evidence.", + created_at: "2026-03-18T10:00:00Z", + }, + summary: { + memory_id: "memory-1", + total_count: 2, + counts_by_label: { + correct: 1, + incorrect: 1, + outdated: 0, + insufficient_evidence: 0, + }, + order: ["correct", "incorrect", "outdated", "insufficient_evidence"], + }, + }); + + render( + <MemoryLabelForm + memoryId="memory-1" + source="live" + apiBaseUrl="https://api.example.com" + userId="user-1" + />, + ); + + fireEvent.change(screen.getByLabelText("Review label"), { + target: { value: "incorrect" }, + }); + fireEvent.change(screen.getByLabelText("Reviewer note (optional)"), { + target: { value: "Conflicts with newer thread evidence." }, + }); + fireEvent.click(screen.getByRole("button", { name: "Submit review label" })); + + await waitFor(() => { + expect(submitMemoryLabelMock).toHaveBeenCalledWith("https://api.example.com", "memory-1", { + user_id: "user-1", + label: "incorrect", + note: "Conflicts with newer thread evidence.", + }); + }); + + expect(refreshMock).toHaveBeenCalled(); + expect(pushMock).not.toHaveBeenCalled(); + expect(screen.getByText(/Label saved\./i)).toBeInTheDocument(); + }); + + it("supports queue-mode submit and next navigation using deterministic next memory id", async () => { + submitMemoryLabelMock.mockResolvedValue({ + label: { + id: "label-2", + memory_id: "memory-1", + reviewer_user_id: "user-1", + label: "correct", + note: null, + created_at: "2026-03-18T10:00:00Z", + }, + summary: { + memory_id: "memory-1", + total_count: 3, + counts_by_label: { + correct: 2, + incorrect: 1, + outdated: 0, + insufficient_evidence: 0, + }, + order: ["correct", "incorrect", "outdated", "insufficient_evidence"], + }, + }); + + render( + <MemoryLabelForm + memoryId="memory-1" + source="live" + apiBaseUrl="https://api.example.com" + userId="user-1" + activeFilter="queue" + nextQueueMemoryId="memory-2" + queuePriorityMode="high_risk_first" + />, + ); + + fireEvent.click(screen.getByRole("button", { name: "Submit and next in queue" })); + + await waitFor(() => { + expect(pushMock).toHaveBeenCalledWith( + "/memories?filter=queue&memory=memory-2&priority_mode=high_risk_first", + ); + }); + expect(refreshMock).not.toHaveBeenCalled(); + expect(screen.getByText("Label saved. Advancing to next queue memory.")).toBeInTheDocument(); + }); + + it("keeps submission disabled when live API mode is unavailable", () => { + render( + <MemoryLabelForm + memoryId="memory-1" + source="fixture" + activeFilter="queue" + nextQueueMemoryId="memory-2" + />, + ); + + expect(screen.getByRole("button", { name: "Submit review label" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Submit and next in queue" })).toBeDisabled(); + expect( + screen.getByText( + "Label submission is unavailable until live API configuration and live memory detail are present.", + ), + ).toBeInTheDocument(); + expect(submitMemoryLabelMock).not.toHaveBeenCalled(); + }); + + it("only shows submit and next in queue mode when a next target exists", () => { + const { rerender } = render(<MemoryLabelForm memoryId="memory-1" source="live" />); + expect(screen.queryByRole("button", { name: "Submit and next in queue" })).not.toBeInTheDocument(); + + rerender( + <MemoryLabelForm + memoryId="memory-1" + source="live" + activeFilter="queue" + nextQueueMemoryId={null} + />, + ); + expect(screen.queryByRole("button", { name: "Submit and next in queue" })).not.toBeInTheDocument(); + + rerender( + <MemoryLabelForm + memoryId="memory-1" + source="live" + activeFilter="queue" + nextQueueMemoryId="memory-2" + />, + ); + expect(screen.getByRole("button", { name: "Submit and next in queue" })).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/memory-label-form.tsx b/apps/web/components/memory-label-form.tsx new file mode 100644 index 0000000..c26a31a --- /dev/null +++ b/apps/web/components/memory-label-form.tsx @@ -0,0 +1,229 @@ +"use client"; + +import type { FormEvent } from "react"; +import { useRef, useState } from "react"; + +import { useRouter } from "next/navigation"; + +import type { + ApiSource, + MemoryReviewLabelValue, + MemoryReviewQueuePriorityMode, +} from "../lib/api"; +import { submitMemoryLabel } from "../lib/api"; +import { EmptyState } from "./empty-state"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +type MemoryLabelFormProps = { + memoryId: string | null; + source: ApiSource | "unavailable" | null; + apiBaseUrl?: string; + userId?: string; + activeFilter?: "active" | "queue"; + nextQueueMemoryId?: string | null; + queuePriorityMode?: MemoryReviewQueuePriorityMode; +}; + +const LABEL_OPTIONS: Array<{ + value: MemoryReviewLabelValue; + label: string; +}> = [ + { value: "correct", label: "Correct" }, + { value: "incorrect", label: "Incorrect" }, + { value: "outdated", label: "Outdated" }, + { value: "insufficient_evidence", label: "Insufficient evidence" }, +]; + +export function MemoryLabelForm({ + memoryId, + source, + apiBaseUrl, + userId, + activeFilter = "active", + nextQueueMemoryId = null, + queuePriorityMode, +}: MemoryLabelFormProps) { + const router = useRouter(); + const submitActionRef = useRef<"submit" | "submit_and_next">("submit"); + const liveModeReady = Boolean(memoryId && apiBaseUrl && userId && source === "live"); + const queueModeWithNext = activeFilter === "queue" && Boolean(nextQueueMemoryId); + const [label, setLabel] = useState<MemoryReviewLabelValue>("correct"); + const [note, setNote] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [statusTone, setStatusTone] = useState<"info" | "success" | "danger">("info"); + const [statusText, setStatusText] = useState( + !memoryId + ? "Select a memory to enable label submission." + : liveModeReady + ? queueModeWithNext + ? "Choose a label, then submit once or submit and move to the next queue item." + : "Choose a label and submit when review is complete." + : "Label submission is unavailable until live API configuration and live memory detail are present.", + ); + const canSubmit = liveModeReady && !isSubmitting; + + async function handleSubmit(event: FormEvent<HTMLFormElement>) { + event.preventDefault(); + + if (!memoryId) { + setStatusTone("danger"); + setStatusText("Select a memory before submitting a label."); + return; + } + + if (!apiBaseUrl || !userId || source !== "live") { + setStatusTone("info"); + setStatusText("Label submission is unavailable until live API configuration and live memory detail are present."); + return; + } + + setIsSubmitting(true); + setStatusTone("info"); + setStatusText("Submitting memory review label..."); + + const submitAndNextRequested = submitActionRef.current === "submit_and_next"; + submitActionRef.current = "submit"; + + try { + const payload = await submitMemoryLabel(apiBaseUrl, memoryId, { + user_id: userId, + label, + note: note.trim() ? note.trim() : null, + }); + + setStatusTone("success"); + if (submitAndNextRequested && activeFilter === "queue" && nextQueueMemoryId) { + setStatusText("Label saved. Advancing to next queue memory."); + } else { + setStatusText( + `Label saved. ${payload.summary.total_count} total label${payload.summary.total_count === 1 ? "" : "s"} now recorded for this memory.`, + ); + } + setNote(""); + if (submitAndNextRequested && activeFilter === "queue" && nextQueueMemoryId) { + const priorityQuery = queuePriorityMode + ? `&priority_mode=${encodeURIComponent(queuePriorityMode)}` + : ""; + router.push( + `/memories?filter=queue&memory=${encodeURIComponent(nextQueueMemoryId)}${priorityQuery}`, + ); + } else { + router.refresh(); + } + } catch (error) { + const detail = error instanceof Error ? error.message : "Submission failed"; + setStatusTone("danger"); + setStatusText(`Unable to submit label: ${detail}`); + } finally { + setIsSubmitting(false); + } + } + + if (!memoryId) { + return ( + <SectionCard + eyebrow="Submit label" + title="No memory selected" + description="Select a memory from the list to submit a review label." + > + <EmptyState + title="Label form is disabled" + description="The submission surface activates after one memory is selected." + /> + </SectionCard> + ); + } + + return ( + <SectionCard + eyebrow="Submit label" + title="Apply review label" + description="Label submission is intentional: pick one label, add an optional note, then submit once." + > + <form className="detail-stack" onSubmit={handleSubmit}> + <div className="form-field"> + <label htmlFor="memory-label-value">Review label</label> + <select + id="memory-label-value" + name="memory-label-value" + value={label} + onChange={(event) => setLabel(event.target.value as MemoryReviewLabelValue)} + > + {LABEL_OPTIONS.map((option) => ( + <option key={option.value} value={option.value}> + {option.label} + </option> + ))} + </select> + </div> + + <div className="form-field"> + <label htmlFor="memory-label-note">Reviewer note (optional)</label> + <textarea + id="memory-label-note" + name="memory-label-note" + value={note} + onChange={(event) => setNote(event.target.value)} + placeholder="Capture why this label was chosen for later review." + maxLength={280} + /> + <p className="field-hint">{note.length}/280</p> + </div> + + <div className="composer-actions"> + <div className="composer-status" aria-live="polite"> + <StatusBadge + status={ + isSubmitting + ? "submitting" + : statusTone === "success" + ? "success" + : statusTone === "danger" + ? "error" + : liveModeReady + ? "info" + : "unavailable" + } + label={ + isSubmitting + ? "Submitting" + : statusTone === "success" + ? "Saved" + : statusTone === "danger" + ? "Attention" + : liveModeReady + ? "Ready" + : "Unavailable" + } + /> + <span>{statusText}</span> + </div> + <button + type="submit" + className="button" + disabled={!canSubmit} + onClick={() => { + submitActionRef.current = "submit"; + }} + > + {isSubmitting ? "Submitting..." : "Submit review label"} + </button> + {queueModeWithNext ? ( + <button + type="submit" + value="submit_and_next" + className="button-secondary" + disabled={!canSubmit} + onClick={() => { + submitActionRef.current = "submit_and_next"; + }} + > + {isSubmitting ? "Submitting..." : "Submit and next in queue"} + </button> + ) : null} + </div> + </form> + </SectionCard> + ); +} diff --git a/apps/web/components/memory-label-list.tsx b/apps/web/components/memory-label-list.tsx new file mode 100644 index 0000000..f646d7c --- /dev/null +++ b/apps/web/components/memory-label-list.tsx @@ -0,0 +1,112 @@ +import type { ApiSource, MemoryReviewLabelRecord, MemoryReviewLabelSummary } from "../lib/api"; +import { EmptyState } from "./empty-state"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +type MemoryLabelListProps = { + memoryId: string | null; + labels: MemoryReviewLabelRecord[]; + summary: MemoryReviewLabelSummary | null; + source: ApiSource | "unavailable" | null; + unavailableReason?: string; +}; + +function formatDate(value: string) { + return new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(value)); +} + +export function MemoryLabelList({ + memoryId, + labels, + summary, + source, + unavailableReason, +}: MemoryLabelListProps) { + if (!memoryId) { + return ( + <SectionCard + eyebrow="Review labels" + title="No memory selected" + description="Select one memory to inspect existing review labels and counts." + > + <EmptyState + title="Label review is idle" + description="The selected memory's label summary will appear here." + /> + </SectionCard> + ); + } + + return ( + <SectionCard + eyebrow="Review labels" + title="Existing labels and counts" + description="Labels remain visible and countable so review confidence can be checked before downstream decisions." + > + <div className="detail-stack"> + <div className="cluster"> + <StatusBadge + status={source ?? "unavailable"} + label={ + source === "live" + ? "Live labels" + : source === "fixture" + ? "Fixture labels" + : "Labels unavailable" + } + /> + <span className="meta-pill">{summary?.total_count ?? 0} total labels</span> + </div> + + <div className="attribute-list" aria-label="Label counts"> + <span className="attribute-item">Correct: {summary?.counts_by_label.correct ?? 0}</span> + <span className="attribute-item">Incorrect: {summary?.counts_by_label.incorrect ?? 0}</span> + <span className="attribute-item">Outdated: {summary?.counts_by_label.outdated ?? 0}</span> + <span className="attribute-item"> + Insufficient evidence: {summary?.counts_by_label.insufficient_evidence ?? 0} + </span> + </div> + + {unavailableReason ? ( + <div className="execution-summary__note execution-summary__note--danger"> + <p className="execution-summary__label">Label read</p> + <p>{unavailableReason}</p> + </div> + ) : null} + + {labels.length === 0 ? ( + <EmptyState + title="No labels yet" + description="Use the submission form to add the first review label for this memory." + /> + ) : ( + <div className="list-rows"> + {labels.map((label) => ( + <article key={label.id} className="list-row"> + <div className="list-row__topline"> + <div className="detail-stack"> + <span className="list-row__eyebrow">{formatDate(label.created_at)}</span> + <h3 className="list-row__title">Reviewer {label.reviewer_user_id}</h3> + </div> + <StatusBadge status={label.label} /> + </div> + + <p>{label.note ?? "No reviewer note provided."}</p> + + <div className="list-row__meta"> + <span className="meta-pill mono">{label.id}</span> + <span className="meta-pill mono">{label.memory_id}</span> + </div> + </article> + ))} + </div> + )} + </div> + </SectionCard> + ); +} diff --git a/apps/web/components/memory-list.test.tsx b/apps/web/components/memory-list.test.tsx new file mode 100644 index 0000000..0c0b746 --- /dev/null +++ b/apps/web/components/memory-list.test.tsx @@ -0,0 +1,105 @@ +import React from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { MemoryList } from "./memory-list"; + +vi.mock("next/link", () => ({ + default: ({ + href, + children, + className, + "aria-current": ariaCurrent, + }: { + href: string; + children: React.ReactNode; + className?: string; + "aria-current"?: string; + }) => ( + <a href={href} className={className} aria-current={ariaCurrent}> + {children} + </a> + ), +})); + +const baseMemories = [ + { + id: "memory-1", + memory_key: "user.preference.merchant", + value: { merchant: "Thorne" }, + status: "active" as const, + source_event_ids: ["event-1"], + created_at: "2026-03-17T10:00:00Z", + updated_at: "2026-03-17T10:00:00Z", + deleted_at: null, + }, + { + id: "memory-2", + memory_key: "user.preference.delivery", + value: { window: "weekday_morning" }, + status: "active" as const, + source_event_ids: ["event-2"], + created_at: "2026-03-17T11:00:00Z", + updated_at: "2026-03-17T11:00:00Z", + deleted_at: null, + }, +]; + +describe("MemoryList", () => { + afterEach(() => { + cleanup(); + }); + + it("renders queue-filter links that preserve selected memory state", () => { + render( + <MemoryList + memories={baseMemories} + selectedMemoryId="memory-2" + summary={null} + source="live" + filter="queue" + priorityMode="high_risk_first" + availablePriorityModes={[ + "oldest_first", + "recent_first", + "high_risk_first", + "stale_truth_first", + ]} + />, + ); + + expect(screen.getByRole("link", { name: /user.preference.merchant/i })).toHaveAttribute( + "href", + "/memories?filter=queue&memory=memory-1&priority_mode=high_risk_first", + ); + expect(screen.getByRole("link", { name: /user.preference.delivery/i })).toHaveAttribute( + "href", + "/memories?filter=queue&memory=memory-2&priority_mode=high_risk_first", + ); + expect(screen.getByRole("link", { name: /user.preference.delivery/i })).toHaveAttribute( + "aria-current", + "page", + ); + expect(screen.getByRole("link", { name: "High risk first" })).toHaveAttribute( + "href", + "/memories?filter=queue&priority_mode=high_risk_first", + ); + }); + + it("renders active-filter links without queue params", () => { + render( + <MemoryList + memories={baseMemories} + selectedMemoryId="memory-1" + summary={null} + source="fixture" + filter="active" + />, + ); + + expect(screen.getByRole("link", { name: /user.preference.merchant/i })).toHaveAttribute( + "href", + "/memories?memory=memory-1", + ); + }); +}); diff --git a/apps/web/components/memory-list.tsx b/apps/web/components/memory-list.tsx new file mode 100644 index 0000000..820843b --- /dev/null +++ b/apps/web/components/memory-list.tsx @@ -0,0 +1,162 @@ +import Link from "next/link"; + +import type { + ApiSource, + MemoryReviewListSummary, + MemoryReviewQueuePriorityMode, + MemoryReviewRecord, +} from "../lib/api"; +import { EmptyState } from "./empty-state"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +type MemoryListProps = { + memories: MemoryReviewRecord[]; + selectedMemoryId?: string; + summary: MemoryReviewListSummary | null; + source: ApiSource | "unavailable"; + filter: "active" | "queue"; + priorityMode?: MemoryReviewQueuePriorityMode; + availablePriorityModes?: MemoryReviewQueuePriorityMode[]; + unavailableReason?: string; +}; + +function formatDate(value: string) { + return new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(value)); +} + +function previewValue(value: unknown) { + if (typeof value === "string") { + return value; + } + + const stringified = JSON.stringify(value); + if (!stringified) { + return "No value"; + } + + return stringified.length > 120 ? `${stringified.slice(0, 117)}...` : stringified; +} + +function memoryHref( + memoryId: string, + filter: "active" | "queue", + priorityMode?: MemoryReviewQueuePriorityMode, +) { + if (filter === "queue") { + const priorityQuery = priorityMode ? `&priority_mode=${encodeURIComponent(priorityMode)}` : ""; + return `/memories?filter=queue&memory=${encodeURIComponent(memoryId)}${priorityQuery}`; + } + + return `/memories?memory=${encodeURIComponent(memoryId)}`; +} + +function priorityLabel(mode: MemoryReviewQueuePriorityMode) { + if (mode === "oldest_first") { + return "Oldest first"; + } + if (mode === "recent_first") { + return "Recent first"; + } + if (mode === "high_risk_first") { + return "High risk first"; + } + return "Stale truth first"; +} + +export function MemoryList({ + memories, + selectedMemoryId, + summary, + source, + filter, + priorityMode, + availablePriorityModes, + unavailableReason, +}: MemoryListProps) { + if (memories.length === 0) { + return ( + <SectionCard + eyebrow="Memory list" + title="No memories available" + description="The active memory list is empty for the current filter state." + > + <EmptyState + title={filter === "queue" ? "Review queue is clear" : "No active memories"} + description={ + filter === "queue" + ? "No unlabeled active memories need review right now." + : "Memory records will appear here once admissions are persisted." + } + /> + </SectionCard> + ); + } + + return ( + <SectionCard + eyebrow="Memory list" + title={filter === "queue" ? "Unlabeled review queue" : "Active memory records"} + description="Select one memory to inspect detail, revision history, and review labels without leaving the workspace." + > + <div className="list-panel"> + <div className="list-panel__header"> + <div className="cluster"> + <StatusBadge status={source} label={source === "live" ? "Live list" : source === "fixture" ? "Fixture list" : "List unavailable"} /> + {summary ? <span className="meta-pill">{summary.total_count} total</span> : null} + {summary?.has_more ? <span className="meta-pill">More available</span> : null} + </div> + </div> + + {filter === "queue" && priorityMode && availablePriorityModes?.length ? ( + <div className="cluster"> + {availablePriorityModes.map((mode) => ( + <Link + key={mode} + href={`/memories?filter=queue&priority_mode=${encodeURIComponent(mode)}`} + className={`button-secondary button-secondary--compact${mode === priorityMode ? " is-current" : ""}`} + > + {priorityLabel(mode)} + </Link> + ))} + </div> + ) : null} + + {unavailableReason ? ( + <p className="responsive-note">Live list read failed: {unavailableReason}</p> + ) : null} + + <div className="list-rows"> + {memories.map((memory) => ( + <Link + key={memory.id} + href={memoryHref(memory.id, filter, priorityMode)} + className={`list-row${memory.id === selectedMemoryId ? " is-selected" : ""}`} + aria-current={memory.id === selectedMemoryId ? "page" : undefined} + > + <div className="list-row__topline"> + <div className="detail-stack"> + <span className="list-row__eyebrow">{formatDate(memory.updated_at)}</span> + <h3 className="list-row__title mono">{memory.memory_key}</h3> + </div> + <StatusBadge status={memory.status} /> + </div> + + <p>{previewValue(memory.value)}</p> + + <div className="list-row__meta"> + <span className="meta-pill mono">{memory.id}</span> + <span className="meta-pill">{memory.source_event_ids.length} source events</span> + </div> + </Link> + ))} + </div> + </div> + </SectionCard> + ); +} diff --git a/apps/web/components/memory-quality-gate.test.tsx b/apps/web/components/memory-quality-gate.test.tsx new file mode 100644 index 0000000..436bc88 --- /dev/null +++ b/apps/web/components/memory-quality-gate.test.tsx @@ -0,0 +1,90 @@ +import React from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import { MemoryQualityGate } from "./memory-quality-gate"; + +function summaryWithGate(status: "healthy" | "needs_review" | "insufficient_sample" | "degraded") { + return { + total_memory_count: 12, + active_memory_count: 10, + deleted_memory_count: 2, + labeled_memory_count: 10, + unlabeled_memory_count: 0, + total_label_row_count: 10, + label_row_counts_by_value: { + correct: 9, + incorrect: 1, + outdated: 0, + insufficient_evidence: 0, + }, + label_value_order: ["correct", "incorrect", "outdated", "insufficient_evidence"] as const, + quality_gate: { + status, + precision: status === "degraded" ? 0.7 : 0.9, + precision_target: 0.8, + adjudicated_sample_count: status === "insufficient_sample" ? 4 : 10, + minimum_adjudicated_sample: 10, + remaining_to_minimum_sample: status === "insufficient_sample" ? 6 : 0, + unlabeled_memory_count: status === "needs_review" ? 2 : 0, + high_risk_memory_count: status === "needs_review" ? 1 : 0, + stale_truth_count: 0, + superseded_active_conflict_count: status === "degraded" ? 1 : 0, + counts: { + active_memory_count: 10, + labeled_active_memory_count: status === "needs_review" ? 8 : 10, + adjudicated_correct_count: status === "degraded" ? 7 : 9, + adjudicated_incorrect_count: status === "degraded" ? 3 : 1, + outdated_label_count: status === "degraded" ? 1 : 0, + insufficient_evidence_label_count: 0, + }, + }, + }; +} + +describe("MemoryQualityGate", () => { + afterEach(() => { + cleanup(); + }); + + it("renders healthy readiness from canonical API status", () => { + render(<MemoryQualityGate summarySource="live" summary={summaryWithGate("healthy")} />); + + expect(screen.getByText("Healthy")).toBeInTheDocument(); + expect(screen.getByText("90%")).toBeInTheDocument(); + expect(screen.getByText(/Quality gate is healthy/i)).toBeInTheDocument(); + }); + + it("renders needs-review posture from canonical API status", () => { + render(<MemoryQualityGate summarySource="fixture" summary={summaryWithGate("needs_review")} />); + + expect(screen.getByText("Needs review")).toBeInTheDocument(); + expect(screen.getByText(/Quality gate needs review/i)).toBeInTheDocument(); + expect(screen.getByText(/2 unlabeled, 1 high risk/i)).toBeInTheDocument(); + }); + + it("renders insufficient-sample posture from canonical API status", () => { + render(<MemoryQualityGate summarySource="fixture" summary={summaryWithGate("insufficient_sample")} />); + + expect(screen.getByText("Insufficient sample")).toBeInTheDocument(); + expect(screen.getByText(/insufficient adjudicated sample/i)).toBeInTheDocument(); + expect(screen.getByText(/4\/10 adjudicated labels with 6 remaining/i)).toBeInTheDocument(); + }); + + it("renders degraded posture from canonical API status", () => { + render(<MemoryQualityGate summarySource="fixture" summary={summaryWithGate("degraded")} />); + + expect(screen.getByText("Degraded")).toBeInTheDocument(); + expect(screen.getByText(/Quality gate is degraded/i)).toBeInTheDocument(); + expect(screen.getByText(/superseded active conflicts/i)).toBeInTheDocument(); + }); + + it("renders unavailable-data posture when quality-gate payload is unavailable", () => { + render(<MemoryQualityGate summarySource="unavailable" summary={null} />); + + expect(screen.getByText("Unavailable data")).toBeInTheDocument(); + expect(screen.getByText(/Quality-gate data is unavailable/i)).toBeInTheDocument(); + expect(screen.getByText("Adjudicated sample posture unavailable.")).toBeInTheDocument(); + expect(screen.getByText("Queue and risk posture unavailable.")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/memory-quality-gate.tsx b/apps/web/components/memory-quality-gate.tsx new file mode 100644 index 0000000..a547fc0 --- /dev/null +++ b/apps/web/components/memory-quality-gate.tsx @@ -0,0 +1,129 @@ +import type { ApiSource, MemoryEvaluationSummary } from "../lib/api"; +import { deriveMemoryQualityGate, formatPrecisionPercent } from "../lib/memory-quality"; +import { StatusBadge } from "./status-badge"; + +type MemoryQualityGateProps = { + summary: MemoryEvaluationSummary | null; + summarySource: ApiSource | "unavailable"; +}; + +function statusBadge(status: ReturnType<typeof deriveMemoryQualityGate>["status"]) { + if (status === "healthy") { + return { badgeStatus: "ready", badgeLabel: "Healthy" }; + } + + if (status === "needs_review") { + return { badgeStatus: "requires_review", badgeLabel: "Needs review" }; + } + + if (status === "insufficient_sample") { + return { badgeStatus: "insufficient_evidence", badgeLabel: "Insufficient sample" }; + } + + if (status === "degraded") { + return { badgeStatus: "error", badgeLabel: "Degraded" }; + } + + return { badgeStatus: "unavailable", badgeLabel: "Unavailable data" }; +} + +function interpretationCopy(gate: ReturnType<typeof deriveMemoryQualityGate>) { + if (gate.status === "healthy") { + return "Quality gate is healthy: precision target is met, sample minimum is met, and no blocking risk posture remains."; + } + + if (gate.status === "needs_review") { + return "Quality gate needs review: precision threshold is met, but unresolved queue risk still blocks healthy posture."; + } + + if (gate.status === "insufficient_sample") { + return "Quality gate is blocked by insufficient adjudicated sample for reliable precision posture."; + } + + if (gate.status === "degraded") { + return "Quality gate is degraded: precision is below target or active supersession conflicts remain unresolved."; + } + + return "Quality-gate data is unavailable, so readiness cannot be determined."; +} + +function sampleProgressCopy(gate: ReturnType<typeof deriveMemoryQualityGate>) { + if (gate.adjudicatedSampleCount === null || gate.minimumAdjudicatedSample === null) { + return "Adjudicated sample posture unavailable."; + } + + if (gate.remainingToMinimumSample === 0) { + return `Sample posture: ${gate.adjudicatedSampleCount}/${gate.minimumAdjudicatedSample} adjudicated labels. Minimum sample is met.`; + } + + return `Sample posture: ${gate.adjudicatedSampleCount}/${gate.minimumAdjudicatedSample} adjudicated labels with ${gate.remainingToMinimumSample} remaining to minimum.`; +} + +function queueRiskCopy(gate: ReturnType<typeof deriveMemoryQualityGate>) { + if ( + gate.unlabeledQueueCount === null || + gate.highRiskMemoryCount === null || + gate.staleTruthCount === null || + gate.supersededActiveConflictCount === null + ) { + return "Queue and risk posture unavailable."; + } + + return `Queue/risk posture: ${gate.unlabeledQueueCount} unlabeled, ${gate.highRiskMemoryCount} high risk, ${gate.staleTruthCount} stale truth, ${gate.supersededActiveConflictCount} superseded active conflicts.`; +} + +function formatPercentTarget(value: number | null) { + if (value === null) { + return "—"; + } + return `${Math.round(value * 100)}%`; +} + +export function MemoryQualityGate({ summary, summarySource }: MemoryQualityGateProps) { + const gate = deriveMemoryQualityGate(summarySource === "unavailable" ? null : summary); + const badge = statusBadge(gate.status); + + return ( + <section className="detail-group detail-group--muted memory-quality-gate"> + <div className="memory-quality-gate__topline"> + <div className="detail-stack"> + <p className="execution-summary__label">Memory-quality gate</p> + <h3>Ship-gate readiness</h3> + </div> + <StatusBadge status={badge.badgeStatus} label={badge.badgeLabel} /> + </div> + + <dl className="key-value-grid key-value-grid--compact"> + <div> + <dt>Precision</dt> + <dd>{formatPrecisionPercent(gate.precision)}</dd> + </div> + <div> + <dt>Adjudicated sample</dt> + <dd>{gate.adjudicatedSampleCount ?? "—"}</dd> + </div> + <div> + <dt>Unlabeled queue</dt> + <dd>{gate.unlabeledQueueCount ?? "—"}</dd> + </div> + <div> + <dt>Remaining to minimum sample</dt> + <dd>{gate.remainingToMinimumSample ?? "—"}</dd> + </div> + </dl> + + <div className="memory-quality-gate__copy"> + <p>{interpretationCopy(gate)}</p> + <p>{sampleProgressCopy(gate)}</p> + <p>{queueRiskCopy(gate)}</p> + </div> + + <div className="cluster"> + <span className="meta-pill">Precision target: {formatPercentTarget(gate.precisionTarget)}</span> + <span className="meta-pill"> + Minimum adjudicated sample: {gate.minimumAdjudicatedSample ?? "—"} + </span> + </div> + </section> + ); +} diff --git a/apps/web/components/memory-revision-list.tsx b/apps/web/components/memory-revision-list.tsx new file mode 100644 index 0000000..d555c46 --- /dev/null +++ b/apps/web/components/memory-revision-list.tsx @@ -0,0 +1,129 @@ +import type { + ApiSource, + MemoryRevisionReviewListSummary, + MemoryRevisionReviewRecord, +} from "../lib/api"; +import { EmptyState } from "./empty-state"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +type MemoryRevisionListProps = { + memoryId: string | null; + revisions: MemoryRevisionReviewRecord[]; + summary: MemoryRevisionReviewListSummary | null; + source: ApiSource | "unavailable" | null; + unavailableReason?: string; +}; + +function formatDate(value: string) { + return new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(value)); +} + +function formatValue(value: unknown | null) { + if (value == null) { + return "null"; + } + + if (typeof value === "string") { + return value; + } + + return JSON.stringify(value, null, 2) ?? "null"; +} + +export function MemoryRevisionList({ + memoryId, + revisions, + summary, + source, + unavailableReason, +}: MemoryRevisionListProps) { + if (!memoryId) { + return ( + <SectionCard + eyebrow="Revision history" + title="No memory selected" + description="Select a memory to inspect ordered revision history." + > + <EmptyState + title="Revision review is idle" + description="Revision records appear here after one memory is selected." + /> + </SectionCard> + ); + } + + return ( + <SectionCard + eyebrow="Revision history" + title="Ordered revisions" + description="Revisions remain chronologically bounded so operators can verify how each memory changed over time." + > + <div className="detail-stack"> + <div className="cluster"> + <StatusBadge + status={source ?? "unavailable"} + label={ + source === "live" + ? "Live revisions" + : source === "fixture" + ? "Fixture revisions" + : "Revisions unavailable" + } + /> + {summary ? <span className="meta-pill">{summary.total_count} revisions</span> : null} + {summary?.has_more ? <span className="meta-pill">More revisions available</span> : null} + </div> + + {unavailableReason ? ( + <div className="execution-summary__note execution-summary__note--danger"> + <p className="execution-summary__label">Revision read</p> + <p>{unavailableReason}</p> + </div> + ) : null} + + {revisions.length === 0 ? ( + <EmptyState + title="No revisions returned" + description="No revision records were returned for the selected memory." + /> + ) : ( + <div className="timeline-list"> + {revisions.map((revision) => ( + <article key={revision.id} className="timeline-item"> + <div className="timeline-item__topline"> + <div className="detail-stack"> + <span className="list-row__eyebrow">Sequence {revision.sequence_no}</span> + <h3 className="list-row__title mono">{revision.memory_key}</h3> + </div> + <StatusBadge status={revision.action.toLowerCase()} label={revision.action} /> + </div> + + <div className="timeline-item__meta"> + <span className="meta-pill">{formatDate(revision.created_at)}</span> + <span className="meta-pill">{revision.source_event_ids.length} source events</span> + </div> + + <div className="key-value-grid key-value-grid--compact"> + <div> + <dt>Previous value</dt> + <dd className="mono">{formatValue(revision.previous_value)}</dd> + </div> + <div> + <dt>New value</dt> + <dd className="mono">{formatValue(revision.new_value)}</dd> + </div> + </div> + </article> + ))} + </div> + )} + </div> + </SectionCard> + ); +} diff --git a/apps/web/components/memory-summary.test.tsx b/apps/web/components/memory-summary.test.tsx new file mode 100644 index 0000000..0692109 --- /dev/null +++ b/apps/web/components/memory-summary.test.tsx @@ -0,0 +1,139 @@ +import React from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { MemorySummary } from "./memory-summary"; + +vi.mock("next/link", () => ({ + default: ({ + href, + children, + className, + }: { + href: string; + children: React.ReactNode; + className?: string; + }) => ( + <a href={href} className={className}> + {children} + </a> + ), +})); + +describe("MemorySummary", () => { + afterEach(() => { + cleanup(); + }); + + it("renders quality gate metrics and preserves queue/active filter controls", () => { + render( + <MemorySummary + summary={{ + total_memory_count: 10, + active_memory_count: 9, + deleted_memory_count: 1, + labeled_memory_count: 10, + unlabeled_memory_count: 2, + total_label_row_count: 10, + label_row_counts_by_value: { + correct: 8, + incorrect: 2, + outdated: 0, + insufficient_evidence: 0, + }, + label_value_order: ["correct", "incorrect", "outdated", "insufficient_evidence"], + quality_gate: { + status: "healthy", + precision: 0.9, + precision_target: 0.8, + adjudicated_sample_count: 12, + minimum_adjudicated_sample: 10, + remaining_to_minimum_sample: 0, + unlabeled_memory_count: 2, + high_risk_memory_count: 0, + stale_truth_count: 0, + superseded_active_conflict_count: 0, + counts: { + active_memory_count: 9, + labeled_active_memory_count: 7, + adjudicated_correct_count: 6, + adjudicated_incorrect_count: 1, + outdated_label_count: 0, + insufficient_evidence_label_count: 0, + }, + }, + }} + summarySource="live" + queueSummary={{ + memory_status: "active", + review_state: "unlabeled", + limit: 20, + returned_count: 2, + total_count: 2, + has_more: false, + order: ["updated_at_desc", "created_at_desc", "id_desc"], + }} + queueSource="live" + activeFilter="active" + />, + ); + + expect(screen.getByText("Ship-gate readiness")).toBeInTheDocument(); + expect(screen.getByText("Healthy")).toBeInTheDocument(); + expect( + screen.getByText( + "Quality gate is healthy: precision target is met, sample minimum is met, and no blocking risk posture remains.", + ), + ).toBeInTheDocument(); + expect(screen.getByText("Sample posture: 12/10 adjudicated labels. Minimum sample is met.")).toBeInTheDocument(); + expect( + screen.getByText( + "Queue/risk posture: 2 unlabeled, 0 high risk, 0 stale truth, 0 superseded active conflicts.", + ), + ).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Active list" })).toHaveAttribute("href", "/memories"); + expect(screen.getByRole("link", { name: "Unlabeled queue" })).toHaveAttribute( + "href", + "/memories?filter=queue", + ); + }); + + it("keeps source fallback notes explicit", () => { + render( + <MemorySummary + summary={{ + total_memory_count: 3, + active_memory_count: 3, + deleted_memory_count: 0, + labeled_memory_count: 1, + unlabeled_memory_count: 2, + total_label_row_count: 1, + label_row_counts_by_value: { + correct: 1, + incorrect: 0, + outdated: 0, + insufficient_evidence: 0, + }, + label_value_order: ["correct", "incorrect", "outdated", "insufficient_evidence"], + }} + summarySource="fixture" + summaryUnavailableReason="summary down" + queueSummary={{ + memory_status: "active", + review_state: "unlabeled", + limit: 20, + returned_count: 2, + total_count: 2, + has_more: false, + order: ["updated_at_desc", "created_at_desc", "id_desc"], + }} + queueSource="fixture" + queueUnavailableReason="queue down" + activeFilter="queue" + />, + ); + + expect(screen.getByText("Summary: summary down")).toBeInTheDocument(); + expect(screen.getByText("Queue: queue down")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/memory-summary.tsx b/apps/web/components/memory-summary.tsx new file mode 100644 index 0000000..a00cde0 --- /dev/null +++ b/apps/web/components/memory-summary.tsx @@ -0,0 +1,103 @@ +import Link from "next/link"; + +import type { ApiSource, MemoryEvaluationSummary, MemoryReviewQueueSummary } from "../lib/api"; +import { MemoryQualityGate } from "./memory-quality-gate"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +type MemorySummaryProps = { + summary: MemoryEvaluationSummary | null; + summarySource: ApiSource | "unavailable"; + summaryUnavailableReason?: string; + queueSummary: MemoryReviewQueueSummary | null; + queueSource: ApiSource | "unavailable"; + queueUnavailableReason?: string; + activeFilter: "active" | "queue"; +}; + +function sourceLabel(source: ApiSource | "unavailable") { + if (source === "live") { + return "Live"; + } + + if (source === "fixture") { + return "Fixture"; + } + + return "Unavailable"; +} + +export function MemorySummary({ + summary, + summarySource, + summaryUnavailableReason, + queueSummary, + queueSource, + queueUnavailableReason, + activeFilter, +}: MemorySummaryProps) { + return ( + <SectionCard + eyebrow="Memory summary" + title="Evaluation and review posture" + description="Keep memory review grounded in one bounded summary before diving into item-level detail." + > + <div className="detail-stack"> + <div className="cluster"> + <StatusBadge status={summarySource} label={`Summary ${sourceLabel(summarySource)}`} /> + <StatusBadge status={queueSource} label={`Queue ${sourceLabel(queueSource)}`} /> + <span className="meta-pill"> + {queueSummary?.total_count ?? 0} unlabeled in review queue + </span> + </div> + + {summaryUnavailableReason || queueUnavailableReason ? ( + <div className="execution-summary__note execution-summary__note--danger"> + <p className="execution-summary__label">Live source notes</p> + {summaryUnavailableReason ? <p>Summary: {summaryUnavailableReason}</p> : null} + {queueUnavailableReason ? <p>Queue: {queueUnavailableReason}</p> : null} + </div> + ) : null} + + <dl className="key-value-grid key-value-grid--compact"> + <div> + <dt>Total memories</dt> + <dd>{summary?.total_memory_count ?? 0}</dd> + </div> + <div> + <dt>Active</dt> + <dd>{summary?.active_memory_count ?? 0}</dd> + </div> + <div> + <dt>Labeled</dt> + <dd>{summary?.labeled_memory_count ?? 0}</dd> + </div> + <div> + <dt>Unlabeled</dt> + <dd>{summary?.unlabeled_memory_count ?? 0}</dd> + </div> + </dl> + + <MemoryQualityGate + summary={summary} + summarySource={summarySource} + /> + + <div className="cluster"> + <Link + href="/memories" + className={`button-secondary button-secondary--compact${activeFilter === "active" ? " is-current" : ""}`} + > + Active list + </Link> + <Link + href="/memories?filter=queue" + className={`button-secondary button-secondary--compact${activeFilter === "queue" ? " is-current" : ""}`} + > + Unlabeled queue + </Link> + </div> + </div> + </SectionCard> + ); +} diff --git a/apps/web/components/mode-toggle.tsx b/apps/web/components/mode-toggle.tsx new file mode 100644 index 0000000..4560671 --- /dev/null +++ b/apps/web/components/mode-toggle.tsx @@ -0,0 +1,59 @@ +import Link from "next/link"; + +export type ChatMode = "assistant" | "request"; + +type ModeToggleProps = { + currentMode: ChatMode; + selectedThreadId?: string; +}; + +const MODE_ITEMS: Array<{ + mode: ChatMode; + label: string; + description: string; +}> = [ + { + mode: "assistant", + label: "Ask the assistant", + description: "Normal ask-and-answer interaction through the shipped response seam.", + }, + { + mode: "request", + label: "Submit a governed request", + description: "Approval-gated action submission through the existing request seam.", + }, +]; + +export function ModeToggle({ currentMode, selectedThreadId }: ModeToggleProps) { + return ( + <nav className="mode-toggle" aria-label="Chat mode"> + {MODE_ITEMS.map((item) => { + const isActive = item.mode === currentMode; + const params = new URLSearchParams(); + + if (item.mode === "request") { + params.set("mode", item.mode); + } + + if (selectedThreadId) { + params.set("thread", selectedThreadId); + } + + const query = params.toString(); + const href = query ? `/chat?${query}` : "/chat"; + + return ( + <Link + key={item.mode} + href={href} + className={["mode-toggle__item", isActive ? "is-active" : ""].filter(Boolean).join(" ")} + aria-current={isActive ? "page" : undefined} + > + <span className="mode-toggle__label">{item.label}</span> + <span className="mode-toggle__description">{item.description}</span> + </Link> + ); + })} + </nav> + ); +} diff --git a/apps/web/components/page-header.tsx b/apps/web/components/page-header.tsx new file mode 100644 index 0000000..49fc524 --- /dev/null +++ b/apps/web/components/page-header.tsx @@ -0,0 +1,21 @@ +import type { ReactNode } from "react"; + +type PageHeaderProps = { + eyebrow?: string; + title: string; + description: string; + meta?: ReactNode; +}; + +export function PageHeader({ eyebrow, title, description, meta }: PageHeaderProps) { + return ( + <header className="page-header"> + <div className="page-header__copy"> + {eyebrow ? <p className="eyebrow">{eyebrow}</p> : null} + <h1>{title}</h1> + <p>{description}</p> + </div> + {meta ? <div>{meta}</div> : null} + </header> + ); +} diff --git a/apps/web/components/request-composer.test.tsx b/apps/web/components/request-composer.test.tsx new file mode 100644 index 0000000..1a01ae8 --- /dev/null +++ b/apps/web/components/request-composer.test.tsx @@ -0,0 +1,171 @@ +import React from "react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { RequestComposer } from "./request-composer"; + +const { submitApprovalRequestMock } = vi.hoisted(() => ({ + submitApprovalRequestMock: vi.fn(), +})); + +vi.mock("next/link", () => ({ + default: ({ + href, + children, + className, + }: { + href: string; + children: React.ReactNode; + className?: string; + }) => ( + <a href={href} className={className}> + {children} + </a> + ), +})); + +vi.mock("../lib/api", async () => { + const actual = await vi.importActual("../lib/api"); + return { + ...actual, + submitApprovalRequest: submitApprovalRequestMock, + }; +}); + +describe("RequestComposer", () => { + beforeEach(() => { + submitApprovalRequestMock.mockReset(); + }); + + afterEach(() => { + cleanup(); + }); + + it("submits governed requests against the selected thread", async () => { + submitApprovalRequestMock.mockResolvedValue({ + request: { + thread_id: "thread-1", + tool_id: "tool-1", + action: "place_order", + scope: "supplements", + domain_hint: "ecommerce", + risk_hint: "purchase", + attributes: { + merchant: "Thorne", + item: "Magnesium Bisglycinate", + quantity: "1", + }, + }, + decision: "approval_required", + tool: { + id: "tool-1", + tool_key: "merchant_proxy", + name: "Merchant Proxy", + description: "Proxy", + version: "0.1.0", + metadata_version: "tool_metadata_v0", + active: true, + tags: [], + action_hints: [], + scope_hints: [], + domain_hints: [], + risk_hints: [], + metadata: {}, + created_at: "2026-03-17T00:00:00Z", + }, + reasons: [], + task: { + id: "task-1", + thread_id: "thread-1", + tool_id: "tool-1", + status: "pending_approval", + request: { + thread_id: "thread-1", + tool_id: "tool-1", + action: "place_order", + scope: "supplements", + domain_hint: "ecommerce", + risk_hint: "purchase", + attributes: { + merchant: "Thorne", + item: "Magnesium Bisglycinate", + quantity: "1", + }, + }, + tool: { + id: "tool-1", + tool_key: "merchant_proxy", + name: "Merchant Proxy", + description: "Proxy", + version: "0.1.0", + metadata_version: "tool_metadata_v0", + active: true, + tags: [], + action_hints: [], + scope_hints: [], + domain_hints: [], + risk_hints: [], + metadata: {}, + created_at: "2026-03-17T00:00:00Z", + }, + latest_approval_id: "approval-1", + latest_execution_id: null, + created_at: "2026-03-17T00:00:00Z", + updated_at: "2026-03-17T00:00:00Z", + }, + approval: null, + routing_trace: { + trace_id: "route-trace-1", + trace_event_count: 3, + }, + trace: { + trace_id: "request-trace-1", + trace_event_count: 6, + }, + }); + + render( + <RequestComposer + initialEntries={[]} + apiBaseUrl="https://api.example.com" + userId="user-1" + selectedThreadId="thread-1" + selectedThreadTitle="Gamma thread" + defaultToolId="tool-1" + />, + ); + + fireEvent.click(screen.getByRole("button", { name: "Submit governed request" })); + + await waitFor(() => { + expect(submitApprovalRequestMock).toHaveBeenCalledWith("https://api.example.com", { + user_id: "user-1", + thread_id: "thread-1", + tool_id: "tool-1", + action: "place_order", + scope: "supplements", + domain_hint: "ecommerce", + risk_hint: "purchase", + attributes: { + merchant: "Thorne", + item: "Magnesium Bisglycinate", + quantity: "1", + }, + }); + }); + + expect(screen.getByText(/Governed request submitted successfully/i)).toBeInTheDocument(); + }); + + it("disables governed submission when no thread is selected", () => { + render( + <RequestComposer + initialEntries={[]} + defaultToolId="tool-1" + />, + ); + + expect(screen.getByRole("button", { name: "Submit governed request" })).toBeDisabled(); + expect(screen.getByText(/Select or create a thread from the right rail/i)).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/request-composer.tsx b/apps/web/components/request-composer.tsx new file mode 100644 index 0000000..dab5f94 --- /dev/null +++ b/apps/web/components/request-composer.tsx @@ -0,0 +1,384 @@ +"use client"; + +import Link from "next/link"; +import type { FormEvent } from "react"; +import { useState } from "react"; + +import type { ApprovalRequestPayload, RequestHistoryEntry } from "../lib/api"; +import { submitApprovalRequest } from "../lib/api"; +import { buildFixtureRequestEntry } from "../lib/fixtures"; +import { EmptyState } from "./empty-state"; +import { StatusBadge } from "./status-badge"; + +type RequestComposerProps = { + initialEntries: RequestHistoryEntry[]; + apiBaseUrl?: string; + userId?: string; + selectedThreadId?: string; + selectedThreadTitle?: string; + defaultToolId?: string; +}; + +function formatDate(value: string) { + return new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(value)); +} + +export function RequestComposer({ + initialEntries, + apiBaseUrl, + userId, + selectedThreadId, + selectedThreadTitle, + defaultToolId, +}: RequestComposerProps) { + const activeThreadId = selectedThreadId?.trim() ?? ""; + const [toolId, setToolId] = useState(defaultToolId ?? ""); + const [action, setAction] = useState("place_order"); + const [scope, setScope] = useState("supplements"); + const [domainHint, setDomainHint] = useState("ecommerce"); + const [riskHint, setRiskHint] = useState("purchase"); + const [attributesText, setAttributesText] = useState( + JSON.stringify( + { + merchant: "Thorne", + item: "Magnesium Bisglycinate", + quantity: "1", + }, + null, + 2, + ), + ); + const [entries, setEntries] = useState(initialEntries); + const [statusText, setStatusText] = useState( + activeThreadId + ? "Ready to submit a governed approval request for the selected thread." + : "Select a thread before submitting a governed approval request.", + ); + const [statusTone, setStatusTone] = useState<"info" | "success" | "danger">("info"); + const [isSubmitting, setIsSubmitting] = useState(false); + + const liveModeReady = Boolean(apiBaseUrl && userId); + const visibleEntries = activeThreadId + ? entries.filter((entry) => entry.threadId === activeThreadId) + : []; + + async function handleSubmit(event: FormEvent<HTMLFormElement>) { + event.preventDefault(); + + const nextToolId = toolId.trim(); + const nextAction = action.trim(); + const nextScope = scope.trim(); + + if (!activeThreadId || !nextToolId || !nextAction || !nextScope) { + setStatusTone("danger"); + setStatusText("Select a thread, then provide tool ID, action, and scope."); + return; + } + + let attributes: Record<string, unknown>; + try { + const parsed = JSON.parse(attributesText); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Attributes must be a JSON object."); + } + attributes = parsed as Record<string, unknown>; + } catch (error) { + setStatusTone("danger"); + setStatusText(error instanceof Error ? error.message : "Attributes JSON is invalid."); + return; + } + + const payload: ApprovalRequestPayload = { + user_id: userId ?? "fixture-user", + thread_id: activeThreadId, + tool_id: nextToolId, + action: nextAction, + scope: nextScope, + domain_hint: domainHint.trim() || null, + risk_hint: riskHint.trim() || null, + attributes, + }; + + setStatusTone("info"); + setStatusText( + liveModeReady + ? "Submitting governed request through the approval-request endpoint..." + : "Preparing a fixture-backed governed request preview...", + ); + setIsSubmitting(true); + + if (!liveModeReady) { + const entry = buildFixtureRequestEntry(payload); + setEntries((current) => [entry, ...current]); + setStatusTone("success"); + setStatusText( + "Fixture request summary added. Configure the web API base URL and user ID to persist live approvals and tasks.", + ); + setIsSubmitting(false); + return; + } + + try { + const response = await submitApprovalRequest(apiBaseUrl!, payload); + const entry: RequestHistoryEntry = { + id: response.trace.trace_id, + submittedAt: new Date().toISOString(), + source: "live", + threadId: response.request.thread_id, + toolId: response.request.tool_id, + toolName: response.tool.name, + action: response.request.action, + scope: response.request.scope, + domainHint: response.request.domain_hint, + riskHint: response.request.risk_hint, + attributes: response.request.attributes, + decision: response.decision, + taskId: response.task.id, + taskStatus: response.task.status, + approvalId: response.approval?.id ?? null, + approvalStatus: response.approval?.status ?? null, + summary: response.approval + ? "The request persisted an approval and downstream task state through the shipped governed workflow." + : "The request was routed without a persisted approval record and still returned downstream task state.", + reasons: response.reasons.map((reason) => reason.message), + trace: { + routingTraceId: response.routing_trace.trace_id, + routingTraceEventCount: response.routing_trace.trace_event_count, + requestTraceId: response.trace.trace_id, + requestTraceEventCount: response.trace.trace_event_count, + }, + }; + + setEntries((current) => [entry, ...current]); + setStatusTone("success"); + setStatusText("Governed request submitted successfully. Approval and task linkage are now visible below."); + } catch (error) { + const detail = error instanceof Error ? error.message : "Request failed"; + setStatusTone("danger"); + setStatusText(`Unable to submit live request: ${detail}`); + } finally { + setIsSubmitting(false); + } + } + + return ( + <section className="composer-card composer-card--request"> + <div className="composer-card__header composer-card__header--tight"> + <div className="governance-banner"> + <strong>{liveModeReady ? "Live operator mode" : "Fixture operator mode"}</strong> + <span>Requests stay explicitly governed and the resulting approval, task, and trace links remain attached.</span> + </div> + + <div className="selected-thread-panel"> + <div className="selected-thread-panel__copy"> + <span className="history-entry__label">Linked thread</span> + <h2 className="composer-title">{selectedThreadTitle ?? "Choose a visible thread"}</h2> + <p className="field-hint"> + {activeThreadId + ? "Governed requests stay explicitly linked to the selected continuity record." + : "Select or create a thread from the right rail before submitting a governed request."} + </p> + </div> + {activeThreadId ? <span className="meta-pill mono">{activeThreadId}</span> : null} + </div> + + <div className="form-field-group form-field-group--two-up"> + <div className="form-field"> + <label htmlFor="tool-id">Tool ID</label> + <input + id="tool-id" + name="tool-id" + value={toolId} + onChange={(event) => setToolId(event.target.value)} + placeholder="Tool UUID" + /> + </div> + </div> + + <div className="composer-intro"> + <p className="eyebrow">Governed request</p> + <h2 className="composer-title">Submit an approval-gated action</h2> + <p className="field-hint"> + Use the existing approval-request seam for consequential actions while the selected-thread transcript above remains the durable conversation record. + </p> + </div> + </div> + + <form className="detail-stack" onSubmit={handleSubmit}> + <div className="form-field-group form-field-group--two-up"> + <div className="form-field"> + <label htmlFor="governed-action">Action</label> + <input + id="governed-action" + name="governed-action" + value={action} + onChange={(event) => setAction(event.target.value)} + placeholder="place_order" + /> + </div> + <div className="form-field"> + <label htmlFor="governed-scope">Scope</label> + <input + id="governed-scope" + name="governed-scope" + value={scope} + onChange={(event) => setScope(event.target.value)} + placeholder="supplements" + /> + </div> + <div className="form-field"> + <label htmlFor="domain-hint">Domain hint</label> + <input + id="domain-hint" + name="domain-hint" + value={domainHint} + onChange={(event) => setDomainHint(event.target.value)} + placeholder="ecommerce" + /> + </div> + <div className="form-field"> + <label htmlFor="risk-hint">Risk hint</label> + <input + id="risk-hint" + name="risk-hint" + value={riskHint} + onChange={(event) => setRiskHint(event.target.value)} + placeholder="purchase" + /> + </div> + </div> + + <div className="form-field"> + <label htmlFor="request-attributes">Attributes JSON</label> + <textarea + id="request-attributes" + name="request-attributes" + placeholder='{"merchant":"Thorne","item":"Magnesium Bisglycinate","quantity":"1"}' + value={attributesText} + onChange={(event) => setAttributesText(event.target.value)} + /> + </div> + + <div className="composer-actions"> + <div className="composer-status" aria-live="polite"> + <StatusBadge + status={ + isSubmitting + ? "submitting" + : statusTone === "success" + ? "success" + : statusTone === "danger" + ? "error" + : "info" + } + label={ + isSubmitting + ? "Submitting" + : statusTone === "success" + ? "Ready" + : statusTone === "danger" + ? "Attention" + : "Prepared" + } + /> + <span>{statusText}</span> + </div> + <button + type="submit" + className="button" + disabled={ + isSubmitting || !activeThreadId || !toolId.trim() || !action.trim() || !scope.trim() + } + > + {isSubmitting ? "Submitting..." : "Submit governed request"} + </button> + </div> + </form> + + <div className="detail-stack"> + <div className="list-panel__header"> + <div> + <p className="eyebrow">Recent activity</p> + <h2>Recent governed request summaries</h2> + <p>Latest submissions stay grouped with decision, approval linkage, task state, and traces.</p> + </div> + </div> + + {!activeThreadId ? ( + <EmptyState + title="Select a thread first" + description="Governed request summaries appear here after one visible thread is selected and used." + /> + ) : visibleEntries.length === 0 ? ( + <EmptyState + title="No governed requests yet" + description="Submitted requests for the selected thread appear here with approval and task linkage." + /> + ) : ( + <div className="history-list"> + {visibleEntries.map((entry) => ( + <article key={entry.id} className="history-entry"> + <div className="history-entry__topline"> + <div className="detail-stack"> + <span className="history-entry__label"> + {entry.source === "live" ? "Live submission" : "Fixture preview"} + </span> + <h3 className="list-row__title"> + {entry.action} / {entry.scope} + </h3> + </div> + <span className="subtle-chip">{formatDate(entry.submittedAt)}</span> + </div> + + <div className="history-entry__state-row"> + <StatusBadge status={entry.decision} label={`Decision ${entry.decision.replace(/_/g, " ")}`} /> + <StatusBadge status={entry.taskStatus} label={`Task ${entry.taskStatus.replace(/_/g, " ")}`} /> + {entry.approvalStatus ? ( + <StatusBadge + status={entry.approvalStatus} + label={`Approval ${entry.approvalStatus.replace(/_/g, " ")}`} + /> + ) : null} + </div> + + <p>{entry.summary}</p> + + <div className="attribute-list"> + <span className="attribute-item">Thread: {entry.threadId}</span> + <span className="attribute-item">Tool: {entry.toolId}</span> + {entry.domainHint ? <span className="attribute-item">Domain: {entry.domainHint}</span> : null} + {entry.riskHint ? <span className="attribute-item">Risk: {entry.riskHint}</span> : null} + </div> + + <div className="history-entry__trace"> + <span className="meta-pill"> + Route {entry.trace.routingTraceId} · {entry.trace.routingTraceEventCount} events + </span> + <span className="meta-pill"> + Request {entry.trace.requestTraceId} · {entry.trace.requestTraceEventCount} events + </span> + </div> + + <div className="cluster"> + <Link href={`/tasks?task=${entry.taskId}`} className="button-secondary"> + Open task + </Link> + {entry.approvalId ? ( + <Link href={`/approvals?approval=${entry.approvalId}`} className="button-secondary"> + Open approval + </Link> + ) : null} + </div> + </article> + ))} + </div> + )} + </div> + </section> + ); +} diff --git a/apps/web/components/response-composer.test.tsx b/apps/web/components/response-composer.test.tsx new file mode 100644 index 0000000..82bae78 --- /dev/null +++ b/apps/web/components/response-composer.test.tsx @@ -0,0 +1,117 @@ +import React from "react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ResponseComposer } from "./response-composer"; + +const { submitAssistantResponseMock } = vi.hoisted(() => ({ + submitAssistantResponseMock: vi.fn(), +})); + +vi.mock("next/link", () => ({ + default: ({ + href, + children, + className, + }: { + href: string; + children: React.ReactNode; + className?: string; + }) => ( + <a href={href} className={className}> + {children} + </a> + ), +})); + +vi.mock("../lib/api", async () => { + const actual = await vi.importActual("../lib/api"); + return { + ...actual, + submitAssistantResponse: submitAssistantResponseMock, + }; +}); + +describe("ResponseComposer", () => { + beforeEach(() => { + submitAssistantResponseMock.mockReset(); + }); + + afterEach(() => { + cleanup(); + }); + + it("submits assistant messages through the shipped response endpoint", async () => { + submitAssistantResponseMock.mockResolvedValue({ + assistant: { + event_id: "assistant-event-1", + sequence_no: 3, + text: "You prefer oat milk.", + model_provider: "openai_responses", + model: "gpt-5-mini", + }, + trace: { + compile_trace_id: "compile-trace-1", + compile_trace_event_count: 3, + response_trace_id: "response-trace-1", + response_trace_event_count: 2, + }, + }); + + render( + <ResponseComposer + initialEntries={[]} + apiBaseUrl="https://api.example.com" + userId="user-1" + selectedThreadId="thread-1" + selectedThreadTitle="Gamma thread" + />, + ); + + fireEvent.change(screen.getByLabelText("Ask the assistant"), { + target: { value: "What do I usually take in coffee?" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Ask assistant" })); + + await waitFor(() => { + expect(submitAssistantResponseMock).toHaveBeenCalledWith("https://api.example.com", { + user_id: "user-1", + thread_id: "thread-1", + message: "What do I usually take in coffee?", + }); + }); + + expect(await screen.findByText("You prefer oat milk.")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Open compile trace" })).toHaveAttribute( + "href", + "/traces?trace=compile-trace-1", + ); + expect(screen.getByText(/Assistant reply added successfully/i)).toBeInTheDocument(); + }); + + it("adds an explicit fixture preview when live API configuration is absent", async () => { + render( + <ResponseComposer + initialEntries={[]} + selectedThreadId="thread-1" + selectedThreadTitle="Gamma thread" + />, + ); + + fireEvent.change(screen.getByLabelText("Ask the assistant"), { + target: { value: "Summarize the latest thread state." }, + }); + fireEvent.click(screen.getByRole("button", { name: "Ask assistant" })); + + expect(submitAssistantResponseMock).not.toHaveBeenCalled(); + expect(await screen.findByText(/Fixture mode generated a preview response only/i)).toBeInTheDocument(); + expect(screen.getByText(/Fixture response preview added/i)).toBeInTheDocument(); + }); + + it("requires a selected thread before enabling assistant submission", () => { + render(<ResponseComposer initialEntries={[]} />); + + expect(screen.getByRole("button", { name: "Ask assistant" })).toBeDisabled(); + expect(screen.getByText(/Select or create a thread from the right rail/i)).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/response-composer.tsx b/apps/web/components/response-composer.tsx new file mode 100644 index 0000000..8ae61de --- /dev/null +++ b/apps/web/components/response-composer.tsx @@ -0,0 +1,222 @@ +"use client"; + +import type { FormEvent } from "react"; +import { useState } from "react"; + +import type { + AssistantResponsePayload, + ResponseHistoryEntry, + ThreadEventItem, +} from "../lib/api"; +import { submitAssistantResponse } from "../lib/api"; +import { buildFixtureResponseEntry } from "../lib/fixtures"; +import { ResponseHistory } from "./response-history"; +import { StatusBadge } from "./status-badge"; + +type ContinuitySource = "live" | "fixture" | "unavailable"; + +type ResponseComposerProps = { + initialEntries: ResponseHistoryEntry[]; + apiBaseUrl?: string; + userId?: string; + selectedThreadId?: string; + selectedThreadTitle?: string; + events?: ThreadEventItem[]; + source?: ContinuitySource; + unavailableReason?: string; +}; + +export function ResponseComposer({ + initialEntries, + apiBaseUrl, + userId, + selectedThreadId, + selectedThreadTitle, + events = [], + source = "fixture", + unavailableReason, +}: ResponseComposerProps) { + const [message, setMessage] = useState(""); + const [entries, setEntries] = useState(initialEntries); + const [statusText, setStatusText] = useState( + selectedThreadId + ? "Ready to ask the assistant inside the selected thread." + : "Select a thread before sending an assistant message.", + ); + const [statusTone, setStatusTone] = useState<"info" | "success" | "danger">("info"); + const [isSubmitting, setIsSubmitting] = useState(false); + + const liveModeReady = Boolean(apiBaseUrl && userId); + const activeThreadId = selectedThreadId?.trim() ?? ""; + const visibleEntries = activeThreadId + ? entries.filter((entry) => entry.threadId === activeThreadId) + : []; + + async function handleSubmit(event: FormEvent<HTMLFormElement>) { + event.preventDefault(); + + const nextMessage = message.trim(); + + if (!activeThreadId || !nextMessage) { + setStatusTone("danger"); + setStatusText("Select a thread and enter a message before submitting."); + return; + } + + const payload: AssistantResponsePayload = { + user_id: userId ?? "fixture-user", + thread_id: activeThreadId, + message: nextMessage, + }; + + setStatusTone("info"); + setStatusText( + liveModeReady + ? "Submitting the operator message through the assistant response endpoint..." + : "Preparing a fixture-backed assistant response preview...", + ); + setIsSubmitting(true); + + if (!liveModeReady) { + const entry = buildFixtureResponseEntry({ + threadId: activeThreadId, + message: nextMessage, + }); + setEntries((current) => [entry, ...current]); + setMessage(""); + setStatusTone("success"); + setStatusText( + "Fixture response preview added. Configure the web API base URL and user ID to persist assistant replies and traces.", + ); + setIsSubmitting(false); + return; + } + + try { + const response = await submitAssistantResponse(apiBaseUrl!, payload); + const entry: ResponseHistoryEntry = { + id: response.trace.response_trace_id, + submittedAt: new Date().toISOString(), + source: "live", + threadId: activeThreadId, + message: nextMessage, + assistantText: response.assistant.text, + assistantEventId: response.assistant.event_id, + assistantSequenceNo: response.assistant.sequence_no, + modelProvider: response.assistant.model_provider, + model: response.assistant.model, + summary: + "The reply was returned through the shipped response seam and linked to both compile and response traces.", + trace: { + compileTraceId: response.trace.compile_trace_id, + compileTraceEventCount: response.trace.compile_trace_event_count, + responseTraceId: response.trace.response_trace_id, + responseTraceEventCount: response.trace.response_trace_event_count, + }, + }; + + setEntries((current) => [entry, ...current]); + setMessage(""); + setStatusTone("success"); + setStatusText("Assistant reply added successfully. Linked trace summaries are visible alongside the response."); + } catch (error) { + const detail = error instanceof Error ? error.message : "Request failed"; + setStatusTone("danger"); + setStatusText(`Unable to submit assistant message: ${detail}`); + } finally { + setIsSubmitting(false); + } + } + + return ( + <div className="chat-workspace"> + <ResponseHistory + entries={visibleEntries} + threadTitle={selectedThreadTitle} + events={events} + source={source} + unavailableReason={unavailableReason} + /> + + <section className="composer-card composer-card--chat-primary composer-card--assistant"> + <div className="composer-card__header composer-card__header--tight"> + <div className="selected-thread-panel"> + <div className="selected-thread-panel__copy"> + <span className="history-entry__label">Selected thread</span> + <h2 className="composer-title">{selectedThreadTitle ?? "Choose a visible thread"}</h2> + <p className="field-hint"> + {activeThreadId + ? "New assistant replies will stay attached to the selected continuity record." + : "Select or create a thread from the right rail before starting assistant conversation."} + </p> + </div> + {activeThreadId ? <span className="meta-pill mono">{activeThreadId}</span> : null} + </div> + + <div className="governance-banner governance-banner--assistant"> + <strong>{liveModeReady ? "Live assistant mode" : "Fixture assistant mode"}</strong> + <span> + Conversation stays anchored to immutable thread continuity while new assistant responses + still go through `POST /v0/responses`. + </span> + </div> + + <div className="composer-intro"> + <p className="eyebrow">Continue thread</p> + <h2 className="composer-title">Add the next operator message</h2> + <p className="field-hint"> + Keep the composer compact and use the transcript above as the durable reading surface + for the selected thread. + </p> + </div> + </div> + + <form className="detail-stack" onSubmit={handleSubmit}> + <div className="form-field"> + <label htmlFor="assistant-message">Ask the assistant</label> + <textarea + id="assistant-message" + name="assistant-message" + placeholder="Summarize the current thread state, explain the last approval, or answer a normal operator question." + value={message} + onChange={(event) => setMessage(event.target.value)} + /> + </div> + + <div className="composer-actions"> + <div className="composer-status" aria-live="polite"> + <StatusBadge + status={ + isSubmitting + ? "submitting" + : statusTone === "success" + ? "success" + : statusTone === "danger" + ? "error" + : "info" + } + label={ + isSubmitting + ? "Submitting" + : statusTone === "success" + ? "Ready" + : statusTone === "danger" + ? "Attention" + : "Prepared" + } + /> + <span>{statusText}</span> + </div> + <button + type="submit" + className="button" + disabled={isSubmitting || !activeThreadId || !message.trim()} + > + {isSubmitting ? "Asking..." : "Ask assistant"} + </button> + </div> + </form> + </section> + </div> + ); +} diff --git a/apps/web/components/response-history.test.tsx b/apps/web/components/response-history.test.tsx new file mode 100644 index 0000000..0627eeb --- /dev/null +++ b/apps/web/components/response-history.test.tsx @@ -0,0 +1,128 @@ +import React from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { ModeToggle } from "./mode-toggle"; +import { ResponseHistory } from "./response-history"; + +vi.mock("next/link", () => ({ + default: ({ + href, + children, + className, + "aria-current": ariaCurrent, + }: { + href: string; + children: React.ReactNode; + className?: string; + "aria-current"?: string; + }) => ( + <a href={href} className={className} aria-current={ariaCurrent}> + {children} + </a> + ), +})); + +afterEach(() => { + cleanup(); +}); + +describe("ResponseHistory", () => { + it("renders an empty state when the selected thread has no transcript yet", () => { + render( + <ResponseHistory + entries={[]} + events={[]} + threadTitle="Gamma thread" + source="live" + />, + ); + + expect(screen.getByText("No transcript yet")).toBeInTheDocument(); + expect(screen.getByText(/Conversation messages will appear here/i)).toBeInTheDocument(); + }); + + it("renders continuity-derived transcript entries and trace links for local responses", () => { + render( + <ResponseHistory + threadTitle="Gamma thread" + source="live" + entries={[ + { + id: "response-trace-1", + submittedAt: "2026-03-17T08:45:00Z", + source: "live", + threadId: "thread-1", + message: "Summarize the latest thread state.", + assistantText: "The latest governed request is still waiting on approval.", + assistantEventId: "assistant-event-1", + assistantSequenceNo: 3, + modelProvider: "openai_responses", + model: "gpt-5-mini", + summary: "The reply is linked to both compile and response traces.", + trace: { + compileTraceId: "compile-trace-1", + compileTraceEventCount: 3, + responseTraceId: "response-trace-1", + responseTraceEventCount: 2, + }, + }, + ]} + events={[ + { + id: "event-1", + thread_id: "thread-1", + session_id: "session-1", + sequence_no: 1, + kind: "message.user", + payload: { text: "Hello there." }, + created_at: "2026-03-17T08:40:00Z", + }, + { + id: "event-2", + thread_id: "thread-1", + session_id: "session-1", + sequence_no: 2, + kind: "message.assistant", + payload: { + text: "I have the earlier continuity context ready.", + model: { + provider: "openai_responses", + model: "gpt-5-mini", + }, + }, + created_at: "2026-03-17T08:41:00Z", + }, + ]} + />, + ); + + expect(screen.getByText("Selected-thread transcript")).toBeInTheDocument(); + expect(screen.getByText("Hello there.")).toBeInTheDocument(); + expect(screen.getByText("The latest governed request is still waiting on approval.")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Open compile trace" })).toHaveAttribute( + "href", + "/traces?trace=compile-trace-1", + ); + expect(screen.getByRole("link", { name: "Open response trace" })).toHaveAttribute( + "href", + "/traces?trace=response-trace-1", + ); + }); +}); + +describe("ModeToggle", () => { + it("keeps the assistant and governed request modes explicit", () => { + render(<ModeToggle currentMode="request" />); + + expect(screen.getByRole("link", { name: /Ask the assistant/i })).toHaveAttribute("href", "/chat"); + expect(screen.getByRole("link", { name: /Submit a governed request/i })).toHaveAttribute( + "href", + "/chat?mode=request", + ); + expect(screen.getByRole("link", { name: /Submit a governed request/i })).toHaveAttribute( + "aria-current", + "page", + ); + }); +}); diff --git a/apps/web/components/response-history.tsx b/apps/web/components/response-history.tsx new file mode 100644 index 0000000..cd4bf2d --- /dev/null +++ b/apps/web/components/response-history.tsx @@ -0,0 +1,307 @@ +"use client"; + +import Link from "next/link"; + +import type { ResponseHistoryEntry, ThreadEventItem } from "../lib/api"; +import { EmptyState } from "./empty-state"; +import { SectionCard } from "./section-card"; + +type ContinuitySource = "live" | "fixture" | "unavailable"; + +type ResponseHistoryProps = { + entries: ResponseHistoryEntry[]; + events?: ThreadEventItem[]; + threadTitle?: string; + source?: ContinuitySource; + unavailableReason?: string; + traceHrefPrefix?: string; +}; + +type TranscriptItem = { + id: string; + createdAt: string; + text: string; + role: "user" | "assistant"; + sequenceNo?: number; + sessionId?: string | null; + model?: string | null; + modelProvider?: string | null; + sourceLabel: string; + trace?: ResponseHistoryEntry["trace"]; +}; + +const TRANSCRIPT_LIMIT = 16; + +function formatDate(value: string) { + return new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(value)); +} + +function getConversationRole(kind: string) { + if (kind === "message.user" || kind.endsWith(".user")) { + return "user"; + } + + if (kind === "message.assistant" || kind.endsWith(".assistant")) { + return "assistant"; + } + + return null; +} + +function extractTranscriptText(payload: unknown) { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return null; + } + + const record = payload as Record<string, unknown>; + + if (typeof record.text === "string" && record.text.trim()) { + return record.text.trim(); + } + + if (typeof record.summary === "string" && record.summary.trim()) { + return record.summary.trim(); + } + + return null; +} + +function extractAssistantModel(payload: unknown) { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return { model: null, modelProvider: null }; + } + + const record = payload as Record<string, unknown>; + const modelRecord = + record.model && typeof record.model === "object" && !Array.isArray(record.model) + ? (record.model as Record<string, unknown>) + : null; + + return { + model: typeof modelRecord?.model === "string" ? modelRecord.model : null, + modelProvider: typeof modelRecord?.provider === "string" ? modelRecord.provider : null, + }; +} + +function buildContinuityTranscriptItem(event: ThreadEventItem): TranscriptItem | null { + const role = getConversationRole(event.kind); + const text = extractTranscriptText(event.payload); + + if (!role || !text) { + return null; + } + + const assistantModel = role === "assistant" ? extractAssistantModel(event.payload) : null; + + return { + id: event.id, + createdAt: event.created_at, + text, + role, + sequenceNo: event.sequence_no, + sessionId: event.session_id, + model: assistantModel?.model ?? null, + modelProvider: assistantModel?.modelProvider ?? null, + sourceLabel: "Continuity event", + }; +} + +function isTranscriptItem(item: TranscriptItem | null): item is TranscriptItem { + return item !== null; +} + +function buildLocalTranscriptItems(entries: ResponseHistoryEntry[], events: ThreadEventItem[]) { + const persistedAssistantEventIds = new Set( + events + .map((event) => event.id) + .filter((eventId) => typeof eventId === "string" && eventId.length > 0), + ); + + return entries.flatMap<TranscriptItem>((entry) => { + if (entry.assistantEventId && persistedAssistantEventIds.has(entry.assistantEventId)) { + return []; + } + + return [ + { + id: `${entry.id}:user`, + createdAt: entry.submittedAt, + text: entry.message, + role: "user" as const, + sequenceNo: undefined, + sessionId: null, + model: null, + modelProvider: null, + sourceLabel: entry.source === "live" ? "Live response" : "Fixture preview", + trace: undefined, + }, + { + id: `${entry.id}:assistant`, + createdAt: entry.submittedAt, + text: entry.assistantText, + role: "assistant" as const, + sequenceNo: entry.assistantSequenceNo, + sessionId: null, + model: entry.model, + modelProvider: entry.modelProvider, + sourceLabel: entry.source === "live" ? "Live response" : "Fixture preview", + trace: entry.trace, + }, + ]; + }); +} + +function compareTranscriptItems(left: TranscriptItem, right: TranscriptItem) { + const timeDelta = new Date(left.createdAt).getTime() - new Date(right.createdAt).getTime(); + + if (timeDelta !== 0) { + return timeDelta; + } + + if (left.sequenceNo !== undefined && right.sequenceNo !== undefined && left.sequenceNo !== right.sequenceNo) { + return left.sequenceNo - right.sequenceNo; + } + + if (left.role !== right.role) { + return left.role === "user" ? -1 : 1; + } + + return left.id.localeCompare(right.id); +} + +export function ResponseHistory({ + entries, + events = [], + threadTitle, + source = "fixture", + unavailableReason, + traceHrefPrefix, +}: ResponseHistoryProps) { + if (source === "unavailable") { + return ( + <SectionCard + eyebrow="Transcript" + title="Transcript unavailable" + description="The selected thread could not load a readable continuity transcript." + className="section-card--history" + > + <EmptyState + title="Transcript unavailable" + description={unavailableReason ?? "Try again once the continuity API is reachable."} + /> + </SectionCard> + ); + } + + if (!threadTitle) { + return ( + <SectionCard + eyebrow="Transcript" + title="No thread selected" + description="Choose a visible thread before reading or extending the durable conversation record." + className="section-card--history" + > + <EmptyState + title="Select a thread" + description="The selected thread transcript appears here once one continuity record is active." + /> + </SectionCard> + ); + } + + const conversationItems = events.map(buildContinuityTranscriptItem).filter(isTranscriptItem); + const localItems = buildLocalTranscriptItems(entries, events); + const transcriptItems = [...conversationItems, ...localItems].sort(compareTranscriptItems); + const visibleItems = transcriptItems.slice(-TRANSCRIPT_LIMIT); + const hiddenItemCount = transcriptItems.length - visibleItems.length; + + return ( + <SectionCard + eyebrow="Transcript" + title="Selected-thread transcript" + description={ + source === "live" + ? "The conversation surface is derived from immutable continuity events so the selected thread stays the durable source of truth." + : "Fixture mode previews the transcript structure with bounded continuity records and explicit fallbacks." + } + className="section-card--history" + > + <div className="transcript-summary"> + <span className="subtle-chip">Thread: {threadTitle}</span> + <span className="subtle-chip">{transcriptItems.length} conversation entries</span> + <span className="subtle-chip">{source === "live" ? "Live continuity" : "Fixture continuity"}</span> + </div> + + {visibleItems.length === 0 ? ( + <> + <EmptyState + title="No transcript yet" + description="Conversation messages will appear here once the selected thread captures user or assistant continuity events." + /> + <p className="responsive-note">No assistant replies yet</p> + </> + ) : ( + <> + {hiddenItemCount > 0 ? ( + <p className="responsive-note"> + Showing the latest {TRANSCRIPT_LIMIT} conversation entries to keep the transcript + bounded and readable. + </p> + ) : null} + + <div className="transcript-stream"> + {visibleItems.map((item) => ( + <article + key={item.id} + className={["transcript-entry", `transcript-entry--${item.role}`].join(" ")} + > + <div className="transcript-entry__topline"> + <div className="transcript-entry__heading"> + <span className={["transcript-entry__role", `transcript-entry__role--${item.role}`].join(" ")}> + {item.role === "user" ? "Operator" : "Assistant"} + </span> + <span className="history-entry__label">{item.sourceLabel}</span> + </div> + <span className="subtle-chip">{formatDate(item.createdAt)}</span> + </div> + + <p className="transcript-entry__content">{item.text}</p> + + <div className="transcript-entry__footer"> + {item.sequenceNo !== undefined ? ( + <span className="meta-pill">Sequence {item.sequenceNo}</span> + ) : null} + {item.sessionId ? <span className="meta-pill mono">{item.sessionId}</span> : null} + {item.model ? <span className="meta-pill">Model {item.model}</span> : null} + {item.modelProvider ? <span className="meta-pill">Provider {item.modelProvider}</span> : null} + </div> + + {item.trace ? ( + <div className="cluster"> + <Link + href={`${traceHrefPrefix ?? "/traces?trace="}${encodeURIComponent(item.trace.compileTraceId)}`} + className="button-secondary" + > + Open compile trace + </Link> + <Link + href={`${traceHrefPrefix ?? "/traces?trace="}${encodeURIComponent(item.trace.responseTraceId)}`} + className="button-secondary" + > + Open response trace + </Link> + </div> + ) : null} + </article> + ))} + </div> + </> + )} + </SectionCard> + ); +} diff --git a/apps/web/components/resumption-brief.test.tsx b/apps/web/components/resumption-brief.test.tsx new file mode 100644 index 0000000..f70b686 --- /dev/null +++ b/apps/web/components/resumption-brief.test.tsx @@ -0,0 +1,133 @@ +import React from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import { ResumptionBrief } from "./resumption-brief"; + +const briefFixture = { + assembly_version: "continuity_resumption_brief_v0", + scope: { + since: null, + until: null, + }, + last_decision: { + item: { + id: "decision-1", + capture_event_id: "capture-1", + object_type: "Decision" as const, + status: "active", + title: "Decision: Keep rollout phased", + body: { decision_text: "Keep rollout phased" }, + provenance: {}, + confirmation_status: "confirmed" as const, + admission_posture: "DERIVED" as const, + confidence: 1, + relevance: 100, + last_confirmed_at: "2026-03-29T10:00:00Z", + supersedes_object_id: null, + superseded_by_object_id: null, + scope_matches: [], + provenance_references: [], + ordering: { + scope_match_count: 0, + query_term_match_count: 0, + confirmation_rank: 3, + posture_rank: 2, + lifecycle_rank: 4, + confidence: 1, + }, + created_at: "2026-03-29T10:00:00Z", + updated_at: "2026-03-29T10:00:00Z", + }, + empty_state: { + is_empty: false, + message: "No decision found in the requested scope.", + }, + }, + open_loops: { + items: [ + { + id: "loop-1", + capture_event_id: "capture-2", + object_type: "WaitingFor" as const, + status: "active", + title: "Waiting For: Vendor quote", + body: { waiting_for_text: "Vendor quote" }, + provenance: {}, + confirmation_status: "unconfirmed" as const, + admission_posture: "DERIVED" as const, + confidence: 1, + relevance: 95, + last_confirmed_at: null, + supersedes_object_id: null, + superseded_by_object_id: null, + scope_matches: [], + provenance_references: [], + ordering: { + scope_match_count: 0, + query_term_match_count: 0, + confirmation_rank: 2, + posture_rank: 2, + lifecycle_rank: 4, + confidence: 1, + }, + created_at: "2026-03-29T10:10:00Z", + updated_at: "2026-03-29T10:10:00Z", + }, + ], + summary: { + limit: 5, + returned_count: 1, + total_count: 1, + order: ["created_at_desc", "id_desc"], + }, + empty_state: { + is_empty: false, + message: "No open loops found in the requested scope.", + }, + }, + recent_changes: { + items: [], + summary: { + limit: 5, + returned_count: 0, + total_count: 1, + order: ["created_at_desc", "id_desc"], + }, + empty_state: { + is_empty: true, + message: "No recent changes found in the requested scope.", + }, + }, + next_action: { + item: null, + empty_state: { + is_empty: true, + message: "No next action found in the requested scope.", + }, + }, + sources: ["continuity_capture_events", "continuity_objects"], +}; + +describe("ResumptionBrief", () => { + afterEach(() => { + cleanup(); + }); + + it("renders deterministic resumption sections with explicit empty states", () => { + render(<ResumptionBrief brief={briefFixture} source="live" />); + + expect(screen.getByText("Live brief")).toBeInTheDocument(); + expect(screen.getByText("continuity_resumption_brief_v0")).toBeInTheDocument(); + expect(screen.getByText("Decision: Keep rollout phased")).toBeInTheDocument(); + expect(screen.getByText("Waiting For: Vendor quote")).toBeInTheDocument(); + expect(screen.getByText("No recent changes found in the requested scope.")).toBeInTheDocument(); + expect(screen.getByText("No next action found in the requested scope.")).toBeInTheDocument(); + }); + + it("renders fallback empty state when brief payload is absent", () => { + render(<ResumptionBrief brief={null} source="fixture" />); + + expect(screen.getByText("Resumption unavailable")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/resumption-brief.tsx b/apps/web/components/resumption-brief.tsx new file mode 100644 index 0000000..36ba683 --- /dev/null +++ b/apps/web/components/resumption-brief.tsx @@ -0,0 +1,119 @@ +import type { ApiSource, ContinuityResumptionBrief } from "../lib/api"; +import { EmptyState } from "./empty-state"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +type ResumptionBriefProps = { + brief: ContinuityResumptionBrief | null; + source: ApiSource | "unavailable"; + unavailableReason?: string; +}; + +function renderSingleSection( + heading: string, + section: { + item: ContinuityResumptionBrief["last_decision"]["item"]; + empty_state: ContinuityResumptionBrief["last_decision"]["empty_state"]; + }, +) { + if (section.item) { + return ( + <div className="detail-group"> + <h3>{heading}</h3> + <p className="list-row__title">{section.item.title}</p> + <div className="cluster"> + <span className="meta-pill">{section.item.object_type}</span> + <StatusBadge status={section.item.confirmation_status} label={section.item.confirmation_status} /> + </div> + </div> + ); + } + + return ( + <div className="detail-group detail-group--muted"> + <h3>{heading}</h3> + <p className="muted-copy">{section.empty_state.message}</p> + </div> + ); +} + +export function ResumptionBrief({ brief, source, unavailableReason }: ResumptionBriefProps) { + if (brief === null) { + return ( + <SectionCard + eyebrow="Resumption" + title="Resumption brief" + description="Compile deterministic resume artifacts from continuity objects." + > + <EmptyState + title="Resumption unavailable" + description="Resumption brief is not available in this mode yet." + /> + </SectionCard> + ); + } + + return ( + <SectionCard + eyebrow="Resumption" + title="Resumption brief" + description="Deterministic sections are compiled for last decision, open loops, recent changes, and next action." + > + <div className="detail-stack"> + <div className="cluster"> + <StatusBadge + status={source} + label={ + source === "live" + ? "Live brief" + : source === "fixture" + ? "Fixture brief" + : "Brief unavailable" + } + /> + <span className="meta-pill mono">{brief.assembly_version}</span> + </div> + + {unavailableReason ? ( + <p className="responsive-note">Live brief read failed: {unavailableReason}</p> + ) : null} + + {renderSingleSection("Last decision", brief.last_decision)} + + <div className="detail-group"> + <h3>Open loops</h3> + {brief.open_loops.items.length === 0 ? ( + <p className="muted-copy">{brief.open_loops.empty_state.message}</p> + ) : ( + <ul className="detail-stack"> + {brief.open_loops.items.map((item) => ( + <li key={item.id} className="cluster"> + <span className="meta-pill">{item.object_type}</span> + <span>{item.title}</span> + </li> + ))} + </ul> + )} + </div> + + <div className="detail-group"> + <h3>Recent changes</h3> + {brief.recent_changes.items.length === 0 ? ( + <p className="muted-copy">{brief.recent_changes.empty_state.message}</p> + ) : ( + <ul className="detail-stack"> + {brief.recent_changes.items.map((item) => ( + <li key={item.id} className="cluster"> + <span className="meta-pill">{item.object_type}</span> + <span>{item.title}</span> + </li> + ))} + </ul> + )} + </div> + + {renderSingleSection("Next action", brief.next_action)} + </div> + </SectionCard> + ); +} diff --git a/apps/web/components/section-card.tsx b/apps/web/components/section-card.tsx new file mode 100644 index 0000000..227186a --- /dev/null +++ b/apps/web/components/section-card.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from "react"; + +type SectionCardProps = { + eyebrow?: string; + title?: string; + description?: string; + children: ReactNode; + className?: string; +}; + +export function SectionCard({ + eyebrow, + title, + description, + children, + className, +}: SectionCardProps) { + return ( + <section className={["section-card", className].filter(Boolean).join(" ")}> + {(eyebrow || title || description) && ( + <header className="section-card__header"> + {eyebrow ? <p className="eyebrow">{eyebrow}</p> : null} + {title ? <h2 className="section-card__title">{title}</h2> : null} + {description ? <p className="section-card__description">{description}</p> : null} + </header> + )} + {children} + </section> + ); +} diff --git a/apps/web/components/status-badge.test.tsx b/apps/web/components/status-badge.test.tsx new file mode 100644 index 0000000..a7877ac --- /dev/null +++ b/apps/web/components/status-badge.test.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import { StatusBadge } from "./status-badge"; + +describe("StatusBadge", () => { + afterEach(() => { + cleanup(); + }); + + it("maps memory label statuses to expected tones", () => { + const { rerender } = render(<StatusBadge status="correct" />); + expect(screen.getByText("Correct")).toHaveClass("status-badge--success"); + + rerender(<StatusBadge status="incorrect" />); + expect(screen.getByText("Incorrect")).toHaveClass("status-badge--danger"); + + rerender(<StatusBadge status="outdated" />); + expect(screen.getByText("Outdated")).toHaveClass("status-badge--warning"); + }); + + it("renders unavailable as neutral tone", () => { + render(<StatusBadge status="unavailable" />); + + expect(screen.getByText("Unavailable")).toHaveClass("status-badge--neutral"); + }); +}); diff --git a/apps/web/components/status-badge.tsx b/apps/web/components/status-badge.tsx new file mode 100644 index 0000000..eefd29f --- /dev/null +++ b/apps/web/components/status-badge.tsx @@ -0,0 +1,76 @@ +type StatusBadgeProps = { + status: string; + label?: string; +}; + +function normalizeStatus(status: string) { + return status.trim().toLowerCase().replace(/\s+/g, "_"); +} + +function toneForStatus(status: string) { + const normalized = normalizeStatus(status); + + if (["approved", "executed", "completed", "active", "ready", "success", "ingested"].includes(normalized)) { + return "success"; + } + + if (normalized === "correct") { + return "success"; + } + + if ( + [ + "pending", + "pending_approval", + "requires_review", + "created", + "blocked", + "approval_required", + "executing", + "outdated", + "insufficient_evidence", + ].includes(normalized) + ) { + return normalized === "blocked" ? "danger" : "warning"; + } + + if ( + [ + "denied", + "rejected", + "inactive", + "superseded", + "error", + "failed", + "incorrect", + "deleted", + ].includes(normalized) + ) { + return "danger"; + } + + if (["info", "live", "loading", "submitting", "registered"].includes(normalized)) { + return "info"; + } + + if (["fixture", "preview", "draft", "unavailable"].includes(normalized)) { + return "neutral"; + } + + return "neutral"; +} + +function formatLabel(status: string) { + return status + .replace(/_/g, " ") + .split(" ") + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +export function StatusBadge({ status, label }: StatusBadgeProps) { + const tone = toneForStatus(status); + + return <span className={`status-badge status-badge--${tone}`}>{label ?? formatLabel(status)}</span>; +} diff --git a/apps/web/components/task-list.tsx b/apps/web/components/task-list.tsx new file mode 100644 index 0000000..8a84ef9 --- /dev/null +++ b/apps/web/components/task-list.tsx @@ -0,0 +1,78 @@ +import Link from "next/link"; + +import type { TaskItem } from "../lib/api"; +import { EmptyState } from "./empty-state"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +function formatDate(value: string) { + return new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(value)); +} + +export function TaskList({ + tasks, + selectedId, +}: { + tasks: TaskItem[]; + selectedId?: string; +}) { + if (tasks.length === 0) { + return ( + <SectionCard + eyebrow="Tasks" + title="No task records" + description="Tasks appear here when governed work is persisted in the backend." + > + <EmptyState + title="Task list is empty" + description="No task lifecycle records are available in the current mode." + actionHref="/chat" + actionLabel="Open requests" + /> + </SectionCard> + ); + } + + return ( + <SectionCard + eyebrow="Task list" + title="Current governed work" + description="Task rows keep lifecycle state and operator context visible without crowding the primary detail column." + > + <div className="list-panel"> + <div className="list-panel__header"> + <p>{tasks.length} tasks in scope</p> + </div> + <div className="list-rows"> + {tasks.map((task) => ( + <Link + key={task.id} + href={`/tasks?task=${task.id}`} + className={`list-row${task.id === selectedId ? " is-selected" : ""}`} + > + <div className="list-row__topline"> + <div className="detail-stack"> + <span className="list-row__eyebrow">{formatDate(task.updated_at)}</span> + <h3 className="list-row__title">{task.tool.name}</h3> + </div> + <StatusBadge status={task.status} /> + </div> + <p> + {task.request.action} / {task.request.scope} + </p> + <div className="list-row__meta"> + <span className="meta-pill">Task {task.id}</span> + {task.latest_approval_id ? <span className="meta-pill">Approval linked</span> : null} + </div> + </Link> + ))} + </div> + </div> + </SectionCard> + ); +} diff --git a/apps/web/components/task-run-list.test.tsx b/apps/web/components/task-run-list.test.tsx new file mode 100644 index 0000000..6f4c744 --- /dev/null +++ b/apps/web/components/task-run-list.test.tsx @@ -0,0 +1,110 @@ +import React from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import { TaskRunList } from "./task-run-list"; + +const task = { + id: "33333333-3333-4333-8333-333333333333", + thread_id: "11111111-1111-4111-8111-111111111111", + tool_id: "22222222-2222-4222-8222-222222222222", + status: "approved", + request: { + thread_id: "11111111-1111-4111-8111-111111111111", + tool_id: "22222222-2222-4222-8222-222222222222", + action: "tool.run", + scope: "workspace", + domain_hint: null, + risk_hint: null, + attributes: {}, + }, + tool: { + id: "22222222-2222-4222-8222-222222222222", + tool_key: "proxy.echo", + name: "Proxy Echo", + description: "Deterministic proxy handler.", + version: "1.0.0", + metadata_version: "tool_metadata_v0", + active: true, + tags: ["proxy"], + action_hints: ["tool.run"], + scope_hints: ["workspace"], + domain_hints: [], + risk_hints: [], + metadata: {}, + created_at: "2026-03-17T00:00:00Z", + }, + latest_approval_id: "44444444-4444-4444-8444-444444444444", + latest_execution_id: null, + created_at: "2026-03-17T00:00:00Z", + updated_at: "2026-03-17T00:00:00Z", +}; + +describe("TaskRunList", () => { + afterEach(() => { + cleanup(); + }); + + it("shows idle state when no task is selected", () => { + render(<TaskRunList task={null} runs={[]} source="fixture" />); + + expect(screen.getByText("No task selected")).toBeInTheDocument(); + expect(screen.getByText("Run review is idle")).toBeInTheDocument(); + }); + + it("shows unavailable backend state without implying empty runs", () => { + render( + <TaskRunList + task={task} + runs={[]} + source="unavailable" + unavailableMessage="Task-run reads timed out." + />, + ); + + expect(screen.getByText("Run review unavailable")).toBeInTheDocument(); + expect(screen.getByText("Unavailable")).toBeInTheDocument(); + expect(screen.getByText("Task-run reads timed out.")).toBeInTheDocument(); + }); + + it("renders durable run counters, checkpoint state, and stop reason", () => { + render( + <TaskRunList + task={task} + source="live" + runs={[ + { + id: "run-1", + task_id: task.id, + status: "paused", + checkpoint: { + cursor: 1, + target_steps: 3, + wait_for_signal: false, + }, + tick_count: 1, + step_count: 1, + max_ticks: 1, + retry_count: 0, + retry_cap: 1, + retry_posture: "terminal", + failure_class: "budget", + stop_reason: "budget_exhausted", + last_transitioned_at: "2026-03-27T10:05:00Z", + created_at: "2026-03-27T10:00:00Z", + updated_at: "2026-03-27T10:05:00Z", + }, + ]} + />, + ); + + expect(screen.getByText("Durable run review")).toBeInTheDocument(); + expect(screen.getByText("1 runs")).toBeInTheDocument(); + expect(screen.getByText("Live run state")).toBeInTheDocument(); + expect(screen.getByText("Run run-1")).toBeInTheDocument(); + expect(screen.getByText("Tick 1 / 1")).toBeInTheDocument(); + expect(screen.getByText("Stop: budget_exhausted")).toBeInTheDocument(); + expect(screen.getByText("1 / 3")).toBeInTheDocument(); + expect(screen.getByText("false")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/task-run-list.tsx b/apps/web/components/task-run-list.tsx new file mode 100644 index 0000000..b94dd54 --- /dev/null +++ b/apps/web/components/task-run-list.tsx @@ -0,0 +1,188 @@ +import type { ApiSource, TaskItem, TaskRunItem } from "../lib/api"; +import { EmptyState } from "./empty-state"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +function formatDate(value: string) { + return new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(value)); +} + +function checkpointSummary(checkpoint: Record<string, unknown>) { + const cursor = typeof checkpoint.cursor === "number" ? checkpoint.cursor : 0; + const targetSteps = typeof checkpoint.target_steps === "number" ? checkpoint.target_steps : 0; + const waitForSignal = checkpoint.wait_for_signal === true; + + return { + cursor, + targetSteps, + waitForSignal, + waitingApprovalId: + typeof checkpoint.waiting_approval_id === "string" ? checkpoint.waiting_approval_id : null, + resolvedApprovalId: + typeof checkpoint.resolved_approval_id === "string" ? checkpoint.resolved_approval_id : null, + resumedFromApprovalId: + typeof checkpoint.resumed_from_approval_id === "string" ? checkpoint.resumed_from_approval_id : null, + lastExecutionId: + typeof checkpoint.last_execution_id === "string" ? checkpoint.last_execution_id : null, + lastExecutionStatus: + typeof checkpoint.last_execution_status === "string" ? checkpoint.last_execution_status : null, + lastTransition: + checkpoint.last_transition && typeof checkpoint.last_transition === "object" + ? (checkpoint.last_transition as Record<string, unknown>) + : null, + }; +} + +export function TaskRunList({ + task, + runs, + source, + unavailableMessage, +}: { + task: TaskItem | null; + runs: TaskRunItem[]; + source: ApiSource | "unavailable"; + unavailableMessage?: string | null; +}) { + if (!task) { + return ( + <SectionCard + eyebrow="Task runs" + title="No task selected" + description="Select a task to review durable run checkpoints and counter state." + > + <EmptyState + title="Run review is idle" + description="Task-run records are shown when a task is selected." + /> + </SectionCard> + ); + } + + if (source === "unavailable") { + return ( + <SectionCard + eyebrow="Task runs" + title="Run review unavailable" + description="Task-run lifecycle detail could not be loaded from the configured backend." + > + <div className="detail-stack"> + <StatusBadge status="unavailable" label="Unavailable" /> + <p className="muted-copy"> + {unavailableMessage ?? "Task-run records were not available from the current backend response."} + </p> + </div> + </SectionCard> + ); + } + + return ( + <SectionCard + eyebrow="Task runs" + title="Durable run review" + description="Run checkpoints, tick counters, and explicit stop reasons stay visible for deterministic replay and continuation." + className="task-step-list" + > + <div className="cluster"> + <span className="meta-pill">{runs.length} runs</span> + <span className="meta-pill">{source === "live" ? "Live run state" : "Fixture run state"}</span> + </div> + + {runs.length === 0 ? ( + <EmptyState + title="No task runs available" + description="No durable task-run records are available for the selected task in the current source mode." + /> + ) : ( + <div className="timeline-list"> + {runs.map((run) => { + const checkpoint = checkpointSummary(run.checkpoint); + return ( + <article key={run.id} className="timeline-item"> + <div className="timeline-item__topline"> + <div className="detail-stack"> + <span className="list-row__eyebrow">{formatDate(run.updated_at)}</span> + <h3 className="list-row__title">Run {run.id}</h3> + </div> + <StatusBadge status={run.status} /> + </div> + <div className="timeline-item__meta"> + <span className="meta-pill">Tick {run.tick_count} / {run.max_ticks}</span> + <span className="meta-pill">Steps {run.step_count}</span> + <span className="meta-pill">Retry {run.retry_count} / {run.retry_cap}</span> + <span className="meta-pill">Posture: {run.retry_posture}</span> + {run.stop_reason ? <span className="meta-pill">Stop: {run.stop_reason}</span> : null} + {run.failure_class ? ( + <span className="meta-pill">Failure: {run.failure_class}</span> + ) : null} + {checkpoint.lastExecutionStatus ? ( + <span className="meta-pill">Execution: {checkpoint.lastExecutionStatus}</span> + ) : null} + </div> + <div className="key-value-grid"> + <div> + <dt>Checkpoint cursor</dt> + <dd> + {checkpoint.cursor} / {checkpoint.targetSteps} + </dd> + </div> + <div> + <dt>Wait flag</dt> + <dd>{checkpoint.waitForSignal ? "true" : "false"}</dd> + </div> + <div> + <dt>Task</dt> + <dd className="mono">{run.task_id}</dd> + </div> + <div> + <dt>Source</dt> + <dd>{source === "live" ? "Live backend" : "Fixture fallback"}</dd> + </div> + <div> + <dt>Last transition</dt> + <dd>{formatDate(run.last_transitioned_at)}</dd> + </div> + {checkpoint.waitingApprovalId ? ( + <div> + <dt>Pending approval</dt> + <dd className="mono">{checkpoint.waitingApprovalId}</dd> + </div> + ) : null} + {checkpoint.resolvedApprovalId ? ( + <div> + <dt>Resolved approval</dt> + <dd className="mono">{checkpoint.resolvedApprovalId}</dd> + </div> + ) : null} + {checkpoint.resumedFromApprovalId ? ( + <div> + <dt>Resumed from</dt> + <dd className="mono">{checkpoint.resumedFromApprovalId}</dd> + </div> + ) : null} + {checkpoint.lastExecutionId ? ( + <div> + <dt>Last execution</dt> + <dd className="mono">{checkpoint.lastExecutionId}</dd> + </div> + ) : null} + {checkpoint.lastTransition ? ( + <div> + <dt>Transition source</dt> + <dd>{String(checkpoint.lastTransition.source ?? "unknown")}</dd> + </div> + ) : null} + </div> + </article> + ); + })} + </div> + )} + </SectionCard> + ); +} diff --git a/apps/web/components/task-step-list.test.tsx b/apps/web/components/task-step-list.test.tsx new file mode 100644 index 0000000..429c2e4 --- /dev/null +++ b/apps/web/components/task-step-list.test.tsx @@ -0,0 +1,71 @@ +import React from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { getFixtureTaskStepSummary, getFixtureTaskSteps } from "../lib/fixtures"; +import { TaskStepList } from "./task-step-list"; + +vi.mock("next/link", () => ({ + default: ({ + href, + children, + className, + }: { + href: string; + children: React.ReactNode; + className?: string; + }) => ( + <a href={href} className={className}> + {children} + </a> + ), +})); + +describe("TaskStepList", () => { + afterEach(() => { + cleanup(); + }); + + it("renders an ordered task-step timeline with summary pills and linked approval state", () => { + const taskId = "33333333-3333-4333-8333-333333333334"; + render( + <TaskStepList + steps={getFixtureTaskSteps(taskId)} + summary={getFixtureTaskStepSummary(taskId)} + source="fixture" + />, + ); + + expect(screen.getByText("Ordered lifecycle steps")).toBeInTheDocument(); + expect(screen.getByText("1 steps")).toBeInTheDocument(); + expect(screen.getByText("Step 1")).toBeInTheDocument(); + expect(screen.getByText("place_order / supplements")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "approved" })).toHaveAttribute( + "href", + "/approvals?approval=44444444-4444-4444-8444-444444444445", + ); + }); + + it("shows an explicit empty state when the selected task has no steps", () => { + render(<TaskStepList steps={[]} summary={null} source="live" />); + + expect(screen.getByText("No task steps available")).toBeInTheDocument(); + expect(screen.getByText(/Select a task with step records/i)).toBeInTheDocument(); + }); + + it("supports embedded chrome for bounded timeline rendering", () => { + const taskId = "33333333-3333-4333-8333-333333333333"; + const { container } = render( + <TaskStepList + steps={getFixtureTaskSteps(taskId)} + summary={getFixtureTaskStepSummary(taskId)} + source="fixture" + chrome="embedded" + />, + ); + + const card = container.querySelector(".task-step-list"); + expect(card).toHaveClass("section-card--embedded"); + expect(card).toHaveClass("task-step-list--embedded"); + }); +}); diff --git a/apps/web/components/task-step-list.tsx b/apps/web/components/task-step-list.tsx new file mode 100644 index 0000000..3ea395a --- /dev/null +++ b/apps/web/components/task-step-list.tsx @@ -0,0 +1,171 @@ +import Link from "next/link"; + +import type { TaskStepItem, TaskStepListSummary } from "../lib/api"; +import { EmptyState } from "./empty-state"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +function formatAttributeValue(value: unknown) { + if (value == null) { + return "None"; + } + + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + return String(value); + } + + return JSON.stringify(value); +} + +function formatDate(value: string) { + return new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(value)); +} + +export function TaskStepList({ + steps, + summary, + source, + chrome = "card", + traceHrefPrefix, +}: { + steps: TaskStepItem[]; + summary: TaskStepListSummary | null; + source: "live" | "fixture"; + chrome?: "card" | "embedded"; + traceHrefPrefix?: string; +}) { + return ( + <SectionCard + eyebrow="Task steps" + title="Ordered lifecycle steps" + description="The timeline preserves request intent, downstream approval or execution state, and trace linkage in one readable sequence." + className={[ + chrome === "embedded" ? "section-card--embedded" : null, + "task-step-list", + chrome === "embedded" ? "task-step-list--embedded" : null, + ] + .filter(Boolean) + .join(" ")} + > + {summary ? ( + <div className="cluster"> + <span className="meta-pill">{summary.total_count} steps</span> + <span className="meta-pill"> + Latest {summary.latest_sequence_no ?? "none"} / {summary.latest_status ?? "empty"} + </span> + <span className="meta-pill">{source === "live" ? "Live sequencing" : "Fixture sequencing"}</span> + </div> + ) : null} + {steps.length === 0 ? ( + <EmptyState + title="No task steps available" + description="Select a task with step records to inspect ordered lifecycle detail." + /> + ) : ( + <div className="timeline-list"> + {steps.map((step) => ( + <article key={step.id} className="timeline-item"> + <div className="timeline-item__topline"> + <div className="detail-stack"> + <span className="list-row__eyebrow">Step {step.sequence_no}</span> + <h3 className="list-row__title"> + {step.request.action} / {step.request.scope} + </h3> + </div> + <StatusBadge status={step.status} /> + </div> + + <div className="timeline-item__meta"> + <span className="meta-pill">{step.kind.replace(/_/g, " ")}</span> + <span className="meta-pill">{formatDate(step.updated_at)}</span> + </div> + + <div className="timeline-item__summary"> + <div className="attribute-list"> + {Object.entries(step.request.attributes).map(([key, value]) => ( + <span key={key} className="attribute-item"> + {key}: {formatAttributeValue(value)} + </span> + ))} + </div> + <div className="key-value-grid"> + <div> + <dt>Routing</dt> + <dd>{step.outcome.routing_decision}</dd> + </div> + <div> + <dt>Approval status</dt> + <dd> + {step.outcome.approval_id ? ( + <Link href={`/approvals?approval=${step.outcome.approval_id}`} className="inline-link"> + {step.outcome.approval_status ?? "Linked approval"} + </Link> + ) : ( + "No approval" + )} + </dd> + </div> + <div> + <dt>Execution status</dt> + <dd>{step.outcome.execution_status ?? "Not executed"}</dd> + </div> + <div> + <dt>Trace</dt> + <dd className="mono"> + {traceHrefPrefix ? ( + <Link + href={`${traceHrefPrefix}${encodeURIComponent(step.trace.trace_id)}`} + className="inline-link" + > + {step.trace.trace_id} + </Link> + ) : ( + step.trace.trace_id + )}{" "} + · {step.trace.trace_kind} + </dd> + </div> + </div> + {step.lineage.parent_step_id || step.lineage.source_approval_id || step.lineage.source_execution_id ? ( + <div className="attribute-list"> + {step.lineage.parent_step_id ? ( + <span key="parent-step" className="attribute-item"> + Parent step: {step.lineage.parent_step_id} + </span> + ) : null} + {step.lineage.source_approval_id ? ( + <span key="source-approval" className="attribute-item"> + Source approval: {step.lineage.source_approval_id} + </span> + ) : null} + {step.lineage.source_execution_id ? ( + <span key="source-execution" className="attribute-item"> + Source execution: {step.lineage.source_execution_id} + </span> + ) : null} + </div> + ) : null} + {step.outcome.execution_id ? ( + <div className="attribute-list"> + <span className="attribute-item">Execution record: {step.outcome.execution_id}</span> + </div> + ) : null} + {step.outcome.blocked_reason ? ( + <div className="execution-summary__note execution-summary__note--danger"> + <p className="execution-summary__label">Blocked reason</p> + <p>{step.outcome.blocked_reason}</p> + </div> + ) : null} + </div> + </article> + ))} + </div> + )} + </SectionCard> + ); +} diff --git a/apps/web/components/task-summary.tsx b/apps/web/components/task-summary.tsx new file mode 100644 index 0000000..b875d92 --- /dev/null +++ b/apps/web/components/task-summary.tsx @@ -0,0 +1,131 @@ +import Link from "next/link"; + +import type { ApiSource, TaskItem, ToolExecutionItem } from "../lib/api"; +import { EmptyState } from "./empty-state"; +import { ExecutionSummary } from "./execution-summary"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +type TaskSummaryProps = { + task: TaskItem | null; + taskSource: ApiSource; + stepSource: ApiSource | "unavailable"; + execution: ToolExecutionItem | null; + executionSource?: ApiSource | null; + executionUnavailableMessage?: string | null; + chrome?: "card" | "embedded"; + showExecutionReview?: boolean; +}; + +export function TaskSummary({ + task, + taskSource, + stepSource, + execution, + executionSource, + executionUnavailableMessage, + chrome = "card", + showExecutionReview = true, +}: TaskSummaryProps) { + if (!task) { + return ( + <SectionCard + eyebrow="Selected task" + title="No task selected" + description="Choose a task to inspect its current governed state and ordered task steps." + className={chrome === "embedded" ? "section-card--embedded" : undefined} + > + <EmptyState + title="Task inspector is idle" + description="No task records are available in the current route state." + /> + </SectionCard> + ); + } + + return ( + <SectionCard + eyebrow="Selected task" + title={task.tool.name} + description="Latest task state, approval linkage, and execution review stay grouped in one bounded summary." + className={[ + chrome === "embedded" ? "section-card--embedded" : null, + "task-summary", + chrome === "embedded" ? "task-summary--embedded" : null, + ] + .filter(Boolean) + .join(" ")} + > + <div className="detail-grid"> + <div className="detail-summary"> + <StatusBadge status={task.status} /> + <span className="detail-summary__label"> + {task.request.action} / {task.request.scope} + </span> + </div> + + <dl className="key-value-grid"> + <div> + <dt>Task</dt> + <dd className="mono">{task.id}</dd> + </div> + <div> + <dt>Thread</dt> + <dd className="mono">{task.thread_id}</dd> + </div> + <div> + <dt>Latest approval</dt> + <dd className="mono">{task.latest_approval_id ?? "Not linked"}</dd> + </div> + <div> + <dt>Latest execution</dt> + <dd className="mono">{task.latest_execution_id ?? "Not executed"}</dd> + </div> + <div> + <dt>Task source</dt> + <dd>{taskSource === "live" ? "Live task detail" : "Fixture task detail"}</dd> + </div> + <div> + <dt>Step source</dt> + <dd> + {stepSource === "live" + ? "Live task-step detail" + : stepSource === "fixture" + ? "Fixture task-step fallback" + : "Task-step read unavailable"} + </dd> + </div> + </dl> + + <div className="detail-group"> + <h3>Governed linkage</h3> + <p className="muted-copy"> + {task.latest_approval_id + ? "This task reflects the latest approval outcome and remains directly linked back to the approval inbox." + : "No approval record is linked to this task in the current payload."} + </p> + {task.latest_approval_id ? ( + <div className="cluster"> + <Link href={`/approvals?approval=${task.latest_approval_id}`} className="button-secondary"> + Open linked approval + </Link> + </div> + ) : null} + </div> + + {showExecutionReview ? ( + <div className="detail-group"> + <h3>Execution review</h3> + <ExecutionSummary + execution={execution} + source={executionSource} + unavailableMessage={executionUnavailableMessage} + emptyTitle="Task has not executed yet" + emptyDescription="When execution runs for this task, the latest execution record and output snapshot will appear here." + /> + </div> + ) : null} + </div> + </SectionCard> + ); +} diff --git a/apps/web/components/thread-create.test.tsx b/apps/web/components/thread-create.test.tsx new file mode 100644 index 0000000..3bfb09c --- /dev/null +++ b/apps/web/components/thread-create.test.tsx @@ -0,0 +1,130 @@ +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { createThreadMock, refreshMock, pushMock } = vi.hoisted(() => ({ + createThreadMock: vi.fn(), + pushMock: vi.fn(), + refreshMock: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + push: pushMock, + refresh: refreshMock, + }), +})); + +vi.mock("../lib/api", async () => { + const actual = await vi.importActual("../lib/api"); + return { + ...actual, + createThread: createThreadMock, + }; +}); + +import { ThreadCreate } from "./thread-create"; + +describe("ThreadCreate", () => { + beforeEach(() => { + createThreadMock.mockReset(); + pushMock.mockReset(); + refreshMock.mockReset(); + }); + + afterEach(() => { + cleanup(); + }); + + it("creates a live thread and routes the operator to the new selection", async () => { + createThreadMock.mockResolvedValue({ + thread: { + id: "thread-2", + title: "Delta thread", + agent_profile_id: "coach_default", + created_at: "2026-03-17T11:00:00Z", + updated_at: "2026-03-17T11:00:00Z", + }, + }); + + render( + <ThreadCreate + apiBaseUrl="https://api.example.com" + userId="user-1" + currentMode="assistant" + agentProfiles={[ + { + id: "assistant_default", + name: "Assistant Default", + description: "General-purpose assistant profile for baseline conversations.", + }, + { + id: "coach_default", + name: "Coach Default", + description: "Coaching-oriented profile focused on guidance and accountability.", + }, + ]} + />, + ); + + fireEvent.change(screen.getByLabelText("Agent profile"), { + target: { value: "coach_default" }, + }); + fireEvent.change(screen.getByLabelText("Thread title"), { + target: { value: "Delta thread" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Create thread" })); + + await waitFor(() => { + expect(createThreadMock).toHaveBeenCalledWith("https://api.example.com", { + user_id: "user-1", + title: "Delta thread", + agent_profile_id: "coach_default", + }); + }); + + expect(pushMock).toHaveBeenCalledWith("/chat?thread=thread-2"); + expect(refreshMock).toHaveBeenCalled(); + }); + + it("shows an explicit unavailable state when live API configuration is absent", () => { + render(<ThreadCreate currentMode="assistant" />); + + expect(screen.getByRole("button", { name: "Create thread" })).toBeDisabled(); + expect( + screen.getByText(/Thread creation becomes available when the live web API base URL and user ID are configured/i), + ).toBeInTheDocument(); + }); + + it("defaults thread creation payload to assistant_default when no profile list is available", async () => { + createThreadMock.mockResolvedValue({ + thread: { + id: "thread-3", + title: "Epsilon thread", + agent_profile_id: "assistant_default", + created_at: "2026-03-17T12:00:00Z", + updated_at: "2026-03-17T12:00:00Z", + }, + }); + + render( + <ThreadCreate + apiBaseUrl="https://api.example.com" + userId="user-1" + currentMode="assistant" + />, + ); + + fireEvent.change(screen.getByLabelText("Thread title"), { + target: { value: "Epsilon thread" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Create thread" })); + + await waitFor(() => { + expect(createThreadMock).toHaveBeenCalledWith("https://api.example.com", { + user_id: "user-1", + title: "Epsilon thread", + agent_profile_id: "assistant_default", + }); + }); + }); +}); diff --git a/apps/web/components/thread-create.tsx b/apps/web/components/thread-create.tsx new file mode 100644 index 0000000..36f9cae --- /dev/null +++ b/apps/web/components/thread-create.tsx @@ -0,0 +1,183 @@ +"use client"; + +import type { FormEvent } from "react"; +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; + +import { createThread, DEFAULT_AGENT_PROFILE_ID, type AgentProfileItem } from "../lib/api"; +import type { ChatMode } from "./mode-toggle"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +type ThreadCreateProps = { + apiBaseUrl?: string; + userId?: string; + currentMode: ChatMode; + agentProfiles?: AgentProfileItem[]; +}; + +function buildThreadHref(mode: ChatMode, threadId: string) { + const params = new URLSearchParams(); + + if (mode === "request") { + params.set("mode", mode); + } + params.set("thread", threadId); + + return `/chat?${params.toString()}`; +} + +export function ThreadCreate({ + apiBaseUrl, + userId, + currentMode, + agentProfiles, +}: ThreadCreateProps) { + const router = useRouter(); + const [title, setTitle] = useState(""); + const [selectedProfileId, setSelectedProfileId] = useState(DEFAULT_AGENT_PROFILE_ID); + const [statusText, setStatusText] = useState( + apiBaseUrl && userId + ? "Create a new visible thread when the current conversation needs a fresh continuity boundary." + : "Thread creation becomes available when the live web API base URL and user ID are configured.", + ); + const [statusTone, setStatusTone] = useState<"info" | "success" | "danger">("info"); + const [isSubmitting, setIsSubmitting] = useState(false); + + const liveModeReady = Boolean(apiBaseUrl && userId); + const profileOptions = agentProfiles && agentProfiles.length > 0 + ? agentProfiles + : [ + { + id: DEFAULT_AGENT_PROFILE_ID, + name: "Assistant Default", + description: "General-purpose assistant profile for baseline conversations.", + }, + ]; + + useEffect(() => { + if (profileOptions.some((profile) => profile.id === selectedProfileId)) { + return; + } + + const fallbackProfile = + profileOptions.find((profile) => profile.id === DEFAULT_AGENT_PROFILE_ID) ?? profileOptions[0]; + setSelectedProfileId(fallbackProfile.id); + }, [profileOptions, selectedProfileId]); + + async function handleSubmit(event: FormEvent<HTMLFormElement>) { + event.preventDefault(); + + const nextTitle = title.trim(); + + if (!liveModeReady) { + setStatusTone("danger"); + setStatusText( + "Thread creation is unavailable in fixture preview because the continuity create endpoint only persists through the live API.", + ); + return; + } + + if (!nextTitle) { + setStatusTone("danger"); + setStatusText("A short thread title is required."); + return; + } + + setIsSubmitting(true); + setStatusTone("info"); + setStatusText("Creating a new continuity thread through the shipped thread-create endpoint..."); + + try { + const response = await createThread(apiBaseUrl!, { + user_id: userId!, + title: nextTitle, + agent_profile_id: selectedProfileId || DEFAULT_AGENT_PROFILE_ID, + }); + + setStatusTone("success"); + setStatusText("Thread created. Loading the new continuity record now."); + setTitle(""); + router.push(buildThreadHref(currentMode, response.thread.id)); + router.refresh(); + } catch (error) { + const detail = error instanceof Error ? error.message : "Request failed"; + setStatusTone("danger"); + setStatusText(`Unable to create thread: ${detail}`); + } finally { + setIsSubmitting(false); + } + } + + return ( + <SectionCard + eyebrow="Create thread" + title="Start a new continuity record" + description="Keep thread identity explicit instead of recycling one conversation container for unrelated work." + > + <form className="detail-stack" onSubmit={handleSubmit}> + <div className="form-field"> + <label htmlFor="thread-agent-profile">Agent profile</label> + <select + id="thread-agent-profile" + name="thread-agent-profile" + value={selectedProfileId} + onChange={(event) => setSelectedProfileId(event.target.value)} + disabled={!liveModeReady || isSubmitting} + > + {profileOptions.map((profile) => ( + <option key={profile.id} value={profile.id}> + {profile.name} + </option> + ))} + </select> + </div> + + <div className="form-field"> + <label htmlFor="thread-title">Thread title</label> + <input + id="thread-title" + name="thread-title" + value={title} + onChange={(event) => setTitle(event.target.value)} + placeholder="Magnesium reorder review" + disabled={!liveModeReady || isSubmitting} + /> + </div> + + <div className="composer-actions"> + <div className="composer-status" aria-live="polite"> + <StatusBadge + status={ + isSubmitting + ? "submitting" + : statusTone === "success" + ? "success" + : statusTone === "danger" + ? "error" + : "info" + } + label={ + isSubmitting + ? "Creating" + : statusTone === "success" + ? "Ready" + : statusTone === "danger" + ? "Attention" + : "Prepared" + } + /> + <span>{statusText}</span> + </div> + <button + type="submit" + className="button" + disabled={isSubmitting || !liveModeReady || !title.trim()} + > + {isSubmitting ? "Creating..." : "Create thread"} + </button> + </div> + </form> + </SectionCard> + ); +} diff --git a/apps/web/components/thread-event-list.test.tsx b/apps/web/components/thread-event-list.test.tsx new file mode 100644 index 0000000..8dca8b9 --- /dev/null +++ b/apps/web/components/thread-event-list.test.tsx @@ -0,0 +1,308 @@ +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ThreadEventList } from "./thread-event-list"; + +const { captureExplicitSignalsMock } = vi.hoisted(() => ({ + captureExplicitSignalsMock: vi.fn(), +})); + +vi.mock("../lib/api", async () => { + const actual = await vi.importActual("../lib/api"); + return { + ...actual, + captureExplicitSignals: captureExplicitSignalsMock, + }; +}); + +const BASE_EVENTS = [ + { + id: "event-1", + thread_id: "thread-1", + session_id: "session-1", + sequence_no: 1, + kind: "message.user", + payload: { text: "Please remind me to submit tax forms." }, + created_at: "2026-03-17T10:00:00Z", + }, + { + id: "event-2", + thread_id: "thread-1", + session_id: "session-1", + sequence_no: 2, + kind: "message.assistant", + payload: { text: "Sure, I can help with that." }, + created_at: "2026-03-17T10:01:00Z", + }, + { + id: "event-3", + thread_id: "thread-1", + session_id: "session-1", + sequence_no: 3, + kind: "approval.request", + payload: { action: "place_order", scope: "supplements", status: "pending" }, + created_at: "2026-03-17T10:02:00Z", + }, + { + id: "event-4", + thread_id: "thread-1", + session_id: "session-1", + sequence_no: 4, + kind: "message.user", + payload: { text: "I also need to call the dentist." }, + created_at: "2026-03-17T10:03:00Z", + }, +] as const; + +describe("ThreadEventList", () => { + beforeEach(() => { + captureExplicitSignalsMock.mockReset(); + }); + + afterEach(() => { + cleanup(); + }); + + it("selects only eligible message.user events and does not auto-capture on render", () => { + render( + <ThreadEventList + threadTitle="Gamma thread" + source="live" + apiBaseUrl="https://api.example.com" + userId="user-1" + sessions={[ + { + id: "session-1", + thread_id: "thread-1", + status: "active", + started_at: "2026-03-17T10:00:00Z", + ended_at: null, + created_at: "2026-03-17T10:00:00Z", + }, + ]} + events={[...BASE_EVENTS]} + />, + ); + + const sourceEventSelect = screen.getByLabelText("Eligible user event"); + const options = within(sourceEventSelect).getAllByRole("option"); + + expect(options).toHaveLength(2); + expect(options.map((option) => (option as HTMLOptionElement).value)).toEqual(["event-4", "event-1"]); + expect(screen.getByRole("button", { name: "Capture explicit signals" })).toBeEnabled(); + expect(captureExplicitSignalsMock).not.toHaveBeenCalled(); + }); + + it("posts deterministic capture payload and renders deterministic summary on success", async () => { + captureExplicitSignalsMock.mockResolvedValue({ + preferences: { + candidates: [], + admissions: [], + summary: { + source_event_id: "event-1", + source_event_kind: "message.user", + candidate_count: 0, + admission_count: 0, + persisted_change_count: 0, + noop_count: 0, + }, + }, + commitments: { + candidates: [], + admissions: [], + summary: { + source_event_id: "event-1", + source_event_kind: "message.user", + candidate_count: 0, + admission_count: 0, + persisted_change_count: 0, + noop_count: 0, + open_loop_created_count: 0, + open_loop_noop_count: 0, + }, + }, + summary: { + source_event_id: "event-1", + source_event_kind: "message.user", + candidate_count: 3, + admission_count: 2, + persisted_change_count: 2, + noop_count: 1, + open_loop_created_count: 1, + open_loop_noop_count: 1, + preference_candidate_count: 1, + preference_admission_count: 1, + commitment_candidate_count: 2, + commitment_admission_count: 1, + }, + }); + + render( + <ThreadEventList + threadTitle="Gamma thread" + source="live" + apiBaseUrl="https://api.example.com" + userId="user-1" + sessions={[]} + events={[...BASE_EVENTS]} + />, + ); + + fireEvent.change(screen.getByLabelText("Eligible user event"), { + target: { value: "event-1" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Capture explicit signals" })); + + await waitFor(() => { + expect(captureExplicitSignalsMock).toHaveBeenCalledWith("https://api.example.com", { + user_id: "user-1", + source_event_id: "event-1", + }); + }); + + expect(await screen.findByText("Capture result summary")).toBeInTheDocument(); + expect(screen.getByText("event-1 (message user)")).toBeInTheDocument(); + expect(screen.getByText("Candidates 3")).toBeInTheDocument(); + expect(screen.getByText("Admissions 2")).toBeInTheDocument(); + expect(screen.getByText("Open loops created 1")).toBeInTheDocument(); + expect(screen.getByText("Open loops noop 1")).toBeInTheDocument(); + }); + + it("renders deterministic non-destructive error text when capture fails", async () => { + captureExplicitSignalsMock.mockRejectedValue( + new Error("source_event_id must reference an existing message.user event"), + ); + + render( + <ThreadEventList + threadTitle="Gamma thread" + source="live" + apiBaseUrl="https://api.example.com" + userId="user-1" + sessions={[]} + events={[BASE_EVENTS[0]]} + />, + ); + + fireEvent.click(screen.getByRole("button", { name: "Capture explicit signals" })); + + expect( + await screen.findByText( + /Capture failed: source_event_id must reference an existing message\.user event/i, + ), + ).toBeInTheDocument(); + }); + + it("shows explicit fixture and unavailable disabled states", () => { + const { rerender } = render( + <ThreadEventList + threadTitle="Gamma thread" + source="fixture" + sessions={[]} + events={[BASE_EVENTS[0]]} + />, + ); + + expect(screen.getByRole("button", { name: "Capture explicit signals" })).toBeDisabled(); + expect( + screen.getByText(/Fixture mode is non-destructive\. Configure live API settings to enable capture\./i), + ).toBeInTheDocument(); + + rerender( + <ThreadEventList + threadTitle="Gamma thread" + source="unavailable" + unavailableReason="Thread events failed to load." + apiBaseUrl="https://api.example.com" + userId="user-1" + sessions={[]} + events={[BASE_EVENTS[0]]} + />, + ); + + expect(screen.getByRole("button", { name: "Capture explicit signals" })).toBeDisabled(); + expect(screen.getAllByText("Thread events failed to load.").length).toBeGreaterThan(0); + }); + + it("shows blocked live state when API config is incomplete", () => { + render( + <ThreadEventList + threadTitle="Gamma thread" + source="live" + sessions={[]} + events={[BASE_EVENTS[0]]} + />, + ); + + const captureButton = screen.getByRole("button", { name: "Capture explicit signals" }); + expect(captureButton).toBeDisabled(); + expect( + screen.getByText("Live API configuration is incomplete for explicit-signal capture."), + ).toBeInTheDocument(); + + fireEvent.click(captureButton); + expect(captureExplicitSignalsMock).not.toHaveBeenCalled(); + }); + + it("shows blocked live state when no eligible message.user events exist", () => { + render( + <ThreadEventList + threadTitle="Gamma thread" + source="live" + apiBaseUrl="https://api.example.com" + userId="user-1" + sessions={[]} + events={[BASE_EVENTS[1], BASE_EVENTS[2]]} + />, + ); + + expect(screen.getByRole("button", { name: "Capture explicit signals" })).toBeDisabled(); + expect( + screen.getByText("No eligible message.user events are available on this thread."), + ).toBeInTheDocument(); + expect(screen.getByLabelText("Eligible user event")).toBeDisabled(); + expect(captureExplicitSignalsMock).not.toHaveBeenCalled(); + }); + + it("renders bounded recent sessions and event summaries for continuity review", () => { + render( + <ThreadEventList + threadTitle="Gamma thread" + source="live" + apiBaseUrl="https://api.example.com" + userId="user-1" + sessions={[ + { + id: "session-1", + thread_id: "thread-1", + status: "active", + started_at: "2026-03-17T10:00:00Z", + ended_at: null, + created_at: "2026-03-17T10:00:00Z", + }, + ]} + events={[...BASE_EVENTS]} + />, + ); + + expect(screen.getByText("Bounded supporting continuity")).toBeInTheDocument(); + expect(screen.getByText(/place_order in supplements is pending/i)).toBeInTheDocument(); + expect(screen.getByText("Sequence 3")).toBeInTheDocument(); + expect(screen.getByText("Active")).toBeInTheDocument(); + }); + + it("shows continuity empty state when selected thread has no sessions and no operational events", () => { + render( + <ThreadEventList + threadTitle="Gamma thread" + source="live" + apiBaseUrl="https://api.example.com" + userId="user-1" + sessions={[]} + events={[]} + />, + ); + + expect(screen.getByText("No supporting continuity yet")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/thread-event-list.tsx b/apps/web/components/thread-event-list.tsx new file mode 100644 index 0000000..d633cd8 --- /dev/null +++ b/apps/web/components/thread-event-list.tsx @@ -0,0 +1,456 @@ +"use client"; + +import type { FormEvent } from "react"; +import { useEffect, useMemo, useState } from "react"; + +import type { ExplicitSignalCaptureResponse, ThreadEventItem, ThreadSessionItem } from "../lib/api"; +import { captureExplicitSignals } from "../lib/api"; +import { EmptyState } from "./empty-state"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +type ThreadEventListProps = { + threadTitle?: string; + sessions: ThreadSessionItem[]; + events: ThreadEventItem[]; + source: "live" | "fixture" | "unavailable"; + unavailableReason?: string; + apiBaseUrl?: string; + userId?: string; +}; + +const SESSION_LIMIT = 3; +const EVENT_LIMIT = 4; + +function formatDate(value: string) { + return new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(value)); +} + +function isConversationEvent(event: ThreadEventItem) { + return event.kind === "message.user" || event.kind === "message.assistant"; +} + +function isCaptureEligibleEvent(event: ThreadEventItem) { + return event.kind === "message.user"; +} + +function sortEligibleCaptureEvents(left: ThreadEventItem, right: ThreadEventItem) { + if (left.sequence_no !== right.sequence_no) { + return right.sequence_no - left.sequence_no; + } + + const leftTimestamp = new Date(left.created_at).getTime(); + const rightTimestamp = new Date(right.created_at).getTime(); + if (leftTimestamp !== rightTimestamp) { + return rightTimestamp - leftTimestamp; + } + + return right.id.localeCompare(left.id); +} + +function summarizePayload(payload: unknown) { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return "Structured continuity payload available."; + } + + const record = payload as Record<string, unknown>; + + if (typeof record.summary === "string" && record.summary.trim()) { + return record.summary; + } + + if (typeof record.text === "string" && record.text.trim()) { + return record.text; + } + + const action = typeof record.action === "string" ? record.action : null; + const scope = typeof record.scope === "string" ? record.scope : null; + const status = typeof record.status === "string" ? record.status : null; + + if (action && scope && status) { + return `${action} in ${scope} is ${status.replace(/_/g, " ")}.`; + } + + if (action && scope) { + return `${action} in ${scope}.`; + } + + if (status) { + return `Continuity status is ${status.replace(/_/g, " ")}.`; + } + + return "Structured continuity payload available."; +} + +function formatKind(kind: string) { + return kind.replace(/[._]/g, " "); +} + +function summarizeCaptureEventPayload(payload: unknown) { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return "User message payload available."; + } + + const record = payload as Record<string, unknown>; + + if (typeof record.text === "string" && record.text.trim()) { + return record.text.trim(); + } + + if (typeof record.summary === "string" && record.summary.trim()) { + return record.summary.trim(); + } + + return "User message payload available."; +} + +function formatCaptureEventOption(event: ThreadEventItem) { + const payloadSummary = summarizeCaptureEventPayload(event.payload); + const compactSummary = + payloadSummary.length > 72 ? `${payloadSummary.slice(0, 69)}...` : payloadSummary; + + return `Sequence ${event.sequence_no} - ${formatDate(event.created_at)} - ${compactSummary}`; +} + +function buildCaptureDisabledReason({ + source, + unavailableReason, + threadTitle, + apiBaseUrl, + userId, + eligibleCount, +}: { + source: ThreadEventListProps["source"]; + unavailableReason?: string; + threadTitle?: string; + apiBaseUrl?: string; + userId?: string; + eligibleCount: number; +}) { + if (source === "unavailable") { + return unavailableReason ?? "Continuity is unavailable, so capture is disabled for now."; + } + + if (source === "fixture") { + return "Fixture mode is non-destructive. Configure live API settings to enable capture."; + } + + if (!threadTitle) { + return "Select a thread before capturing explicit signals."; + } + + if (!apiBaseUrl || !userId) { + return "Live API configuration is incomplete for explicit-signal capture."; + } + + if (eligibleCount === 0) { + return "No eligible message.user events are available on this thread."; + } + + return null; +} + +export function ThreadEventList({ + threadTitle, + sessions, + events, + source, + unavailableReason, + apiBaseUrl, + userId, +}: ThreadEventListProps) { + const eligibleCaptureEvents = useMemo( + () => [...events].filter(isCaptureEligibleEvent).sort(sortEligibleCaptureEvents), + [events], + ); + const [selectedCaptureEventId, setSelectedCaptureEventId] = useState( + eligibleCaptureEvents[0]?.id ?? "", + ); + const [captureSummary, setCaptureSummary] = + useState<ExplicitSignalCaptureResponse["summary"] | null>(null); + const [captureError, setCaptureError] = useState<string | null>(null); + const [isCapturing, setIsCapturing] = useState(false); + + useEffect(() => { + setSelectedCaptureEventId((currentValue) => { + if (currentValue && eligibleCaptureEvents.some((event) => event.id === currentValue)) { + return currentValue; + } + + return eligibleCaptureEvents[0]?.id ?? ""; + }); + }, [eligibleCaptureEvents]); + + useEffect(() => { + setCaptureSummary(null); + setCaptureError(null); + }, [selectedCaptureEventId, threadTitle, source]); + + const captureDisabledReason = buildCaptureDisabledReason({ + source, + unavailableReason, + threadTitle, + apiBaseUrl, + userId, + eligibleCount: eligibleCaptureEvents.length, + }); + const canCapture = !captureDisabledReason && !isCapturing; + const disableEventSelection = source !== "live" || isCapturing || eligibleCaptureEvents.length === 0; + + let captureStatus = "info"; + let captureStatusLabel = "Prepared"; + let captureStatusText = "Ready to run explicit-signal capture for a selected message.user event."; + + if (captureDisabledReason) { + captureStatus = source === "unavailable" ? "unavailable" : source === "fixture" ? "fixture" : "blocked"; + captureStatusLabel = source === "unavailable" ? "Unavailable" : source === "fixture" ? "Fixture" : "Blocked"; + captureStatusText = captureDisabledReason; + } else if (isCapturing) { + captureStatus = "submitting"; + captureStatusLabel = "Capturing"; + captureStatusText = "Running POST /v0/memories/capture-explicit-signals for the selected source event."; + } else if (captureError) { + captureStatus = "error"; + captureStatusLabel = "Error"; + captureStatusText = `Capture failed: ${captureError}`; + } else if (captureSummary) { + captureStatus = "success"; + captureStatusLabel = "Captured"; + captureStatusText = `Capture completed for source event ${captureSummary.source_event_id}.`; + } + + async function handleCaptureSubmit(event: FormEvent<HTMLFormElement>) { + event.preventDefault(); + + if (!canCapture || !selectedCaptureEventId || !apiBaseUrl || !userId) { + setCaptureSummary(null); + setCaptureError(captureDisabledReason ?? "Select an eligible message.user event before capturing."); + return; + } + + setCaptureSummary(null); + setCaptureError(null); + setIsCapturing(true); + + try { + const response = await captureExplicitSignals(apiBaseUrl, { + user_id: userId, + source_event_id: selectedCaptureEventId, + }); + setCaptureSummary(response.summary); + } catch (error) { + setCaptureError( + error instanceof Error ? error.message : "Explicit-signal capture request failed.", + ); + } finally { + setIsCapturing(false); + } + } + + const captureControlSection = ( + <SectionCard + eyebrow="Explicit signal capture" + title="Manual capture control" + description="Manually trigger unified explicit-signal capture for one selected message.user event. No automatic capture runs in this rail." + > + <form className="detail-stack" onSubmit={handleCaptureSubmit}> + <div className="form-field"> + <label htmlFor="explicit-signal-source-event">Eligible user event</label> + <select + id="explicit-signal-source-event" + name="explicit-signal-source-event" + value={selectedCaptureEventId} + onChange={(inputEvent) => setSelectedCaptureEventId(inputEvent.target.value)} + disabled={disableEventSelection} + > + {eligibleCaptureEvents.length === 0 ? ( + <option value="">No eligible message.user events</option> + ) : ( + eligibleCaptureEvents.map((captureEvent) => ( + <option key={captureEvent.id} value={captureEvent.id}> + {formatCaptureEventOption(captureEvent)} + </option> + )) + )} + </select> + </div> + + <div className="composer-actions"> + <div className="composer-status" aria-live="polite"> + <StatusBadge status={captureStatus} label={captureStatusLabel} /> + <span>{captureStatusText}</span> + </div> + <button type="submit" className="button" disabled={!canCapture}> + {isCapturing ? "Capturing..." : "Capture explicit signals"} + </button> + </div> + </form> + + {captureSummary ? ( + <div className="detail-stack" aria-live="polite"> + <div className="detail-summary"> + <span className="detail-summary__label">Capture result summary</span> + <span className="subtle-chip">Source event confirmed</span> + </div> + <p> + {captureSummary.source_event_id} ({formatKind(captureSummary.source_event_kind)}) + </p> + <div className="attribute-list"> + <span className="meta-pill">Candidates {captureSummary.candidate_count}</span> + <span className="meta-pill">Admissions {captureSummary.admission_count}</span> + <span className="meta-pill"> + Open loops created {captureSummary.open_loop_created_count} + </span> + <span className="meta-pill">Open loops noop {captureSummary.open_loop_noop_count}</span> + </div> + </div> + ) : null} + </SectionCard> + ); + + if (source === "unavailable") { + return ( + <> + {captureControlSection} + <SectionCard + eyebrow="Operational review" + title="Supporting continuity unavailable" + description="The bounded operational review panel could not load for the selected thread." + > + <EmptyState + title="Operational review unavailable" + description={unavailableReason ?? "Try again once the continuity API is reachable."} + /> + </SectionCard> + </> + ); + } + + if (!threadTitle) { + return ( + <> + {captureControlSection} + <SectionCard + eyebrow="Operational review" + title="No thread selected" + description="Pick a visible thread before reviewing recent session state and non-conversation continuity." + > + <EmptyState + title="Select a thread" + description="Supporting continuity stays visible here once one thread is selected." + /> + </SectionCard> + </> + ); + } + + const operationalEvents = events.filter((event) => !isConversationEvent(event)); + + if (sessions.length === 0 && operationalEvents.length === 0) { + return ( + <> + {captureControlSection} + <SectionCard + eyebrow="Operational review" + title="Supporting continuity" + description="This thread exists, but no supporting session or operational continuity has been recorded yet." + > + <EmptyState + title="No supporting continuity yet" + description="Assistant and governed activity will add bounded operational review details here without cluttering the transcript." + /> + </SectionCard> + </> + ); + } + + const visibleSessions = sessions.slice(-SESSION_LIMIT).reverse(); + const visibleEvents = operationalEvents.slice(-EVENT_LIMIT).reverse(); + + return ( + <> + {captureControlSection} + <SectionCard + eyebrow="Operational review" + title="Bounded supporting continuity" + description="Sessions and non-conversation events stay available for review without repeating the main transcript." + > + <div className="thread-review-grid"> + <div className="detail-group"> + <div className="detail-summary"> + <span className="detail-summary__label">Recent sessions</span> + <span className="subtle-chip">{sessions.length} total</span> + </div> + + {visibleSessions.length === 0 ? ( + <EmptyState + title="No sessions yet" + description="Session lifecycle updates appear here once the selected thread has started recording them." + className="empty-state--compact" + /> + ) : ( + <div className="timeline-list"> + {visibleSessions.map((session) => ( + <article key={session.id} className="timeline-item"> + <div className="timeline-item__topline"> + <div className="detail-stack"> + <h3 className="list-row__title">{formatDate(session.started_at ?? session.created_at)}</h3> + <p>{session.ended_at ? "Session closed cleanly." : "Current live session remains open."}</p> + </div> + <StatusBadge status={session.status} /> + </div> + + <div className="attribute-list"> + <span className="meta-pill mono">{session.id}</span> + <span className="meta-pill">Started {formatDate(session.started_at ?? session.created_at)}</span> + {session.ended_at ? <span className="meta-pill">Ended {formatDate(session.ended_at)}</span> : null} + </div> + </article> + ))} + </div> + )} + </div> + + <div className="detail-group"> + <div className="detail-summary"> + <span className="detail-summary__label">Operational events</span> + <span className="subtle-chip">{operationalEvents.length} total</span> + </div> + + {visibleEvents.length === 0 ? ( + <EmptyState + title="No operational events yet" + description="Approval, execution, and other supporting continuity events will appear here once the thread records them." + className="empty-state--compact" + /> + ) : ( + <div className="timeline-list"> + {visibleEvents.map((event) => ( + <article key={event.id} className="timeline-item"> + <div className="timeline-item__topline"> + <div className="detail-stack"> + <span className="history-entry__label">{formatKind(event.kind)}</span> + <h3 className="list-row__title">{summarizePayload(event.payload)}</h3> + </div> + <span className="subtle-chip">{formatDate(event.created_at)}</span> + </div> + + <div className="attribute-list"> + <span className="meta-pill">Sequence {event.sequence_no}</span> + {event.session_id ? <span className="meta-pill mono">{event.session_id}</span> : null} + </div> + </article> + ))} + </div> + )} + </div> + </div> + </SectionCard> + </> + ); +} diff --git a/apps/web/components/thread-list.test.tsx b/apps/web/components/thread-list.test.tsx new file mode 100644 index 0000000..80ca831 --- /dev/null +++ b/apps/web/components/thread-list.test.tsx @@ -0,0 +1,79 @@ +import React from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { ThreadList } from "./thread-list"; + +vi.mock("next/link", () => ({ + default: ({ + href, + children, + className, + "aria-current": ariaCurrent, + }: { + href: string; + children: React.ReactNode; + className?: string; + "aria-current"?: string; + }) => ( + <a href={href} className={className} aria-current={ariaCurrent}> + {children} + </a> + ), +})); + +describe("ThreadList", () => { + afterEach(() => { + cleanup(); + }); + + it("renders thread links that preserve the current mode and selected thread", () => { + render( + <ThreadList + threads={[ + { + id: "thread-1", + title: "Gamma thread", + agent_profile_id: "assistant_default", + created_at: "2026-03-17T10:00:00Z", + updated_at: "2026-03-17T10:00:00Z", + }, + { + id: "thread-2", + title: "Delta thread", + agent_profile_id: "coach_default", + created_at: "2026-03-17T11:00:00Z", + updated_at: "2026-03-17T11:00:00Z", + }, + ]} + selectedThreadId="thread-2" + currentMode="request" + agentProfiles={[ + { + id: "assistant_default", + name: "Assistant Default", + description: "General-purpose assistant profile for baseline conversations.", + }, + { + id: "coach_default", + name: "Coach Default", + description: "Coaching-oriented profile focused on guidance and accountability.", + }, + ]} + source="live" + />, + ); + + expect(screen.getByRole("link", { name: /Gamma thread/i })).toHaveAttribute( + "href", + "/chat?mode=request&thread=thread-1", + ); + expect(screen.getByRole("link", { name: /Delta thread/i })).toHaveAttribute( + "href", + "/chat?mode=request&thread=thread-2", + ); + expect(screen.getByRole("link", { name: /Delta thread/i })).toHaveAttribute("aria-current", "page"); + expect(screen.getByText("Profile Assistant Default")).toBeInTheDocument(); + expect(screen.getByText("Profile Coach Default")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/thread-list.tsx b/apps/web/components/thread-list.tsx new file mode 100644 index 0000000..a758f98 --- /dev/null +++ b/apps/web/components/thread-list.tsx @@ -0,0 +1,109 @@ +import Link from "next/link"; + +import { DEFAULT_AGENT_PROFILE_ID, type AgentProfileItem, type ThreadItem } from "../lib/api"; +import type { ChatMode } from "./mode-toggle"; +import { EmptyState } from "./empty-state"; +import { SectionCard } from "./section-card"; + +type ThreadListProps = { + threads: ThreadItem[]; + selectedThreadId?: string; + currentMode: ChatMode; + agentProfiles?: AgentProfileItem[]; + source: "live" | "fixture" | "unavailable"; + unavailableReason?: string; +}; + +function formatDate(value: string) { + return new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(value)); +} + +function buildThreadHref(mode: ChatMode, threadId: string) { + const params = new URLSearchParams(); + + if (mode === "request") { + params.set("mode", mode); + } + params.set("thread", threadId); + + return `/chat?${params.toString()}`; +} + +function resolveAgentProfileName( + agentProfileId: string, + profiles: AgentProfileItem[], +) { + return profiles.find((profile) => profile.id === agentProfileId)?.name ?? agentProfileId; +} + +export function ThreadList({ + threads, + selectedThreadId, + currentMode, + agentProfiles = [], + source, + unavailableReason, +}: ThreadListProps) { + const description = + source === "live" + ? "Visible threads stay explicit and bounded so the operator can anchor work to one continuity record at a time." + : source === "fixture" + ? "Fixture preview keeps the selection surface readable when live continuity configuration is absent." + : "Thread review is temporarily unavailable even though the chat shell is still reachable."; + + return ( + <SectionCard + eyebrow="Visible threads" + title="Select a thread" + description={description} + > + {source === "unavailable" ? ( + <EmptyState + title="Thread continuity unavailable" + description={unavailableReason ?? "The thread list could not be loaded from the continuity API."} + /> + ) : threads.length === 0 ? ( + <EmptyState + title="No threads yet" + description="Create a thread first so assistant replies and governed requests stay attached to a visible continuity record." + /> + ) : ( + <div className="history-list history-list--scrollable"> + {threads.map((thread) => { + const isSelected = thread.id === selectedThreadId; + const agentProfileId = thread.agent_profile_id || DEFAULT_AGENT_PROFILE_ID; + const agentProfileName = resolveAgentProfileName(agentProfileId, agentProfiles); + + return ( + <Link + key={thread.id} + href={buildThreadHref(currentMode, thread.id)} + className={["list-row", isSelected ? "is-selected" : ""].filter(Boolean).join(" ")} + aria-current={isSelected ? "page" : undefined} + > + <div className="list-row__topline"> + <div className="detail-stack"> + <span className="list-row__eyebrow">{isSelected ? "Selected thread" : "Thread"}</span> + <h3 className="list-row__title">{thread.title}</h3> + </div> + <span className="subtle-chip">{formatDate(thread.updated_at)}</span> + </div> + + <div className="list-row__meta"> + <span className="meta-pill">Updated {formatDate(thread.updated_at)}</span> + <span className="meta-pill">Profile {agentProfileName}</span> + <span className="meta-pill mono">{thread.id}</span> + </div> + </Link> + ); + })} + </div> + )} + </SectionCard> + ); +} diff --git a/apps/web/components/thread-summary.test.tsx b/apps/web/components/thread-summary.test.tsx new file mode 100644 index 0000000..004a9df --- /dev/null +++ b/apps/web/components/thread-summary.test.tsx @@ -0,0 +1,201 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import { ThreadSummary } from "./thread-summary"; + +describe("ThreadSummary", () => { + afterEach(() => { + cleanup(); + }); + + it("renders selected thread metadata and continuity counts", () => { + render( + <ThreadSummary + thread={{ + id: "thread-1", + title: "Gamma thread", + agent_profile_id: "coach_default", + created_at: "2026-03-17T10:00:00Z", + updated_at: "2026-03-17T10:05:00Z", + }} + sessions={[ + { + id: "session-1", + thread_id: "thread-1", + status: "active", + started_at: "2026-03-17T10:00:00Z", + ended_at: null, + created_at: "2026-03-17T10:00:00Z", + }, + ]} + events={[ + { + id: "event-1", + thread_id: "thread-1", + session_id: "session-1", + sequence_no: 1, + kind: "message.user", + payload: { text: "Hello" }, + created_at: "2026-03-17T10:01:00Z", + }, + ]} + agentProfiles={[ + { + id: "assistant_default", + name: "Assistant Default", + description: "General-purpose assistant profile for baseline conversations.", + }, + { + id: "coach_default", + name: "Coach Default", + description: "Coaching-oriented profile focused on guidance and accountability.", + }, + ]} + source="live" + />, + ); + + expect(screen.getByText("Gamma thread")).toBeInTheDocument(); + expect(screen.getByText("Live continuity API")).toBeInTheDocument(); + expect(screen.getByText("thread-1")).toBeInTheDocument(); + expect(screen.getByText("Sessions")).toBeInTheDocument(); + expect(screen.getByText("Events")).toBeInTheDocument(); + expect(screen.getByText("active")).toBeInTheDocument(); + expect(screen.getByText("Profile Coach Default")).toBeInTheDocument(); + expect(screen.getByText("Agent profile")).toBeInTheDocument(); + }); + + it("shows the explicit no-thread state when nothing is selected", () => { + render( + <ThreadSummary + thread={null} + sessions={[]} + events={[]} + source="fixture" + />, + ); + + expect(screen.getByText("No thread selected")).toBeInTheDocument(); + expect(screen.getByText("Select a thread")).toBeInTheDocument(); + }); + + it("renders deterministic resumption brief sections when available", () => { + render( + <ThreadSummary + thread={{ + id: "thread-1", + title: "Gamma thread", + agent_profile_id: "assistant_default", + created_at: "2026-03-17T10:00:00Z", + updated_at: "2026-03-17T10:05:00Z", + }} + sessions={[]} + events={[]} + source="live" + resumptionSource="live" + resumptionBrief={{ + assembly_version: "resumption_brief_v0", + thread: { + id: "thread-1", + title: "Gamma thread", + agent_profile_id: "assistant_default", + created_at: "2026-03-17T10:00:00Z", + updated_at: "2026-03-17T10:05:00Z", + }, + conversation: { + items: [ + { + id: "event-1", + thread_id: "thread-1", + session_id: "session-1", + sequence_no: 1, + kind: "message.user", + payload: { text: "Hello" }, + created_at: "2026-03-17T10:01:00Z", + }, + ], + summary: { + limit: 8, + returned_count: 1, + total_count: 1, + order: ["sequence_no_asc"], + kinds: ["message.user", "message.assistant"], + }, + }, + open_loops: { + items: [ + { + id: "loop-1", + memory_id: null, + title: "Follow up on dosage", + status: "open", + opened_at: "2026-03-17T10:02:00Z", + due_at: null, + resolved_at: null, + resolution_note: null, + created_at: "2026-03-17T10:02:00Z", + updated_at: "2026-03-17T10:02:00Z", + }, + ], + summary: { + limit: 5, + returned_count: 1, + total_count: 1, + order: ["opened_at_desc", "created_at_desc", "id_desc"], + }, + }, + memory_highlights: { + items: [ + { + id: "memory-1", + memory_key: "user.preference.dose_time", + value: { value: "morning" }, + status: "active", + source_event_ids: ["event-1"], + memory_type: "preference", + confirmation_status: "confirmed", + created_at: "2026-03-17T10:00:00Z", + updated_at: "2026-03-17T10:03:00Z", + }, + ], + summary: { + limit: 5, + returned_count: 1, + total_count: 1, + order: ["updated_at_asc", "created_at_asc", "id_asc"], + }, + }, + workflow: null, + sources: ["threads", "events", "open_loops", "memories"], + }} + />, + ); + + expect(screen.getByText(/Live deterministic brief/i)).toBeInTheDocument(); + expect(screen.getByText("Latest conversation evidence")).toBeInTheDocument(); + expect(screen.getByText("Follow up on dosage")).toBeInTheDocument(); + expect(screen.getByText("user.preference.dose_time")).toBeInTheDocument(); + }); + + it("shows explicit resumption brief unavailable state", () => { + render( + <ThreadSummary + thread={{ + id: "thread-1", + title: "Gamma thread", + agent_profile_id: "assistant_default", + created_at: "2026-03-17T10:00:00Z", + updated_at: "2026-03-17T10:05:00Z", + }} + sessions={[]} + events={[]} + source="live" + resumptionSource="unavailable" + resumptionUnavailableReason="resumption brief down" + />, + ); + + expect(screen.getByText("Resumption brief unavailable")).toBeInTheDocument(); + expect(screen.getByText("resumption brief down")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/thread-summary.tsx b/apps/web/components/thread-summary.tsx new file mode 100644 index 0000000..9e7b0c6 --- /dev/null +++ b/apps/web/components/thread-summary.tsx @@ -0,0 +1,257 @@ +import { + DEFAULT_AGENT_PROFILE_ID, + type AgentProfileItem, + type ResumptionBrief, + type ThreadEventItem, + type ThreadItem, + type ThreadSessionItem, +} from "../lib/api"; +import { EmptyState } from "./empty-state"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +type ThreadSummaryProps = { + thread: ThreadItem | null; + sessions: ThreadSessionItem[]; + events: ThreadEventItem[]; + agentProfiles?: AgentProfileItem[]; + source: "live" | "fixture" | "unavailable"; + unavailableReason?: string; + resumptionBrief?: ResumptionBrief | null; + resumptionSource?: "live" | "fixture" | "unavailable" | null; + resumptionUnavailableReason?: string; +}; + +function formatDate(value: string) { + return new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(value)); +} + +function formatStatus(status: string | undefined) { + if (!status) { + return "No sessions yet"; + } + + return status.replace(/_/g, " "); +} + +function isConversationEvent(event: ThreadEventItem) { + return event.kind === "message.user" || event.kind === "message.assistant"; +} + +function summarizeEvent(event: ThreadEventItem) { + const payload = event.payload; + if (payload && typeof payload === "object" && "text" in payload && typeof payload.text === "string") { + return payload.text; + } + return event.kind; +} + +function resolveAgentProfileName(agentProfileId: string, profiles: AgentProfileItem[]) { + return profiles.find((profile) => profile.id === agentProfileId)?.name ?? agentProfileId; +} + +export function ThreadSummary({ + thread, + sessions, + events, + agentProfiles = [], + source, + unavailableReason, + resumptionBrief = null, + resumptionSource = null, + resumptionUnavailableReason, +}: ThreadSummaryProps) { + if (source === "unavailable") { + return ( + <SectionCard + eyebrow="Selected thread" + title="Continuity summary unavailable" + description="The thread summary could not be loaded from the continuity API." + > + <EmptyState + title="Summary unavailable" + description={unavailableReason ?? "Thread metadata and continuity counts are temporarily unavailable."} + /> + </SectionCard> + ); + } + + if (!thread) { + return ( + <SectionCard + eyebrow="Selected thread" + title="No thread selected" + description="Choose a visible thread first so the chat surface stays anchored to a single continuity record." + > + <EmptyState + title="Select a thread" + description="The assistant and governed request forms stay disabled until one visible thread is selected." + /> + </SectionCard> + ); + } + + const latestSession = sessions[sessions.length - 1]; + const conversationCount = events.filter(isConversationEvent).length; + const operationalCount = events.length - conversationCount; + const agentProfileId = thread.agent_profile_id || DEFAULT_AGENT_PROFILE_ID; + const agentProfileName = resolveAgentProfileName(agentProfileId, agentProfiles); + + return ( + <SectionCard + eyebrow="Selected thread" + title={thread.title} + description={ + source === "live" + ? "Thread identity and continuity footprint stay explicit while the transcript remains the primary reading surface." + : "Fixture preview keeps the selected thread identity and continuity footprint explicit." + } + > + <div className="thread-summary__topline"> + <StatusBadge + status={latestSession?.status ?? "info"} + label={formatStatus(latestSession?.status)} + /> + <div className="attribute-list"> + <span className="meta-pill">Profile {agentProfileName}</span> + <span className="meta-pill">Created {formatDate(thread.created_at)}</span> + <span className="meta-pill">Updated {formatDate(thread.updated_at)}</span> + </div> + </div> + + <dl className="key-value-grid key-value-grid--compact"> + <div> + <dt>Sessions</dt> + <dd>{sessions.length}</dd> + </div> + <div> + <dt>Events</dt> + <dd>{events.length}</dd> + </div> + <div> + <dt>Latest session</dt> + <dd>{latestSession?.started_at ? formatDate(latestSession.started_at) : "Not started yet"}</dd> + </div> + <div> + <dt>Review mode</dt> + <dd>{source === "live" ? "Live continuity API" : "Fixture preview"}</dd> + </div> + <div> + <dt>Agent profile</dt> + <dd>{agentProfileName}</dd> + </div> + </dl> + + <div className="detail-group detail-group--muted"> + <span className="history-entry__label">Continuity breakdown</span> + <p> + {conversationCount} conversation entries and {operationalCount} supporting operational + events are attached to this thread. + </p> + </div> + + {resumptionSource === "unavailable" ? ( + <div className="detail-group"> + <span className="history-entry__label">Resumption brief</span> + <EmptyState + title="Resumption brief unavailable" + description={ + resumptionUnavailableReason ?? + "The deterministic resumption brief could not be loaded for this thread." + } + /> + </div> + ) : resumptionBrief ? ( + <div className="detail-group"> + <span className="history-entry__label">Resumption brief</span> + <p> + {resumptionSource === "live" + ? "Live deterministic brief" + : "Fixture deterministic brief"}{" "} + from durable continuity seams. + </p> + <dl className="key-value-grid key-value-grid--compact"> + <div> + <dt>Conversation evidence</dt> + <dd> + {resumptionBrief.conversation.summary.returned_count}/ + {resumptionBrief.conversation.summary.total_count} + </dd> + </div> + <div> + <dt>Active open loops</dt> + <dd> + {resumptionBrief.open_loops.summary.returned_count}/ + {resumptionBrief.open_loops.summary.total_count} + </dd> + </div> + <div> + <dt>Memory highlights</dt> + <dd> + {resumptionBrief.memory_highlights.summary.returned_count}/ + {resumptionBrief.memory_highlights.summary.total_count} + </dd> + </div> + <div> + <dt>Workflow posture</dt> + <dd>{resumptionBrief.workflow ? "Present" : "Not linked"}</dd> + </div> + </dl> + + {resumptionBrief.conversation.items.length > 0 ? ( + <div className="detail-group detail-group--muted"> + <span className="history-entry__label">Latest conversation evidence</span> + <ul className="timeline-list"> + {resumptionBrief.conversation.items.map((item) => ( + <li key={item.id} className="timeline-item"> + <p className="mono">#{item.sequence_no} {item.kind}</p> + <p>{summarizeEvent(item)}</p> + </li> + ))} + </ul> + </div> + ) : ( + <p className="muted-copy">No conversation evidence is currently available.</p> + )} + + {resumptionBrief.open_loops.items.length > 0 ? ( + <div className="detail-group detail-group--muted"> + <span className="history-entry__label">Active open loops</span> + <ul className="timeline-list"> + {resumptionBrief.open_loops.items.map((item) => ( + <li key={item.id} className="timeline-item"> + <p className="mono">{item.title}</p> + <p>Status: {item.status}</p> + </li> + ))} + </ul> + </div> + ) : null} + + {resumptionBrief.memory_highlights.items.length > 0 ? ( + <div className="detail-group detail-group--muted"> + <span className="history-entry__label">Memory highlights</span> + <ul className="timeline-list"> + {resumptionBrief.memory_highlights.items.map((item) => ( + <li key={item.id} className="timeline-item"> + <p className="mono">{item.memory_key}</p> + </li> + ))} + </ul> + </div> + ) : null} + </div> + ) : null} + + <div className="detail-group"> + <span className="history-entry__label">Thread ID</span> + <p className="thread-summary__id mono">{thread.id}</p> + </div> + </SectionCard> + ); +} diff --git a/apps/web/components/thread-trace-panel.test.tsx b/apps/web/components/thread-trace-panel.test.tsx new file mode 100644 index 0000000..61a1d07 --- /dev/null +++ b/apps/web/components/thread-trace-panel.test.tsx @@ -0,0 +1,197 @@ +import React from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { threadFixtures } from "../lib/fixtures"; +import { ThreadTracePanel } from "./thread-trace-panel"; + +const { listTracesMock, getTraceDetailMock, getTraceEventsMock } = vi.hoisted(() => ({ + listTracesMock: vi.fn(), + getTraceDetailMock: vi.fn(), + getTraceEventsMock: vi.fn(), +})); + +vi.mock("next/link", () => ({ + default: ({ + href, + children, + className, + "aria-current": ariaCurrent, + }: { + href: string; + children: React.ReactNode; + className?: string; + "aria-current"?: string; + }) => ( + <a href={href} className={className} aria-current={ariaCurrent}> + {children} + </a> + ), +})); + +vi.mock("../lib/api", async () => { + const actual = await vi.importActual("../lib/api"); + return { + ...actual, + listTraces: listTracesMock, + getTraceDetail: getTraceDetailMock, + getTraceEvents: getTraceEventsMock, + }; +}); + +describe("ThreadTracePanel", () => { + beforeEach(() => { + listTracesMock.mockReset(); + getTraceDetailMock.mockReset(); + getTraceEventsMock.mockReset(); + }); + + afterEach(() => { + cleanup(); + }); + + it("renders a bounded empty state when no thread is selected", async () => { + render( + await ThreadTracePanel({ + thread: null, + source: "fixture", + traceTargets: [], + }), + ); + + expect(screen.getByText("Thread-linked explainability")).toBeInTheDocument(); + expect(screen.getByText("Select a thread")).toBeInTheDocument(); + }); + + it("renders fixture explainability detail for a selected linked trace", async () => { + render( + await ThreadTracePanel({ + thread: threadFixtures[0], + source: "fixture", + traceTargets: [ + { + id: "trace-response-101", + label: "Assistant response trace", + }, + { + id: "trace-ctx-401", + label: "Assistant compile trace", + }, + ], + selectedTraceId: "trace-response-101", + traceHrefPrefix: "/chat?thread=11111111-1111-4111-8111-111111111111&trace=", + }), + ); + + expect(screen.getByText("Thread-linked explainability")).toBeInTheDocument(); + expect(screen.getByText("Assistant response review")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /Assistant compile trace/i })).toHaveAttribute( + "href", + "/chat?thread=11111111-1111-4111-8111-111111111111&trace=trace-ctx-401", + ); + expect(screen.getByText("Ordered events")).toBeInTheDocument(); + expect(screen.getByText("Open full trace workspace")).toBeInTheDocument(); + }); + + it("keeps the panel explicit when linked trace IDs are unavailable in the current mode", async () => { + render( + await ThreadTracePanel({ + thread: threadFixtures[2], + source: "fixture", + traceTargets: [ + { + id: "trace-does-not-exist", + label: "Workflow trace", + }, + ], + }), + ); + + expect(screen.getByText("Linked trace unavailable")).toBeInTheDocument(); + expect(screen.getByText(/Missing trace IDs/i)).toBeInTheDocument(); + }); + + it("ignores unrelated trace query ids in live mode and keeps selected-thread scope", async () => { + listTracesMock.mockResolvedValue({ + items: [ + { + id: "trace-related", + thread_id: threadFixtures[0].id, + kind: "response.generate", + compiler_version: "response_generation_v0", + status: "completed", + created_at: "2026-03-17T08:45:04Z", + trace_event_count: 1, + }, + { + id: "trace-foreign", + thread_id: "thread-not-selected", + kind: "response.generate", + compiler_version: "response_generation_v0", + status: "completed", + created_at: "2026-03-17T08:45:10Z", + trace_event_count: 1, + }, + ], + summary: { + total_count: 2, + order: ["created_at_desc", "id_desc"], + }, + }); + getTraceDetailMock.mockResolvedValue({ + trace: { + id: "trace-related", + thread_id: threadFixtures[0].id, + kind: "response.generate", + compiler_version: "response_generation_v0", + status: "completed", + created_at: "2026-03-17T08:45:04Z", + trace_event_count: 1, + limits: { + max_events: 8, + }, + }, + }); + getTraceEventsMock.mockResolvedValue({ + items: [ + { + id: "event-1", + trace_id: "trace-related", + sequence_no: 1, + kind: "response.model.completed", + payload: { + provider: "openai_responses", + }, + created_at: "2026-03-17T08:45:05Z", + }, + ], + summary: { + trace_id: "trace-related", + total_count: 1, + order: ["sequence_asc", "id_asc"], + }, + }); + + render( + await ThreadTracePanel({ + thread: threadFixtures[0], + source: "live", + traceTargets: [ + { + id: "trace-related", + label: "Workflow trace", + }, + ], + selectedTraceId: "trace-foreign", + apiBaseUrl: "https://api.example.com", + userId: "user-1", + }), + ); + + expect(getTraceDetailMock).toHaveBeenCalledWith("https://api.example.com", "trace-related", "user-1"); + expect(screen.getByRole("link", { name: "Open full trace workspace" })).toHaveAttribute( + "href", + "/traces?trace=trace-related", + ); + }); +}); diff --git a/apps/web/components/thread-trace-panel.tsx b/apps/web/components/thread-trace-panel.tsx new file mode 100644 index 0000000..b01070d --- /dev/null +++ b/apps/web/components/thread-trace-panel.tsx @@ -0,0 +1,587 @@ +import Link from "next/link"; + +import type { + ApiSource, + ThreadItem, + TraceReviewEventItem, + TraceReviewItem, + TraceReviewSummaryItem, +} from "../lib/api"; +import { getTraceDetail, getTraceEvents, listTraces } from "../lib/api"; +import { getFixtureTrace } from "../lib/fixtures"; +import { EmptyState } from "./empty-state"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; +import type { TraceEventItem, TraceItem } from "./trace-list"; + +export type ThreadTraceTarget = { + id: string; + label: string; +}; + +type ThreadTracePanelProps = { + thread: ThreadItem | null; + source: ApiSource; + traceTargets: ThreadTraceTarget[]; + selectedTraceId?: string; + traceHrefPrefix?: string; + apiBaseUrl?: string; + userId?: string; +}; + +type TraceOption = { + id: string; + label: string; + available: boolean; + status?: string; + eventCount?: number; +}; + +type LoadedTraceState = { + modeLabel: string; + options: TraceOption[]; + trace: TraceItem | null; + unavailableReason?: string; + unresolvedTargetIds: string[]; +}; + +function formatDate(value: string) { + return new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(value)); +} + +function formatKind(kind: string) { + return kind + .split(/[._]/) + .filter(Boolean) + .map((part) => part[0]?.toUpperCase() + part.slice(1)) + .join(" "); +} + +function formatStatus(status: string) { + return status.replaceAll("_", " "); +} + +function shortId(value: string) { + return value.length > 12 ? `${value.slice(0, 8)}...${value.slice(-4)}` : value; +} + +function stringifyValue(value: unknown): string { + if (typeof value === "string") { + return value; + } + + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + + if (value === null) { + return "null"; + } + + if (Array.isArray(value)) { + return value.map((item) => stringifyValue(item)).join(", "); + } + + if (typeof value === "object") { + return JSON.stringify(value); + } + + return "unknown"; +} + +function buildTraceSummary(trace: TraceReviewSummaryItem | TraceReviewItem) { + const eventLabel = trace.trace_event_count === 1 ? "ordered event" : "ordered events"; + return `${formatKind(trace.kind)} recorded ${trace.trace_event_count} ${eventLabel} for thread ${shortId(trace.thread_id)} and ended in ${formatStatus(trace.status)} status.`; +} + +function buildEventFacts(event: TraceReviewEventItem) { + const payload = event.payload; + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return [`Sequence ${event.sequence_no}`, `Payload: ${stringifyValue(payload)}`]; + } + + const entries = Object.entries(payload as Record<string, unknown>).slice(0, 4); + return [ + `Sequence ${event.sequence_no}`, + ...entries.map(([key, value]) => `${key}: ${stringifyValue(value)}`), + ]; +} + +function buildEventDetail(event: TraceReviewEventItem) { + const payload = event.payload; + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return `This event captured payload value ${stringifyValue(payload)}.`; + } + + const keys = Object.keys(payload as Record<string, unknown>); + if (keys.length === 0) { + return "This event completed without additional payload fields."; + } + + return `This event captured ${keys.length} payload field${keys.length === 1 ? "" : "s"} for operator review.`; +} + +function buildEventTitle(event: TraceReviewEventItem) { + return `${formatKind(event.kind)} event`; +} + +function buildBaseTraceItem(trace: TraceReviewSummaryItem): TraceItem { + return { + id: trace.id, + kind: trace.kind, + status: trace.status, + title: `${formatKind(trace.kind)} review`, + summary: buildTraceSummary(trace), + eventCount: trace.trace_event_count, + createdAt: trace.created_at, + source: trace.compiler_version, + scope: `Thread ${shortId(trace.thread_id)}`, + related: { + threadId: trace.thread_id, + compilerVersion: trace.compiler_version, + }, + metadata: [ + `Trace: ${trace.id}`, + `Thread: ${trace.thread_id}`, + `Compiler: ${trace.compiler_version}`, + `Status: ${formatStatus(trace.status)}`, + ], + evidence: [], + events: [], + detailSource: "live", + eventSource: "live", + }; +} + +function buildLiveTraceItem( + trace: TraceReviewItem, + events: TraceReviewEventItem[], + options?: { + eventsUnavailable?: boolean; + }, +): TraceItem { + const metadata = [ + `Trace: ${trace.id}`, + `Thread: ${trace.thread_id}`, + `Compiler: ${trace.compiler_version}`, + `Status: ${formatStatus(trace.status)}`, + ...Object.entries(trace.limits).map(([key, value]) => `Limit ${key}: ${stringifyValue(value)}`), + ]; + + const evidence = events.length + ? [ + `${events.length} ordered event${events.length === 1 ? "" : "s"} loaded from the shipped trace review API.`, + ] + : ["No ordered events were returned for this trace."]; + + return { + ...buildBaseTraceItem(trace), + metadata, + evidence, + events: events.map<TraceEventItem>((event) => ({ + id: event.id, + kind: event.kind, + title: buildEventTitle(event), + detail: buildEventDetail(event), + facts: buildEventFacts(event), + })), + eventsUnavailable: options?.eventsUnavailable, + }; +} + +function normalizeTargets(traceTargets: ThreadTraceTarget[]) { + const targetMap = new Map<string, ThreadTraceTarget>(); + + for (const target of traceTargets) { + const id = target.id.trim(); + if (!id || targetMap.has(id)) { + continue; + } + + targetMap.set(id, { + id, + label: target.label, + }); + } + + return [...targetMap.values()]; +} + +function buildTraceHref(traceId: string, traceHrefPrefix?: string) { + const prefix = traceHrefPrefix ?? "/traces?trace="; + return `${prefix}${encodeURIComponent(traceId)}`; +} + +function toMessage(error: unknown, fallback: string) { + if (error instanceof Error && error.message) { + return error.message; + } + + return fallback; +} + +function prioritizeId(ids: string[], prioritizedId: string) { + if (!prioritizedId || !ids.includes(prioritizedId)) { + return ids; + } + + return [prioritizedId, ...ids.filter((id) => id !== prioritizedId)]; +} + +async function loadFixtureTraceState( + normalizedTargets: ThreadTraceTarget[], + selectedTraceId: string, +): Promise<LoadedTraceState> { + const targetLabelById = new Map(normalizedTargets.map((target) => [target.id, target.label])); + const scopedTargetIds = prioritizeId( + [...new Set(normalizedTargets.map((target) => target.id))], + selectedTraceId, + ); + const fixtureById = new Map<string, TraceItem>( + scopedTargetIds + .map((traceId) => [traceId, getFixtureTrace(traceId)] as const) + .filter((entry): entry is readonly [string, TraceItem] => Boolean(entry[1])), + ); + + const availableOptions = scopedTargetIds + .map<TraceOption>((traceId) => { + const fixtureTrace = fixtureById.get(traceId); + return { + id: traceId, + label: targetLabelById.get(traceId) ?? `Trace ${shortId(traceId)}`, + available: Boolean(fixtureTrace), + status: fixtureTrace?.status, + eventCount: fixtureTrace?.eventCount, + }; + }) + .filter((option) => option.available); + + const trace = fixtureById.get(availableOptions[0]?.id ?? "") ?? null; + + return { + modeLabel: "Fixture explainability", + options: availableOptions, + trace, + unresolvedTargetIds: normalizedTargets + .filter((target) => !fixtureById.has(target.id)) + .map((target) => target.id), + }; +} + +async function loadLiveTraceState( + threadId: string, + normalizedTargets: ThreadTraceTarget[], + selectedTraceId: string, + apiBaseUrl: string, + userId: string, +): Promise<LoadedTraceState> { + let summaries: TraceReviewSummaryItem[] = []; + let listUnavailableReason: string | undefined; + + try { + const payload = await listTraces(apiBaseUrl, userId); + summaries = payload.items; + } catch (error) { + listUnavailableReason = toMessage(error, "Trace summaries could not be loaded."); + } + + const summariesById = new Map<string, TraceReviewSummaryItem>(summaries.map((trace) => [trace.id, trace])); + const threadSummaries = summaries.filter((trace) => trace.thread_id === threadId); + const targetLabelById = new Map(normalizedTargets.map((target) => [target.id, target.label])); + const scopedIds = [...new Set([...normalizedTargets.map((target) => target.id), ...threadSummaries.map((trace) => trace.id)])]; + const orderedIds = prioritizeId(scopedIds, selectedTraceId); + + if (orderedIds.length === 0) { + return { + modeLabel: "Live explainability", + options: [], + trace: null, + unavailableReason: listUnavailableReason, + unresolvedTargetIds: [], + }; + } + + const options = orderedIds.map<TraceOption>((id) => { + const summary = summariesById.get(id); + return { + id, + label: targetLabelById.get(id) ?? (summary ? `${formatKind(summary.kind)} trace` : `Trace ${shortId(id)}`), + available: Boolean(summary), + status: summary?.status, + eventCount: summary?.trace_event_count, + }; + }); + + const activeId = orderedIds[0]; + const selectedSummary = summariesById.get(activeId); + let trace: TraceItem | null = null; + + try { + const detailPayload = await getTraceDetail(apiBaseUrl, activeId, userId); + + try { + const eventPayload = await getTraceEvents(apiBaseUrl, activeId, userId); + trace = buildLiveTraceItem(detailPayload.trace, eventPayload.items); + } catch { + trace = buildLiveTraceItem(detailPayload.trace, [], { + eventsUnavailable: true, + }); + } + } catch (error) { + if (selectedSummary) { + trace = { + ...buildBaseTraceItem(selectedSummary), + evidence: [ + "The selected summary came from the live trace list, but full trace detail could not be read.", + ], + detailUnavailable: true, + eventsUnavailable: true, + }; + } else if (!listUnavailableReason) { + return { + modeLabel: "Live explainability", + options, + trace: null, + unavailableReason: toMessage(error, "Trace detail could not be loaded."), + unresolvedTargetIds: normalizedTargets + .filter((target) => !summariesById.has(target.id)) + .map((target) => target.id), + }; + } + } + + return { + modeLabel: "Live explainability", + options, + trace, + unavailableReason: listUnavailableReason, + unresolvedTargetIds: normalizedTargets + .filter((target) => !summariesById.has(target.id)) + .map((target) => target.id), + }; +} + +export async function ThreadTracePanel({ + thread, + source, + traceTargets, + selectedTraceId = "", + traceHrefPrefix, + apiBaseUrl, + userId, +}: ThreadTracePanelProps) { + if (!thread) { + return ( + <SectionCard + eyebrow="Explain why" + title="Thread-linked explainability" + description="A bounded explain-why inspector appears here once one thread is selected." + > + <EmptyState + title="Select a thread" + description="Choose a thread first to inspect its linked trace explanation inside chat." + /> + </SectionCard> + ); + } + + const normalizedTargets = normalizeTargets(traceTargets); + const state = + source === "live" && apiBaseUrl && userId + ? await loadLiveTraceState(thread.id, normalizedTargets, selectedTraceId, apiBaseUrl, userId) + : await loadFixtureTraceState(normalizedTargets, selectedTraceId); + + if (state.unavailableReason && !state.trace) { + return ( + <SectionCard + eyebrow="Explain why" + title="Thread-linked explainability unavailable" + description="The selected thread's explain-why data could not be loaded from the configured trace review seam." + className="thread-trace-panel" + > + <div className="thread-trace-panel__summary-row"> + <span className="subtle-chip">Thread: {thread.title}</span> + <span className="subtle-chip">{state.modeLabel}</span> + </div> + <EmptyState + title="Explainability unavailable" + description={state.unavailableReason} + /> + </SectionCard> + ); + } + + if (!state.trace) { + const hasUnresolvedTargets = state.unresolvedTargetIds.length > 0; + + return ( + <SectionCard + eyebrow="Explain why" + title="No linked explain-why trace" + description="This panel stays bounded and only renders when a selected-thread trace can be read or matched." + className="thread-trace-panel" + > + <div className="thread-trace-panel__summary-row"> + <span className="subtle-chip">Thread: {thread.title}</span> + <span className="subtle-chip">{state.modeLabel}</span> + </div> + <EmptyState + title={hasUnresolvedTargets ? "Linked trace unavailable" : "No linked trace yet"} + description={ + hasUnresolvedTargets + ? "The selected thread references trace IDs that are not currently available in this mode." + : "When the selected thread exposes approval, task-step, execution, or response traces, a bounded explain-why inspector appears here." + } + /> + {hasUnresolvedTargets ? ( + <p className="responsive-note"> + Missing trace IDs: {state.unresolvedTargetIds.map((traceId) => shortId(traceId)).join(", ")} + </p> + ) : null} + </SectionCard> + ); + } + + const selectedLabel = + state.options.find((option) => option.id === state.trace?.id)?.label ?? + `${formatKind(state.trace.kind)} trace`; + const activeTrace = state.trace; + + return ( + <SectionCard + eyebrow="Explain why" + title="Thread-linked explainability" + description="One bounded inspector keeps the selected trace summary, metadata, and ordered events visible beside transcript and workflow review." + className="thread-trace-panel" + > + <div className="thread-trace-panel__summary-row"> + <StatusBadge status={activeTrace.status} /> + <span className="subtle-chip">Thread: {thread.title}</span> + <span className="subtle-chip">{state.modeLabel}</span> + <span className="subtle-chip">{selectedLabel}</span> + </div> + + {state.options.length > 1 ? ( + <div className="thread-trace-panel__options"> + {state.options.map((option) => ( + <Link + key={option.id} + href={buildTraceHref(option.id, traceHrefPrefix)} + className={[ + "button-secondary", + "button-secondary--compact", + option.id === activeTrace.id ? "is-current" : null, + ] + .filter(Boolean) + .join(" ")} + aria-current={option.id === activeTrace.id ? "page" : undefined} + > + {option.label} + {option.eventCount !== undefined ? ` · ${option.eventCount} events` : ""} + </Link> + ))} + </div> + ) : null} + + <div className="trace-panel trace-panel--embedded"> + <div className="detail-summary"> + <StatusBadge status={activeTrace.status} /> + <span className="detail-summary__label"> + {activeTrace.kind.replaceAll(".", " ")} · {activeTrace.eventCount} events + </span> + </div> + + <div className="trace-summary"> + <h3 className="thread-trace-panel__trace-title">{activeTrace.title}</h3> + <p>{activeTrace.summary}</p> + <div className="attribute-list"> + <span className="attribute-item">Source: {activeTrace.source}</span> + <span className="attribute-item">Scope: {activeTrace.scope}</span> + <span className="attribute-item">Captured: {formatDate(activeTrace.createdAt)}</span> + <span className="attribute-item"> + Detail: {activeTrace.detailSource === "live" ? "Live trace detail" : "Fixture trace detail"} + </span> + <span className="attribute-item"> + Events: {activeTrace.eventSource === "live" ? "Live event review" : "Fixture event review"} + </span> + </div> + </div> + + <div className="detail-group detail-group--muted"> + <h3>Key metadata</h3> + <div className="evidence-list"> + {activeTrace.metadata.map((item) => ( + <span key={item} className="evidence-chip"> + {item} + </span> + ))} + </div> + </div> + + {activeTrace.evidence.length > 0 ? ( + <div className="detail-group"> + <h3>Review notes</h3> + <div className="evidence-list"> + {activeTrace.evidence.map((item) => ( + <span key={item} className="evidence-chip"> + {item} + </span> + ))} + </div> + </div> + ) : null} + + <div className="detail-group"> + <h3>Ordered events</h3> + {activeTrace.eventsUnavailable ? ( + <EmptyState + title="Ordered events unavailable" + description="The trace summary loaded, but ordered event reads are unavailable right now." + className="empty-state--compact" + /> + ) : activeTrace.events.length === 0 ? ( + <EmptyState + title="No ordered events" + description="This trace currently has no event records to review." + className="empty-state--compact" + /> + ) : ( + <ol className="trace-events"> + {activeTrace.events.map((event) => ( + <li key={event.id} className="trace-event"> + <div className="trace-event__topline"> + <div className="detail-stack"> + <span className="list-row__eyebrow">{event.kind}</span> + <h4>{event.title}</h4> + </div> + </div> + <p>{event.detail}</p> + {event.facts?.length ? ( + <div className="attribute-list"> + {event.facts.map((fact) => ( + <span key={fact} className="attribute-item"> + {fact} + </span> + ))} + </div> + ) : null} + </li> + ))} + </ol> + )} + </div> + </div> + + <div className="thread-trace-panel__footer"> + <Link href={`/traces?trace=${activeTrace.id}`} className="button-secondary button-secondary--compact"> + Open full trace workspace + </Link> + </div> + </SectionCard> + ); +} diff --git a/apps/web/components/thread-workflow-panel.test.tsx b/apps/web/components/thread-workflow-panel.test.tsx new file mode 100644 index 0000000..47b877a --- /dev/null +++ b/apps/web/components/thread-workflow-panel.test.tsx @@ -0,0 +1,144 @@ +import React from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + approvalFixtures, + executionFixtures, + getFixtureTaskStepSummary, + getFixtureTaskSteps, + taskFixtures, + threadFixtures, +} from "../lib/fixtures"; +import { ThreadWorkflowPanel } from "./thread-workflow-panel"; + +vi.mock("next/link", () => ({ + default: ({ + href, + children, + className, + }: { + href: string; + children: React.ReactNode; + className?: string; + }) => ( + <a href={href} className={className}> + {children} + </a> + ), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + refresh: vi.fn(), + }), +})); + +describe("ThreadWorkflowPanel", () => { + afterEach(() => { + cleanup(); + }); + + it("renders embedded approval and task review for a thread with linked workflow", () => { + render( + <ThreadWorkflowPanel + thread={threadFixtures[1]} + approval={approvalFixtures[1]} + approvalSource="fixture" + task={taskFixtures[1]} + taskSource="fixture" + execution={executionFixtures[0]} + executionSource="fixture" + taskSteps={getFixtureTaskSteps(taskFixtures[1].id)} + taskStepSummary={getFixtureTaskStepSummary(taskFixtures[1].id)} + taskStepSource="fixture" + />, + ); + + expect(screen.getByText("Governed workflow review")).toBeInTheDocument(); + expect(screen.getByText("Fixture workflow")).toBeInTheDocument(); + expect(screen.getByText("Approval: approved")).toBeInTheDocument(); + expect(screen.getByText("Task: executed")).toBeInTheDocument(); + expect(screen.getByText("Execution: completed")).toBeInTheDocument(); + expect(screen.getByText("Approval detail")).toBeInTheDocument(); + expect(screen.getByText("Selected task")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Executed" })).toBeDisabled(); + expect(screen.getByText("Post-execution memory write-back")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Submit memory write-back" })).toBeDisabled(); + expect(screen.getByText("Open linked approval")).toBeInTheDocument(); + expect(screen.getByText("Ordered lifecycle steps")).toBeInTheDocument(); + expect(screen.getByText("1 steps")).toBeInTheDocument(); + }); + + it("shows an explicit empty state when the selected thread has no linked workflow", () => { + render( + <ThreadWorkflowPanel + thread={threadFixtures[2]} + approval={null} + approvalSource="fixture" + task={null} + taskSource="fixture" + execution={null} + executionSource={null} + />, + ); + + expect(screen.getByText("No governed workflow linked yet")).toBeInTheDocument(); + expect( + screen.getByText(/When this thread produces an approval-gated request/i), + ).toBeInTheDocument(); + }); + + it("renders partial unavailable states without hiding the rest of the live workflow review", () => { + render( + <ThreadWorkflowPanel + thread={threadFixtures[0]} + approval={null} + approvalSource="unavailable" + approvalUnavailableReason="Approval API timed out." + task={taskFixtures[0]} + taskSource="live" + execution={null} + executionSource="unavailable" + executionUnavailableReason="Execution API timed out." + taskSteps={[]} + taskStepSummary={null} + taskStepSource="unavailable" + taskStepUnavailableReason="Task step API timed out." + apiBaseUrl="https://api.example.com" + userId="user-1" + />, + ); + + expect(screen.getByText("Partial workflow review")).toBeInTheDocument(); + expect(screen.getByText("Approval review unavailable")).toBeInTheDocument(); + expect(screen.getByText("Approval API timed out.")).toBeInTheDocument(); + expect(screen.getByText("Selected task")).toBeInTheDocument(); + expect(screen.getByText("Task-step timeline unavailable")).toBeInTheDocument(); + expect(screen.getByText("Task step API timed out.")).toBeInTheDocument(); + expect(screen.getByText("Execution review could not be loaded")).toBeInTheDocument(); + expect(screen.getByText("Execution API timed out.")).toBeInTheDocument(); + }); + + it("shows execution review inside task summary when approval detail is absent but execution data exists", () => { + render( + <ThreadWorkflowPanel + thread={threadFixtures[1]} + approval={null} + approvalSource="fixture" + task={taskFixtures[1]} + taskSource="fixture" + execution={executionFixtures[0]} + executionSource="fixture" + taskSteps={getFixtureTaskSteps(taskFixtures[1].id)} + taskStepSummary={getFixtureTaskStepSummary(taskFixtures[1].id)} + taskStepSource="fixture" + />, + ); + + expect(screen.getByText("Selected task")).toBeInTheDocument(); + expect(screen.getByText("Execution review")).toBeInTheDocument(); + expect(screen.getByText("Execution record in review")).toBeInTheDocument(); + expect(screen.getByText("Fixture execution detail")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/thread-workflow-panel.tsx b/apps/web/components/thread-workflow-panel.tsx new file mode 100644 index 0000000..f16cb72 --- /dev/null +++ b/apps/web/components/thread-workflow-panel.tsx @@ -0,0 +1,251 @@ +import Link from "next/link"; + +import type { + ApiSource, + ApprovalItem, + TaskItem, + TaskStepItem, + TaskStepListSummary, + ThreadItem, + ToolExecutionItem, +} from "../lib/api"; +import { ApprovalDetail } from "./approval-detail"; +import { EmptyState } from "./empty-state"; +import { ExecutionSummary } from "./execution-summary"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; +import { TaskStepList } from "./task-step-list"; +import { TaskSummary } from "./task-summary"; + +type WorkflowSource = ApiSource | "unavailable"; + +type ThreadWorkflowPanelProps = { + thread: ThreadItem | null; + approval: ApprovalItem | null; + approvalSource: WorkflowSource; + approvalUnavailableReason?: string; + task: TaskItem | null; + taskSource: WorkflowSource; + taskUnavailableReason?: string; + execution: ToolExecutionItem | null; + executionSource: WorkflowSource | null; + executionUnavailableReason?: string; + taskSteps?: TaskStepItem[]; + taskStepSummary?: TaskStepListSummary | null; + taskStepSource?: WorkflowSource | null; + taskStepUnavailableReason?: string; + apiBaseUrl?: string; + userId?: string; + traceHrefPrefix?: string; +}; + +function workflowModeLabel( + approvalSource: WorkflowSource, + taskSource: WorkflowSource, + executionSource: WorkflowSource | null, +) { + const sources = [approvalSource, taskSource, executionSource].filter(Boolean); + + if (sources.some((source) => source === "unavailable")) { + return "Partial workflow review"; + } + + return sources.every((source) => source === "fixture") ? "Fixture workflow" : "Live workflow"; +} + +export function ThreadWorkflowPanel({ + thread, + approval, + approvalSource, + approvalUnavailableReason, + task, + taskSource, + taskUnavailableReason, + execution, + executionSource, + executionUnavailableReason, + taskSteps = [], + taskStepSummary = null, + taskStepSource = null, + taskStepUnavailableReason, + apiBaseUrl, + userId, + traceHrefPrefix, +}: ThreadWorkflowPanelProps) { + if (!thread) { + return ( + <SectionCard + eyebrow="Thread-linked workflow" + title="Governed workflow stays with the thread" + description="Approval, task, and execution review appears here once one visible thread is selected." + > + <EmptyState + title="Select a thread" + description="Choose or create a thread first so workflow review stays attached to one durable conversation." + /> + </SectionCard> + ); + } + + const hasWorkflow = Boolean(approval || task || execution); + const hasUnavailableState = + approvalSource === "unavailable" || taskSource === "unavailable" || executionSource === "unavailable"; + const resolvedTaskStepSource = taskStepSource ?? (task ? taskSource : null); + const linkedTraceTargets = new Map<string, string>(); + + if (approval?.routing.trace.trace_id) { + linkedTraceTargets.set(approval.routing.trace.trace_id, "Open approval routing trace"); + } + + if (execution?.trace_id && !linkedTraceTargets.has(execution.trace_id)) { + linkedTraceTargets.set(execution.trace_id, "Open execution trace"); + } + + return ( + <SectionCard + eyebrow="Thread-linked workflow" + title="Governed workflow review" + description="The latest approval, task, and execution state stays visible beside the selected thread without turning chat into a separate operations dashboard." + className="thread-workflow-panel" + > + <div className="thread-workflow-panel__summary"> + <StatusBadge status={approval?.status ?? task?.status ?? execution?.status ?? "info"} /> + <div className="thread-workflow-panel__chips"> + <span className="subtle-chip">{workflowModeLabel(approvalSource, taskSource, executionSource)}</span> + <span className="subtle-chip">Thread: {thread.title}</span> + {approval ? <span className="subtle-chip">Approval: {approval.status.replace(/_/g, " ")}</span> : null} + {task ? <span className="subtle-chip">Task: {task.status.replace(/_/g, " ")}</span> : null} + {execution ? ( + <span className="subtle-chip">Execution: {execution.status.replace(/_/g, " ")}</span> + ) : null} + </div> + </div> + + {linkedTraceTargets.size > 0 ? ( + <div className="thread-workflow-panel__trace-links"> + <p className="history-entry__label">Explain-why shortcuts</p> + <div className="cluster"> + {[...linkedTraceTargets.entries()].map(([traceId, label]) => ( + <Link + key={traceId} + href={`${traceHrefPrefix ?? "/traces?trace="}${encodeURIComponent(traceId)}`} + className="button-secondary button-secondary--compact" + > + {label} + </Link> + ))} + </div> + </div> + ) : null} + + {!hasWorkflow && !hasUnavailableState ? ( + <EmptyState + title="No governed workflow linked yet" + description="When this thread produces an approval-gated request, the latest approval, task, and execution review will appear here." + /> + ) : ( + <div className="thread-workflow-panel__stack"> + {approval ? ( + <ApprovalDetail + initialApproval={approval} + detailSource={approvalSource === "fixture" ? "fixture" : "live"} + initialExecution={execution} + executionSource={executionSource === "fixture" || executionSource === "live" ? executionSource : null} + executionUnavailableMessage={executionUnavailableReason} + apiBaseUrl={apiBaseUrl} + userId={userId} + chrome="embedded" + /> + ) : approvalSource === "unavailable" ? ( + <EmptyState + title="Approval review unavailable" + description={ + approvalUnavailableReason ?? + "Approval state could not be loaded for the selected thread from the configured backend." + } + className="empty-state--compact" + /> + ) : null} + + {task ? ( + <TaskSummary + task={task} + taskSource={taskSource === "fixture" ? "fixture" : "live"} + stepSource={ + resolvedTaskStepSource === "fixture" + ? "fixture" + : resolvedTaskStepSource === "unavailable" + ? "unavailable" + : "live" + } + execution={execution} + executionSource={executionSource === "fixture" || executionSource === "live" ? executionSource : null} + executionUnavailableMessage={executionUnavailableReason} + chrome="embedded" + showExecutionReview={!approval} + /> + ) : taskSource === "unavailable" ? ( + <EmptyState + title="Task review unavailable" + description={ + taskUnavailableReason ?? + "Task state could not be loaded for the selected thread from the configured backend." + } + className="empty-state--compact" + /> + ) : null} + + {task ? ( + resolvedTaskStepSource === "unavailable" ? ( + <SectionCard + eyebrow="Task steps" + title="Task-step timeline unavailable" + description="The selected-thread task-step sequence could not be loaded from the configured backend." + className="section-card--embedded task-step-list--embedded" + > + <EmptyState + title="Task-step review unavailable" + description={ + taskStepUnavailableReason ?? + "Task-step records could not be loaded for the selected thread's latest linked task." + } + className="empty-state--compact" + /> + </SectionCard> + ) : ( + <TaskStepList + steps={taskSteps} + summary={taskStepSummary} + source={resolvedTaskStepSource === "fixture" ? "fixture" : "live"} + chrome="embedded" + traceHrefPrefix={traceHrefPrefix} + /> + ) + ) : null} + + {!approval && !task && (execution || executionSource === "unavailable") ? ( + <SectionCard + eyebrow="Execution review" + title={execution ? "Latest execution record" : "Execution review unavailable"} + description="Execution outcome stays visible even when approval or task detail is missing from the selected-thread workflow review." + className="section-card--embedded" + > + <ExecutionSummary + execution={execution} + source={executionSource === "fixture" || executionSource === "live" ? executionSource : null} + unavailableMessage={ + execution + ? null + : executionUnavailableReason ?? + "Execution state could not be loaded for the selected thread from the configured backend." + } + emptyTitle="Execution record is unavailable" + emptyDescription="Execution review appears here when a selected-thread execution record can be loaded." + /> + </SectionCard> + ) : null} + </div> + )} + </SectionCard> + ); +} diff --git a/apps/web/components/trace-list.test.tsx b/apps/web/components/trace-list.test.tsx new file mode 100644 index 0000000..1807ae4 --- /dev/null +++ b/apps/web/components/trace-list.test.tsx @@ -0,0 +1,239 @@ +import React from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import TracesPage from "../app/traces/page"; +import { TraceList, type TraceItem } from "./trace-list"; + +const { + getApiConfigMock, + hasLiveApiConfigMock, + listTracesMock, + getTraceDetailMock, + getTraceEventsMock, +} = vi.hoisted(() => ({ + getApiConfigMock: vi.fn(), + hasLiveApiConfigMock: vi.fn(), + listTracesMock: vi.fn(), + getTraceDetailMock: vi.fn(), + getTraceEventsMock: vi.fn(), +})); + +vi.mock("next/link", () => ({ + default: ({ + href, + children, + className, + }: { + href: string; + children: React.ReactNode; + className?: string; + }) => ( + <a href={href} className={className}> + {children} + </a> + ), +})); + +vi.mock("../lib/api", async () => { + const actual = await vi.importActual("../lib/api"); + return { + ...actual, + getApiConfig: getApiConfigMock, + hasLiveApiConfig: hasLiveApiConfigMock, + listTraces: listTracesMock, + getTraceDetail: getTraceDetailMock, + getTraceEvents: getTraceEventsMock, + }; +}); + +const liveTrace: TraceItem = { + id: "trace-1", + kind: "context.compile", + status: "completed", + title: "Context Compile review", + summary: "Context Compile recorded 2 ordered events for thread thread-1 and ended in completed status.", + eventCount: 2, + createdAt: "2026-03-17T00:00:00Z", + source: "continuity_v0", + scope: "Thread thread-1", + related: { + threadId: "thread-1", + compilerVersion: "continuity_v0", + }, + metadata: ["Trace: trace-1", "Thread: thread-1", "Compiler: continuity_v0"], + evidence: ["2 ordered events loaded from the shipped trace review API."], + events: [ + { + id: "event-1", + kind: "context.summary", + title: "Context Summary event", + detail: "This event captured 1 payload field for operator review.", + facts: ["Sequence 1", "thread_id: thread-1"], + }, + ], + detailSource: "live", + eventSource: "live", +}; + +describe("TraceList", () => { + beforeEach(() => { + getApiConfigMock.mockReset(); + hasLiveApiConfigMock.mockReset(); + listTracesMock.mockReset(); + getTraceDetailMock.mockReset(); + getTraceEventsMock.mockReset(); + }); + + afterEach(() => { + cleanup(); + }); + + it("renders an explicit unavailable state when the configured trace API cannot be reached", () => { + render(<TraceList traces={[]} apiUnavailable />); + + expect(screen.getByText("Trace API unavailable")).toBeInTheDocument(); + expect(screen.getByText("No live trace detail")).toBeInTheDocument(); + }); + + it("renders an empty split state when no traces are available", () => { + render(<TraceList traces={[]} />); + + expect(screen.getByText("Trace review is empty")).toBeInTheDocument(); + expect(screen.getByText("Explain-why detail is idle")).toBeInTheDocument(); + }); + + it("keeps the selected trace bounded when ordered events are unavailable", () => { + render( + <TraceList + traces={[ + { + ...liveTrace, + events: [], + eventsUnavailable: true, + }, + ]} + selectedId="trace-1" + />, + ); + + expect(screen.getByText("Key metadata")).toBeInTheDocument(); + expect(screen.getByText("Ordered events unavailable")).toBeInTheDocument(); + expect(screen.getByText("Detail: Live trace detail")).toBeInTheDocument(); + }); + + it("renders ordered event review for a selected trace", () => { + render(<TraceList traces={[liveTrace]} selectedId="trace-1" />); + + expect(screen.getAllByText("Context Compile review")).toHaveLength(2); + expect(screen.getByText("Context Summary event")).toBeInTheDocument(); + expect(screen.getByText("Sequence 1")).toBeInTheDocument(); + }); +}); + +describe("TracesPage", () => { + beforeEach(() => { + getApiConfigMock.mockReset(); + hasLiveApiConfigMock.mockReset(); + listTracesMock.mockReset(); + getTraceDetailMock.mockReset(); + getTraceEventsMock.mockReset(); + }); + + afterEach(() => { + cleanup(); + }); + + it("stays fixture-backed when live API configuration is absent", async () => { + getApiConfigMock.mockReturnValue({ + apiBaseUrl: "", + userId: "", + defaultThreadId: "", + defaultToolId: "", + }); + hasLiveApiConfigMock.mockReturnValue(false); + + render( + await TracesPage({ + searchParams: Promise.resolve({ + trace: "trace-approval-101", + }), + }), + ); + + expect(screen.getByText("Fixture-backed")).toBeInTheDocument(); + expect(screen.getAllByText("Approval request review").length).toBeGreaterThan(0); + expect(listTracesMock).not.toHaveBeenCalled(); + }); + + it("shows an explicit unavailable state when the live trace list cannot be loaded", async () => { + getApiConfigMock.mockReturnValue({ + apiBaseUrl: "https://api.example.com", + userId: "user-1", + defaultThreadId: "", + defaultToolId: "", + }); + hasLiveApiConfigMock.mockReturnValue(true); + listTracesMock.mockRejectedValue(new Error("trace list failed")); + + render(await TracesPage({ searchParams: Promise.resolve({}) })); + + expect(screen.getAllByText("Trace API unavailable")).toHaveLength(2); + expect(screen.getByText("Explainability review is unavailable")).toBeInTheDocument(); + }); + + it("keeps the live route bounded when detail loads but ordered events fail", async () => { + getApiConfigMock.mockReturnValue({ + apiBaseUrl: "https://api.example.com", + userId: "user-1", + defaultThreadId: "", + defaultToolId: "", + }); + hasLiveApiConfigMock.mockReturnValue(true); + listTracesMock.mockResolvedValue({ + items: [ + { + id: "trace-1", + thread_id: "thread-1", + kind: "context.compile", + compiler_version: "continuity_v0", + status: "completed", + created_at: "2026-03-17T00:00:00Z", + trace_event_count: 2, + }, + ], + summary: { + total_count: 1, + order: ["created_at_desc", "id_desc"], + }, + }); + getTraceDetailMock.mockResolvedValue({ + trace: { + id: "trace-1", + thread_id: "thread-1", + kind: "context.compile", + compiler_version: "continuity_v0", + status: "completed", + created_at: "2026-03-17T00:00:00Z", + trace_event_count: 2, + limits: { + max_sessions: 3, + max_events: 8, + }, + }, + }); + getTraceEventsMock.mockRejectedValue(new Error("event read failed")); + + render( + await TracesPage({ + searchParams: Promise.resolve({ + trace: "trace-1", + }), + }), + ); + + expect(screen.getByText("Live API")).toBeInTheDocument(); + expect(screen.getByText("Ordered events unavailable")).toBeInTheDocument(); + expect(screen.getByText("Limit max_events: 8")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/trace-list.tsx b/apps/web/components/trace-list.tsx new file mode 100644 index 0000000..e2362f5 --- /dev/null +++ b/apps/web/components/trace-list.tsx @@ -0,0 +1,265 @@ +import Link from "next/link"; + +import type { ApiSource } from "../lib/api"; +import { EmptyState } from "./empty-state"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; + +export type TraceEventItem = { + id: string; + kind: string; + title: string; + detail: string; + facts?: string[]; +}; + +export type TraceItem = { + id: string; + kind: string; + status: string; + title: string; + summary: string; + eventCount: number; + createdAt: string; + source: string; + scope: string; + related: { + threadId?: string; + taskId?: string; + approvalId?: string; + executionId?: string; + compilerVersion?: string; + }; + metadata: string[]; + evidence: string[]; + events: TraceEventItem[]; + detailSource: ApiSource; + eventSource: ApiSource; + detailUnavailable?: boolean; + eventsUnavailable?: boolean; +}; + +function formatDate(value: string) { + return new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(value)); +} + +export function TraceList({ + traces, + selectedId, + apiUnavailable = false, +}: { + traces: TraceItem[]; + selectedId?: string; + apiUnavailable?: boolean; +}) { + if (apiUnavailable) { + return ( + <div className="split-layout"> + <SectionCard + eyebrow="Trace list" + title="Trace API unavailable" + description="The live explain-why list could not be loaded from the configured backend." + > + <EmptyState + title="Explainability review is unavailable" + description="The configured trace review endpoints did not return a usable response. Verify the API and retry." + /> + </SectionCard> + + <SectionCard + eyebrow="Trace detail" + title="Detail unavailable" + description="Detail and ordered events stay hidden until the trace review API becomes reachable again." + > + <EmptyState + title="No live trace detail" + description="The detail panel remains bounded instead of falling back to stale or invented event data." + /> + </SectionCard> + </div> + ); + } + + if (traces.length === 0) { + return ( + <div className="split-layout"> + <SectionCard + eyebrow="Trace list" + title="No trace records" + description="Explainability entries will appear here when trace sources are available." + > + <EmptyState + title="Trace review is empty" + description="No trace summaries are available in the current mode." + /> + </SectionCard> + + <SectionCard + eyebrow="Trace detail" + title="No trace selected" + description="Select a trace once explainability records are available." + > + <EmptyState + title="Explain-why detail is idle" + description="The detail panel stays empty until a trace summary can be selected." + /> + </SectionCard> + </div> + ); + } + + const selected = traces.find((trace) => trace.id === selectedId) ?? traces[0]; + + return ( + <div className="split-layout"> + <SectionCard + eyebrow="Trace list" + title="Explainability summaries" + description="Trace rows surface kind, status, and event count before you open the bounded review panel." + > + <div className="list-panel"> + <div className="list-panel__header"> + <p>{traces.length} explainability entries</p> + </div> + <div className="list-rows"> + {traces.map((trace) => ( + <Link + key={trace.id} + href={`/traces?trace=${trace.id}`} + className={`list-row${trace.id === selected.id ? " is-selected" : ""}`} + > + <div className="list-row__topline"> + <div className="detail-stack"> + <span className="list-row__eyebrow">{formatDate(trace.createdAt)}</span> + <h3 className="list-row__title">{trace.title}</h3> + </div> + <StatusBadge status={trace.status} /> + </div> + <p>{trace.summary}</p> + <div className="list-row__meta"> + <span className="meta-pill">{trace.kind.replaceAll(".", " ")}</span> + <span className="meta-pill">{trace.eventCount} events</span> + </div> + </Link> + ))} + </div> + </div> + </SectionCard> + + <SectionCard + eyebrow="Trace detail" + title={selected.title} + description={ + selected.detailUnavailable + ? "The selected live trace detail could not be read, so this panel stays on the bounded summary already returned by the list." + : "Summary, key metadata, and ordered events stay grouped here without expanding into a raw debugging dump." + } + > + <div className="trace-panel"> + <div className="detail-summary"> + <StatusBadge status={selected.status} /> + <span className="detail-summary__label"> + {selected.kind.replaceAll(".", " ")} · {selected.eventCount} events + </span> + </div> + + <div className="trace-summary"> + <p>{selected.summary}</p> + <div className="attribute-list"> + <span className="attribute-item">Source: {selected.source}</span> + <span className="attribute-item">Scope: {selected.scope}</span> + <span className="attribute-item"> + Detail: {selected.detailSource === "live" ? "Live trace detail" : "Fixture trace detail"} + </span> + <span className="attribute-item"> + Events: {selected.eventSource === "live" ? "Live event review" : "Fixture event review"} + </span> + {selected.related.threadId ? ( + <span className="attribute-item">Thread: {selected.related.threadId}</span> + ) : null} + {selected.related.taskId ? ( + <span className="attribute-item">Task: {selected.related.taskId}</span> + ) : null} + {selected.related.approvalId ? ( + <span className="attribute-item">Approval: {selected.related.approvalId}</span> + ) : null} + {selected.related.executionId ? ( + <span className="attribute-item">Execution: {selected.related.executionId}</span> + ) : null} + {selected.related.compilerVersion ? ( + <span className="attribute-item">Compiler: {selected.related.compilerVersion}</span> + ) : null} + </div> + </div> + + <div className="detail-group"> + <h3>Key metadata</h3> + <div className="evidence-list"> + {selected.metadata.map((item) => ( + <span key={item} className="evidence-chip"> + {item} + </span> + ))} + </div> + </div> + + {selected.evidence.length > 0 ? ( + <div className="detail-group"> + <h3>Review notes</h3> + <div className="evidence-list"> + {selected.evidence.map((item) => ( + <span key={item} className="evidence-chip"> + {item} + </span> + ))} + </div> + </div> + ) : null} + + <div className="detail-group"> + <h3>Ordered events</h3> + {selected.eventsUnavailable ? ( + <EmptyState + title="Ordered events unavailable" + description="The trace summary loaded, but the ordered event review could not be read from the current backing source." + /> + ) : selected.events.length === 0 ? ( + <EmptyState + title="No ordered events" + description="This trace currently has no event records to review." + /> + ) : ( + <ol className="trace-events"> + {selected.events.map((event) => ( + <li key={event.id} className="trace-event"> + <div className="trace-event__topline"> + <div className="detail-stack"> + <span className="list-row__eyebrow">{event.kind}</span> + <h4>{event.title}</h4> + </div> + </div> + <p>{event.detail}</p> + {event.facts?.length ? ( + <div className="attribute-list"> + {event.facts.map((fact) => ( + <span key={fact} className="attribute-item"> + {fact} + </span> + ))} + </div> + ) : null} + </li> + ))} + </ol> + )} + </div> + </div> + </SectionCard> + </div> + ); +} diff --git a/apps/web/components/workflow-memory-writeback-form.tsx b/apps/web/components/workflow-memory-writeback-form.tsx new file mode 100644 index 0000000..1ccc6f7 --- /dev/null +++ b/apps/web/components/workflow-memory-writeback-form.tsx @@ -0,0 +1,277 @@ +"use client"; + +import { useEffect, useMemo, useState, type FormEvent } from "react"; + +import { useRouter } from "next/navigation"; + +import type { ApiSource, ApprovalExecutionResponse, ToolExecutionItem } from "../lib/api"; +import { admitMemory } from "../lib/api"; +import { StatusBadge } from "./status-badge"; + +type WorkflowMemoryWritebackFormProps = { + execution: ToolExecutionItem | null; + preview?: ApprovalExecutionResponse | null; + source?: ApiSource | null; + apiBaseUrl?: string; + userId?: string; +}; + +function deriveExecutionEvidenceEventIds( + execution: ToolExecutionItem | null, + preview?: ApprovalExecutionResponse | null, +) { + const ids: string[] = []; + + const previewResultEventId = preview?.events?.result_event_id?.trim(); + const previewRequestEventId = preview?.events?.request_event_id?.trim(); + const executionResultEventId = execution?.result_event_id?.trim(); + const executionRequestEventId = execution?.request_event_id?.trim(); + + if (previewResultEventId) { + ids.push(previewResultEventId); + } + if (previewRequestEventId) { + ids.push(previewRequestEventId); + } + if (executionResultEventId) { + ids.push(executionResultEventId); + } + if (executionRequestEventId) { + ids.push(executionRequestEventId); + } + + return Array.from(new Set(ids)); +} + +function defaultStatusText(options: { + hasExecutionEvidence: boolean; + liveModeReady: boolean; + source: ApiSource | null; + hasPreview: boolean; +}) { + if (!options.hasExecutionEvidence) { + return "Execution evidence is required before memory write-back can be submitted."; + } + + if (!options.liveModeReady) { + return "Memory write-back is disabled until live API configuration is present."; + } + + if (options.hasPreview || options.source === "live") { + return "Set memory key and JSON value, then submit explicit write-back."; + } + + return "Fixture workflow review is read-only. Memory write-back submits only from live workflow data."; +} + +export function WorkflowMemoryWritebackForm({ + execution, + preview, + source, + apiBaseUrl, + userId, +}: WorkflowMemoryWritebackFormProps) { + const router = useRouter(); + + const [memoryKey, setMemoryKey] = useState(""); + const [valueText, setValueText] = useState(""); + const [deleteRequested, setDeleteRequested] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [statusTone, setStatusTone] = useState<"info" | "success" | "danger">("info"); + const evidenceEventIds = useMemo( + () => deriveExecutionEvidenceEventIds(execution, preview), + [execution, preview], + ); + const liveModeReady = Boolean(apiBaseUrl && userId); + const liveWorkflowEvidence = source === "live" || Boolean(preview); + const canSubmit = Boolean(liveModeReady && liveWorkflowEvidence && evidenceEventIds.length > 0 && !isSubmitting); + const [statusText, setStatusText] = useState( + defaultStatusText({ + hasExecutionEvidence: evidenceEventIds.length > 0, + liveModeReady, + source: source ?? null, + hasPreview: Boolean(preview), + }), + ); + + useEffect(() => { + setStatusTone("info"); + setStatusText( + defaultStatusText({ + hasExecutionEvidence: evidenceEventIds.length > 0, + liveModeReady, + source: source ?? null, + hasPreview: Boolean(preview), + }), + ); + }, [evidenceEventIds.length, liveModeReady, preview, source]); + + async function handleSubmit(event: FormEvent<HTMLFormElement>) { + event.preventDefault(); + + const normalizedMemoryKey = memoryKey.trim(); + if (!normalizedMemoryKey) { + setStatusTone("danger"); + setStatusText("Memory key is required."); + return; + } + + if (!canSubmit || !apiBaseUrl || !userId) { + setStatusTone("info"); + setStatusText( + defaultStatusText({ + hasExecutionEvidence: evidenceEventIds.length > 0, + liveModeReady, + source: source ?? null, + hasPreview: Boolean(preview), + }), + ); + return; + } + + let parsedValue: unknown = null; + + if (!deleteRequested) { + const normalizedValueText = valueText.trim(); + if (!normalizedValueText) { + setStatusTone("danger"); + setStatusText("Enter a JSON value or enable delete mode."); + return; + } + + try { + parsedValue = JSON.parse(normalizedValueText); + } catch { + setStatusTone("danger"); + setStatusText("Memory value must be valid JSON."); + return; + } + } + + setIsSubmitting(true); + setStatusTone("info"); + setStatusText("Submitting explicit memory write-back..."); + + try { + const payload = await admitMemory(apiBaseUrl, { + user_id: userId, + memory_key: normalizedMemoryKey, + value: deleteRequested ? null : parsedValue, + source_event_ids: evidenceEventIds, + delete_requested: deleteRequested, + }); + + const decisionMessage = + payload.decision === "NOOP" + ? `No write was persisted (${payload.reason}).` + : payload.revision + ? `${payload.decision} persisted at revision ${payload.revision.sequence_no}.` + : `${payload.decision} persisted.`; + + setStatusTone(payload.decision === "NOOP" ? "info" : "success"); + setStatusText(decisionMessage); + + if (payload.decision !== "NOOP") { + setValueText(""); + } + + router.refresh(); + } catch (error) { + const detail = error instanceof Error ? error.message : "Memory write-back failed"; + setStatusTone("danger"); + setStatusText(`Unable to submit memory write-back: ${detail}`); + } finally { + setIsSubmitting(false); + } + } + + return ( + <form className="detail-stack workflow-memory-writeback" onSubmit={handleSubmit}> + <div className="form-field"> + <label htmlFor="workflow-memory-key">Memory key</label> + <input + id="workflow-memory-key" + name="workflow-memory-key" + type="text" + placeholder="user.preference.supplement.magnesium" + value={memoryKey} + onChange={(event) => setMemoryKey(event.target.value)} + maxLength={200} + /> + </div> + + <div className="form-field workflow-memory-writeback__value-field"> + <label htmlFor="workflow-memory-value">Memory value (JSON)</label> + <textarea + id="workflow-memory-value" + name="workflow-memory-value" + placeholder='{"merchant":"Thorne","item":"Magnesium Bisglycinate"}' + value={valueText} + onChange={(event) => setValueText(event.target.value)} + disabled={deleteRequested} + /> + <p className="field-hint"> + Write-back evidence is fixed to execution-linked event IDs shown below. + </p> + </div> + + <label className="workflow-memory-writeback__toggle"> + <input + type="checkbox" + checked={deleteRequested} + onChange={(event) => setDeleteRequested(event.target.checked)} + /> + Delete requested (submit a DELETE memory revision) + </label> + + <div className="workflow-memory-writeback__evidence"> + <p className="history-entry__label">Execution evidence</p> + {evidenceEventIds.length > 0 ? ( + <div className="evidence-list"> + {evidenceEventIds.map((eventId) => ( + <span key={eventId} className="evidence-chip mono"> + {eventId} + </span> + ))} + </div> + ) : ( + <p className="muted-copy">No execution-linked source events are available yet.</p> + )} + </div> + + <div className="composer-actions"> + <div className="composer-status" aria-live="polite"> + <StatusBadge + status={ + isSubmitting + ? "submitting" + : statusTone === "success" + ? "success" + : statusTone === "danger" + ? "error" + : canSubmit + ? "ready" + : "fixture" + } + label={ + isSubmitting + ? "Submitting" + : statusTone === "success" + ? "Saved" + : statusTone === "danger" + ? "Attention" + : canSubmit + ? "Ready" + : "Read-only" + } + /> + <span>{statusText}</span> + </div> + + <button type="submit" className="button" disabled={!canSubmit}> + {isSubmitting ? "Submitting..." : "Submit memory write-back"} + </button> + </div> + </form> + ); +} diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs new file mode 100644 index 0000000..ec5cc8a --- /dev/null +++ b/apps/web/eslint.config.mjs @@ -0,0 +1,21 @@ +import js from "@eslint/js"; +import { FlatCompat } from "@eslint/eslintrc"; +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, +}); + +const eslintConfig = [ + { + ignores: [".next/**", "node_modules/**", "next-env.d.ts"], + }, + ...compat.extends("next/core-web-vitals"), +]; + +export default eslintConfig; diff --git a/apps/web/lib/api.test.ts b/apps/web/lib/api.test.ts new file mode 100644 index 0000000..d7fc239 --- /dev/null +++ b/apps/web/lib/api.test.ts @@ -0,0 +1,3574 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + admitMemory, + ApiError, + combinePageModes, + connectCalendarAccount, + connectGmailAccount, + createThread, + createContinuityCapture, + applyContinuityCorrection, + deriveThreadWorkflowState, + createOpenLoop, + getCalendarAccountDetail, + getGmailAccountDetail, + getOpenLoopDetail, + getTaskArtifactDetail, + getTaskWorkspaceDetail, + getEntityDetail, + getContinuityCaptureDetail, + getContinuityReviewDetail, + getContinuityOpenLoopDashboard, + getContinuityDailyBrief, + getContinuityWeeklyReview, + getMemoryDetail, + getMemoryEvaluationSummary, + getMemoryTrustDashboard, + getMemoryRevisions, + getTaskSteps, + getThreadDetail, + getThreadEvents, + getThreadResumptionBrief, + getContinuityResumptionBrief, + getChiefOfStaffPriorityBrief, + captureChiefOfStaffExecutionRoutingAction, + captureChiefOfStaffHandoffOutcome, + captureChiefOfStaffHandoffReviewAction, + captureChiefOfStaffRecommendationOutcome, + getThreadSessions, + executeApproval, + ingestCalendarEvent, + ingestGmailMessage, + listCalendarAccounts, + listCalendarEvents, + listContinuityCaptures, + listContinuityReviewQueue, + listEntities, + listEntityEdges, + listGmailAccounts, + listOpenLoops, + listTaskArtifactChunks, + listTaskArtifacts, + listTaskWorkspaces, + listMemories, + listMemoryLabels, + listMemoryReviewQueue, + listTaskRuns, + listAgentProfiles, + getToolExecution, + getTraceDetail, + getTraceEvents, + listThreads, + listTraces, + queryContinuityRecall, + getContinuityRetrievalEvaluation, + pageModeLabel, + resolveApproval, + shouldExpectThreadExecutionReview, + submitAssistantResponse, + submitApprovalRequest, + captureExplicitSignals, + extractExplicitCommitments, + applyContinuityOpenLoopReviewAction, + submitMemoryLabel, + updateOpenLoopStatus, +} from "./api"; + +describe("api helpers", () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + fetchMock.mockReset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("combines live and fixture sources into a mixed page mode", () => { + expect(combinePageModes("live", "fixture")).toBe("mixed"); + expect(pageModeLabel("mixed")).toBe("Mixed fallback"); + }); + + it("does not borrow an older unrelated execution from the same thread", () => { + const approval = { + id: "approval-new", + thread_id: "thread-1", + task_step_id: "step-new", + status: "approved", + request: { + thread_id: "thread-1", + tool_id: "tool-1", + action: "place_order", + scope: "supplements", + domain_hint: "ecommerce", + risk_hint: "purchase", + attributes: {}, + }, + tool: { + id: "tool-1", + tool_key: "merchant_proxy", + name: "Merchant Proxy", + description: "Proxy", + version: "0.1.0", + metadata_version: "tool_metadata_v0", + active: true, + tags: [], + action_hints: [], + scope_hints: [], + domain_hints: [], + risk_hints: [], + metadata: {}, + created_at: "2026-03-17T00:00:00Z", + }, + routing: { + decision: "require_approval", + reasons: [], + trace: { + trace_id: "trace-approval-new", + trace_event_count: 3, + }, + }, + created_at: "2026-03-18T10:00:00Z", + resolution: { + resolved_at: "2026-03-18T10:05:00Z", + resolved_by_user_id: "user-1", + }, + }; + + const olderApproval = { + ...approval, + id: "approval-old", + task_step_id: "step-old", + created_at: "2026-03-17T10:00:00Z", + }; + + const task = { + id: "task-new", + thread_id: "thread-1", + tool_id: "tool-1", + status: "approved", + request: approval.request, + tool: approval.tool, + latest_approval_id: "approval-new", + latest_execution_id: null, + created_at: "2026-03-18T10:00:00Z", + updated_at: "2026-03-18T10:05:00Z", + }; + + const olderTask = { + ...task, + id: "task-old", + latest_approval_id: "approval-old", + latest_execution_id: "execution-old", + created_at: "2026-03-17T10:00:00Z", + updated_at: "2026-03-17T10:10:00Z", + }; + + const olderExecution = { + id: "execution-old", + approval_id: "approval-old", + task_step_id: "step-old", + thread_id: "thread-1", + tool_id: "tool-1", + trace_id: "trace-execution-old", + request_event_id: "request-event-old", + result_event_id: "result-event-old", + status: "completed", + handler_key: "proxy.echo", + request: approval.request, + tool: approval.tool, + result: { + handler_key: "proxy.echo", + status: "completed", + output: { ok: true }, + reason: null, + }, + executed_at: "2026-03-17T10:10:00Z", + }; + + const workflow = deriveThreadWorkflowState( + "thread-1", + [olderApproval, approval], + [olderTask, task], + [olderExecution], + ); + + expect(workflow.approval?.id).toBe("approval-new"); + expect(workflow.task?.id).toBe("task-new"); + expect(workflow.execution).toBeNull(); + expect(shouldExpectThreadExecutionReview(workflow.approval, workflow.task)).toBe(true); + }); + + it("returns explicitly linked execution when the selected task carries latest_execution_id", () => { + const approval = { + id: "approval-1", + thread_id: "thread-1", + task_step_id: "step-1", + status: "approved", + request: { + thread_id: "thread-1", + tool_id: "tool-1", + action: "place_order", + scope: "supplements", + domain_hint: "ecommerce", + risk_hint: "purchase", + attributes: {}, + }, + tool: { + id: "tool-1", + tool_key: "merchant_proxy", + name: "Merchant Proxy", + description: "Proxy", + version: "0.1.0", + metadata_version: "tool_metadata_v0", + active: true, + tags: [], + action_hints: [], + scope_hints: [], + domain_hints: [], + risk_hints: [], + metadata: {}, + created_at: "2026-03-17T00:00:00Z", + }, + routing: { + decision: "require_approval", + reasons: [], + trace: { + trace_id: "trace-approval-1", + trace_event_count: 3, + }, + }, + created_at: "2026-03-18T10:00:00Z", + resolution: { + resolved_at: "2026-03-18T10:05:00Z", + resolved_by_user_id: "user-1", + }, + }; + + const task = { + id: "task-1", + thread_id: "thread-1", + tool_id: "tool-1", + status: "executed", + request: approval.request, + tool: approval.tool, + latest_approval_id: "approval-1", + latest_execution_id: "execution-1", + created_at: "2026-03-18T10:00:00Z", + updated_at: "2026-03-18T10:06:00Z", + }; + + const execution = { + id: "execution-1", + approval_id: "approval-1", + task_step_id: "step-1", + thread_id: "thread-1", + tool_id: "tool-1", + trace_id: "trace-execution-1", + request_event_id: "request-event-1", + result_event_id: "result-event-1", + status: "completed", + handler_key: "proxy.echo", + request: approval.request, + tool: approval.tool, + result: { + handler_key: "proxy.echo", + status: "completed", + output: { ok: true }, + reason: null, + }, + executed_at: "2026-03-18T10:06:00Z", + }; + + const workflow = deriveThreadWorkflowState("thread-1", [approval], [task], [execution]); + + expect(workflow.execution?.id).toBe("execution-1"); + }); + + it("posts governed approval requests to the shipped endpoint", async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + request: { + thread_id: "thread-1", + tool_id: "tool-1", + action: "place_order", + scope: "supplements", + domain_hint: "ecommerce", + risk_hint: "purchase", + attributes: { quantity: "1" }, + }, + decision: "approval_required", + tool: { + id: "tool-1", + tool_key: "merchant_proxy", + name: "Merchant Proxy", + description: "Proxy", + version: "0.1.0", + metadata_version: "tool_metadata_v0", + active: true, + tags: [], + action_hints: [], + scope_hints: [], + domain_hints: [], + risk_hints: [], + metadata: {}, + created_at: "2026-03-17T00:00:00Z", + }, + reasons: [], + task: { + id: "task-1", + thread_id: "thread-1", + tool_id: "tool-1", + status: "pending_approval", + request: { + thread_id: "thread-1", + tool_id: "tool-1", + action: "place_order", + scope: "supplements", + domain_hint: "ecommerce", + risk_hint: "purchase", + attributes: { quantity: "1" }, + }, + tool: { + id: "tool-1", + tool_key: "merchant_proxy", + name: "Merchant Proxy", + description: "Proxy", + version: "0.1.0", + metadata_version: "tool_metadata_v0", + active: true, + tags: [], + action_hints: [], + scope_hints: [], + domain_hints: [], + risk_hints: [], + metadata: {}, + created_at: "2026-03-17T00:00:00Z", + }, + latest_approval_id: "approval-1", + latest_execution_id: null, + created_at: "2026-03-17T00:00:00Z", + updated_at: "2026-03-17T00:00:00Z", + }, + approval: null, + routing_trace: { + trace_id: "route-trace-1", + trace_event_count: 3, + }, + trace: { + trace_id: "request-trace-1", + trace_event_count: 6, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + await submitApprovalRequest("https://api.example.com", { + user_id: "user-1", + thread_id: "thread-1", + tool_id: "tool-1", + action: "place_order", + scope: "supplements", + domain_hint: "ecommerce", + risk_hint: "purchase", + attributes: { quantity: "1" }, + }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.com/v0/approvals/requests", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ "Content-Type": "application/json" }), + }), + ); + expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ + user_id: "user-1", + thread_id: "thread-1", + tool_id: "tool-1", + action: "place_order", + scope: "supplements", + domain_hint: "ecommerce", + risk_hint: "purchase", + attributes: { quantity: "1" }, + }); + }); + + it("posts assistant messages to the shipped response endpoint", async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + assistant: { + event_id: "assistant-event-1", + sequence_no: 3, + text: "You prefer oat milk.", + model_provider: "openai_responses", + model: "gpt-5-mini", + }, + trace: { + compile_trace_id: "compile-trace-1", + compile_trace_event_count: 3, + response_trace_id: "response-trace-1", + response_trace_event_count: 2, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + await submitAssistantResponse("https://api.example.com", { + user_id: "user-1", + thread_id: "thread-1", + message: "What do I usually take in coffee?", + }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.com/v0/responses", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ "Content-Type": "application/json" }), + }), + ); + expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ + user_id: "user-1", + thread_id: "thread-1", + message: "What do I usually take in coffee?", + }); + }); + + it("uses the shipped continuity endpoints for thread create and review", async () => { + fetchMock + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + thread: { + id: "thread-1", + title: "Gamma thread", + agent_profile_id: "coach_default", + created_at: "2026-03-17T10:00:00Z", + updated_at: "2026-03-17T10:00:00Z", + }, + }), + { status: 201, headers: { "Content-Type": "application/json" } }, + ), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + items: [ + { + id: "thread-1", + title: "Gamma thread", + agent_profile_id: "coach_default", + created_at: "2026-03-17T10:00:00Z", + updated_at: "2026-03-17T10:00:00Z", + }, + ], + summary: { + total_count: 1, + order: ["created_at_desc", "id_desc"], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + thread: { + id: "thread-1", + title: "Gamma thread", + agent_profile_id: "coach_default", + created_at: "2026-03-17T10:00:00Z", + updated_at: "2026-03-17T10:00:00Z", + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + items: [ + { + id: "session-1", + thread_id: "thread-1", + status: "active", + started_at: "2026-03-17T10:00:00Z", + ended_at: null, + created_at: "2026-03-17T10:00:00Z", + }, + ], + summary: { + thread_id: "thread-1", + total_count: 1, + order: ["started_at_asc", "created_at_asc", "id_asc"], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + items: [ + { + id: "event-1", + thread_id: "thread-1", + session_id: "session-1", + sequence_no: 1, + kind: "message.user", + payload: { text: "Hello" }, + created_at: "2026-03-17T10:00:00Z", + }, + ], + summary: { + thread_id: "thread-1", + total_count: 1, + order: ["sequence_no_asc"], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + items: [ + { + id: "assistant_default", + name: "Assistant Default", + description: "General-purpose assistant profile for baseline conversations.", + }, + { + id: "coach_default", + name: "Coach Default", + description: "Coaching-oriented profile focused on guidance and accountability.", + }, + ], + summary: { + total_count: 2, + order: ["id_asc"], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + const createPayload = await createThread("https://api.example.com", { + user_id: "user-1", + title: "Gamma thread", + agent_profile_id: "coach_default", + }); + const threadListPayload = await listThreads("https://api.example.com", "user-1"); + const threadDetailPayload = await getThreadDetail("https://api.example.com", "thread-1", "user-1"); + await getThreadSessions("https://api.example.com", "thread-1", "user-1"); + await getThreadEvents("https://api.example.com", "thread-1", "user-1"); + const profileRegistryPayload = await listAgentProfiles("https://api.example.com"); + + expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([ + "https://api.example.com/v0/threads", + "https://api.example.com/v0/threads?user_id=user-1", + "https://api.example.com/v0/threads/thread-1?user_id=user-1", + "https://api.example.com/v0/threads/thread-1/sessions?user_id=user-1", + "https://api.example.com/v0/threads/thread-1/events?user_id=user-1", + "https://api.example.com/v0/agent-profiles", + ]); + expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ + user_id: "user-1", + title: "Gamma thread", + agent_profile_id: "coach_default", + }); + expect(createPayload.thread.agent_profile_id).toBe("coach_default"); + expect(threadListPayload.items[0]?.agent_profile_id).toBe("coach_default"); + expect(threadDetailPayload.thread.agent_profile_id).toBe("coach_default"); + expect(profileRegistryPayload.items.map((item) => item.id)).toEqual([ + "assistant_default", + "coach_default", + ]); + }); + + it("throws ApiError when approval resolution returns a backend error envelope", async () => { + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ detail: "approval conflict" }), { + status: 409, + headers: { "Content-Type": "application/json" }, + }), + ); + + await expect( + resolveApproval("https://api.example.com", "approval-1", "approve", "user-1"), + ).rejects.toEqual(expect.objectContaining<ApiError>({ message: "approval conflict", status: 409 })); + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.com/v0/approvals/approval-1/approve", + expect.objectContaining({ + method: "POST", + }), + ); + }); + + it("executes approved requests and reads execution detail from the shipped endpoints", async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + request: { + approval_id: "approval-1", + task_step_id: "step-1", + }, + approval: { + id: "approval-1", + thread_id: "thread-1", + task_step_id: "step-1", + status: "approved", + request: { + thread_id: "thread-1", + tool_id: "tool-1", + action: "place_order", + scope: "supplements", + domain_hint: "ecommerce", + risk_hint: "purchase", + attributes: { quantity: "1" }, + }, + tool: { + id: "tool-1", + tool_key: "merchant_proxy", + name: "Merchant Proxy", + description: "Proxy", + version: "0.1.0", + metadata_version: "tool_metadata_v0", + active: true, + tags: [], + action_hints: [], + scope_hints: [], + domain_hints: [], + risk_hints: [], + metadata: {}, + created_at: "2026-03-17T00:00:00Z", + }, + routing: { + decision: "require_approval", + reasons: [], + trace: { + trace_id: "trace-1", + trace_event_count: 3, + }, + }, + created_at: "2026-03-17T00:00:00Z", + resolution: { + resolved_at: "2026-03-17T00:02:00Z", + resolved_by_user_id: "user-1", + }, + }, + tool: { + id: "tool-1", + tool_key: "merchant_proxy", + name: "Merchant Proxy", + description: "Proxy", + version: "0.1.0", + metadata_version: "tool_metadata_v0", + active: true, + tags: [], + action_hints: [], + scope_hints: [], + domain_hints: [], + risk_hints: [], + metadata: {}, + created_at: "2026-03-17T00:00:00Z", + }, + result: { + handler_key: "proxy.echo", + status: "completed", + output: { ok: true }, + reason: null, + }, + events: { + request_event_id: "event-1", + request_sequence_no: 1, + result_event_id: "event-2", + result_sequence_no: 2, + }, + trace: { + trace_id: "trace-2", + trace_event_count: 9, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + execution: { + id: "execution-1", + approval_id: "approval-1", + task_step_id: "step-1", + thread_id: "thread-1", + tool_id: "tool-1", + trace_id: "trace-2", + request_event_id: "event-1", + result_event_id: "event-2", + status: "completed", + handler_key: "proxy.echo", + request: { + thread_id: "thread-1", + tool_id: "tool-1", + action: "place_order", + scope: "supplements", + domain_hint: "ecommerce", + risk_hint: "purchase", + attributes: { quantity: "1" }, + }, + tool: { + id: "tool-1", + tool_key: "merchant_proxy", + name: "Merchant Proxy", + description: "Proxy", + version: "0.1.0", + metadata_version: "tool_metadata_v0", + active: true, + tags: [], + action_hints: [], + scope_hints: [], + domain_hints: [], + risk_hints: [], + metadata: {}, + created_at: "2026-03-17T00:00:00Z", + }, + result: { + handler_key: "proxy.echo", + status: "completed", + output: { ok: true }, + reason: null, + }, + executed_at: "2026-03-17T00:03:00Z", + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + await executeApproval("https://api.example.com", "approval-1", "user-1"); + await getToolExecution("https://api.example.com", "execution-1", "user-1"); + + expect(fetchMock.mock.calls).toEqual([ + [ + "https://api.example.com/v0/approvals/approval-1/execute", + expect.objectContaining({ + method: "POST", + }), + ], + [ + "https://api.example.com/v0/tool-executions/execution-1?user_id=user-1", + expect.objectContaining({ + cache: "no-store", + }), + ], + ]); + expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ + user_id: "user-1", + }); + }); + + it("reads task-step timelines from the shipped endpoint", async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + items: [ + { + id: "step-1", + task_id: "task-1", + sequence_no: 1, + kind: "governed_request", + status: "created", + request: { + thread_id: "thread-1", + tool_id: "tool-1", + action: "place_order", + scope: "supplements", + domain_hint: "ecommerce", + risk_hint: "purchase", + attributes: {}, + }, + outcome: { + routing_decision: "require_approval", + approval_id: "approval-1", + approval_status: "pending", + execution_id: null, + execution_status: null, + blocked_reason: null, + }, + lineage: { + parent_step_id: null, + source_approval_id: null, + source_execution_id: null, + }, + trace: { + trace_id: "trace-1", + trace_kind: "approval_request", + }, + created_at: "2026-03-17T00:00:00Z", + updated_at: "2026-03-17T00:00:00Z", + }, + ], + summary: { + task_id: "task-1", + total_count: 1, + latest_sequence_no: 1, + latest_status: "created", + next_sequence_no: 2, + append_allowed: false, + order: ["step-1"], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + await getTaskSteps("https://api.example.com", "task-1", "user-1"); + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.com/v0/tasks/task-1/steps?user_id=user-1", + expect.objectContaining({ + cache: "no-store", + headers: expect.objectContaining({ "Content-Type": "application/json" }), + }), + ); + }); + + it("reads resumption briefs from the shipped thread endpoint with bounded query params", async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + brief: { + assembly_version: "resumption_brief_v0", + thread: { + id: "thread-1", + title: "Gamma thread", + created_at: "2026-03-17T10:00:00Z", + updated_at: "2026-03-17T10:05:00Z", + }, + conversation: { + items: [], + summary: { + limit: 1, + returned_count: 0, + total_count: 0, + order: ["sequence_no_asc"], + kinds: ["message.user", "message.assistant"], + }, + }, + open_loops: { + items: [], + summary: { + limit: 1, + returned_count: 0, + total_count: 0, + order: ["opened_at_desc", "created_at_desc", "id_desc"], + }, + }, + memory_highlights: { + items: [], + summary: { + limit: 1, + returned_count: 0, + total_count: 0, + order: ["updated_at_asc", "created_at_asc", "id_asc"], + }, + }, + workflow: null, + sources: ["threads", "events", "open_loops", "memories"], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + await getThreadResumptionBrief("https://api.example.com", "thread-1", "user-1", { + maxEvents: 1, + maxOpenLoops: 1, + maxMemories: 1, + }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.com/v0/threads/thread-1/resumption-brief?user_id=user-1&max_events=1&max_open_loops=1&max_memories=1", + expect.objectContaining({ + cache: "no-store", + headers: expect.objectContaining({ "Content-Type": "application/json" }), + }), + ); + }); + + it("reads the shipped trace review endpoints with user-scoped query params", async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + items: [ + { + id: "trace-1", + thread_id: "thread-1", + kind: "context.compile", + compiler_version: "continuity_v0", + status: "completed", + created_at: "2026-03-17T00:00:00Z", + trace_event_count: 2, + }, + ], + summary: { + total_count: 1, + order: ["created_at_desc", "id_desc"], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + trace: { + id: "trace-1", + thread_id: "thread-1", + kind: "context.compile", + compiler_version: "continuity_v0", + status: "completed", + created_at: "2026-03-17T00:00:00Z", + trace_event_count: 2, + limits: { + max_sessions: 3, + max_events: 8, + }, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + items: [ + { + id: "event-1", + trace_id: "trace-1", + sequence_no: 1, + kind: "context.summary", + payload: { + thread_id: "thread-1", + }, + created_at: "2026-03-17T00:00:01Z", + }, + ], + summary: { + trace_id: "trace-1", + total_count: 1, + order: ["sequence_no_asc", "id_asc"], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + await listTraces("https://api.example.com", "user-1"); + await getTraceDetail("https://api.example.com", "trace-1", "user-1"); + await getTraceEvents("https://api.example.com", "trace-1", "user-1"); + + expect(fetchMock.mock.calls).toEqual([ + [ + "https://api.example.com/v0/traces?user_id=user-1", + expect.objectContaining({ + cache: "no-store", + headers: expect.objectContaining({ "Content-Type": "application/json" }), + }), + ], + [ + "https://api.example.com/v0/traces/trace-1?user_id=user-1", + expect.objectContaining({ + cache: "no-store", + }), + ], + [ + "https://api.example.com/v0/traces/trace-1/events?user_id=user-1", + expect.objectContaining({ + cache: "no-store", + }), + ], + ]); + }); + + it("reads entity review list, detail, and edge endpoints with user-scoped query params", async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + items: [ + { + id: "entity-1", + entity_type: "person", + name: "Alice", + source_memory_ids: ["memory-1"], + created_at: "2026-03-18T00:00:00Z", + }, + ], + summary: { + total_count: 1, + order: ["created_at_asc", "id_asc"], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + entity: { + id: "entity-1", + entity_type: "person", + name: "Alice", + source_memory_ids: ["memory-1"], + created_at: "2026-03-18T00:00:00Z", + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + items: [ + { + id: "edge-1", + from_entity_id: "entity-1", + to_entity_id: "entity-2", + relationship_type: "prefers_merchant", + valid_from: "2026-03-18T00:00:00Z", + valid_to: null, + source_memory_ids: ["memory-1"], + created_at: "2026-03-18T00:01:00Z", + }, + ], + summary: { + entity_id: "entity-1", + total_count: 1, + order: ["created_at_asc", "id_asc"], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + await listEntities("https://api.example.com", "user-1"); + await getEntityDetail("https://api.example.com", "entity-1", "user-1"); + await listEntityEdges("https://api.example.com", "entity-1", "user-1"); + + expect(fetchMock.mock.calls).toEqual([ + [ + "https://api.example.com/v0/entities?user_id=user-1", + expect.objectContaining({ + cache: "no-store", + }), + ], + [ + "https://api.example.com/v0/entities/entity-1?user_id=user-1", + expect.objectContaining({ + cache: "no-store", + }), + ], + [ + "https://api.example.com/v0/entities/entity-1/edges?user_id=user-1", + expect.objectContaining({ + cache: "no-store", + }), + ], + ]); + }); + + it("reads and writes Gmail account and selected-message ingestion endpoints", async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + account: { + id: "gmail-account-1", + provider: "gmail", + auth_kind: "oauth_access_token", + provider_account_id: "acct-owner-001", + email_address: "owner@gmail.example", + display_name: "Owner", + scope: "https://www.googleapis.com/auth/gmail.readonly", + created_at: "2026-03-18T00:00:00Z", + updated_at: "2026-03-18T00:00:00Z", + }, + }), + { status: 201, headers: { "Content-Type": "application/json" } }, + ), + ); + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + items: [ + { + id: "gmail-account-1", + provider: "gmail", + auth_kind: "oauth_access_token", + provider_account_id: "acct-owner-001", + email_address: "owner@gmail.example", + display_name: "Owner", + scope: "https://www.googleapis.com/auth/gmail.readonly", + created_at: "2026-03-18T00:00:00Z", + updated_at: "2026-03-18T00:00:00Z", + }, + ], + summary: { + total_count: 1, + order: ["created_at_asc", "id_asc"], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + account: { + id: "gmail-account-1", + provider: "gmail", + auth_kind: "oauth_access_token", + provider_account_id: "acct-owner-001", + email_address: "owner@gmail.example", + display_name: "Owner", + scope: "https://www.googleapis.com/auth/gmail.readonly", + created_at: "2026-03-18T00:00:00Z", + updated_at: "2026-03-18T00:00:00Z", + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + account: { + id: "gmail-account-1", + provider: "gmail", + auth_kind: "oauth_access_token", + provider_account_id: "acct-owner-001", + email_address: "owner@gmail.example", + display_name: "Owner", + scope: "https://www.googleapis.com/auth/gmail.readonly", + created_at: "2026-03-18T00:00:00Z", + updated_at: "2026-03-18T00:00:00Z", + }, + message: { + provider_message_id: "msg-001", + artifact_relative_path: "gmail/acct-owner-001/msg-001.eml", + media_type: "message/rfc822", + }, + artifact: { + id: "artifact-1", + task_id: "task-1", + task_workspace_id: "workspace-1", + status: "registered", + ingestion_status: "ingested", + relative_path: "gmail/acct-owner-001/msg-001.eml", + media_type_hint: "message/rfc822", + created_at: "2026-03-18T00:05:00Z", + updated_at: "2026-03-18T00:06:00Z", + }, + summary: { + total_count: 1, + total_characters: 240, + media_type: "message/rfc822", + chunking_rule: "normalized_utf8_text_fixed_window_1000_chars_v1", + order: ["sequence_no_asc", "id_asc"], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + await connectGmailAccount("https://api.example.com", { + user_id: "user-1", + provider_account_id: "acct-owner-001", + email_address: "owner@gmail.example", + display_name: "Owner", + scope: "https://www.googleapis.com/auth/gmail.readonly", + access_token: "access-token-1", + }); + await listGmailAccounts("https://api.example.com", "user-1"); + await getGmailAccountDetail("https://api.example.com", "gmail-account-1", "user-1"); + await ingestGmailMessage( + "https://api.example.com", + "gmail-account-1", + "msg-001", + { + user_id: "user-1", + task_workspace_id: "workspace-1", + }, + ); + + expect(fetchMock.mock.calls).toEqual([ + [ + "https://api.example.com/v0/gmail-accounts", + expect.objectContaining({ + method: "POST", + cache: "no-store", + }), + ], + [ + "https://api.example.com/v0/gmail-accounts?user_id=user-1", + expect.objectContaining({ + cache: "no-store", + }), + ], + [ + "https://api.example.com/v0/gmail-accounts/gmail-account-1?user_id=user-1", + expect.objectContaining({ + cache: "no-store", + }), + ], + [ + "https://api.example.com/v0/gmail-accounts/gmail-account-1/messages/msg-001/ingest", + expect.objectContaining({ + method: "POST", + cache: "no-store", + }), + ], + ]); + + expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ + user_id: "user-1", + provider_account_id: "acct-owner-001", + email_address: "owner@gmail.example", + display_name: "Owner", + scope: "https://www.googleapis.com/auth/gmail.readonly", + access_token: "access-token-1", + }); + expect(JSON.parse(String(fetchMock.mock.calls[3]?.[1]?.body))).toEqual({ + user_id: "user-1", + task_workspace_id: "workspace-1", + }); + }); + + it("reads Calendar discovery and writes selected-event ingestion endpoints", async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + account: { + id: "calendar-account-1", + provider: "google_calendar", + auth_kind: "oauth_access_token", + provider_account_id: "acct-owner-001", + email_address: "owner@gmail.example", + display_name: "Owner", + scope: "https://www.googleapis.com/auth/calendar.readonly", + created_at: "2026-03-18T00:00:00Z", + updated_at: "2026-03-18T00:00:00Z", + }, + items: [ + { + provider_event_id: "evt-001", + status: "confirmed", + summary: "Sprint planning review", + start_time: "2026-03-20T09:00:00+00:00", + end_time: "2026-03-20T09:30:00+00:00", + html_link: "https://calendar.google.com/event?eid=evt-001", + updated_at: "2026-03-19T10:00:00+00:00", + }, + ], + summary: { + total_count: 1, + limit: 20, + order: ["start_time_asc", "provider_event_id_asc"], + time_min: "2026-03-20T00:00:00Z", + time_max: "2026-03-21T00:00:00Z", + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + account: { + id: "calendar-account-1", + provider: "google_calendar", + auth_kind: "oauth_access_token", + provider_account_id: "acct-owner-001", + email_address: "owner@gmail.example", + display_name: "Owner", + scope: "https://www.googleapis.com/auth/calendar.readonly", + created_at: "2026-03-18T00:00:00Z", + updated_at: "2026-03-18T00:00:00Z", + }, + }), + { status: 201, headers: { "Content-Type": "application/json" } }, + ), + ); + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + items: [ + { + id: "calendar-account-1", + provider: "google_calendar", + auth_kind: "oauth_access_token", + provider_account_id: "acct-owner-001", + email_address: "owner@gmail.example", + display_name: "Owner", + scope: "https://www.googleapis.com/auth/calendar.readonly", + created_at: "2026-03-18T00:00:00Z", + updated_at: "2026-03-18T00:00:00Z", + }, + ], + summary: { + total_count: 1, + order: ["created_at_asc", "id_asc"], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + account: { + id: "calendar-account-1", + provider: "google_calendar", + auth_kind: "oauth_access_token", + provider_account_id: "acct-owner-001", + email_address: "owner@gmail.example", + display_name: "Owner", + scope: "https://www.googleapis.com/auth/calendar.readonly", + created_at: "2026-03-18T00:00:00Z", + updated_at: "2026-03-18T00:00:00Z", + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + account: { + id: "calendar-account-1", + provider: "google_calendar", + auth_kind: "oauth_access_token", + provider_account_id: "acct-owner-001", + email_address: "owner@gmail.example", + display_name: "Owner", + scope: "https://www.googleapis.com/auth/calendar.readonly", + created_at: "2026-03-18T00:00:00Z", + updated_at: "2026-03-18T00:00:00Z", + }, + event: { + provider_event_id: "evt-001", + artifact_relative_path: "calendar/acct-owner-001/evt-001.txt", + media_type: "text/plain", + }, + artifact: { + id: "artifact-1", + task_id: "task-1", + task_workspace_id: "workspace-1", + status: "registered", + ingestion_status: "ingested", + relative_path: "calendar/acct-owner-001/evt-001.txt", + media_type_hint: "text/plain", + created_at: "2026-03-18T00:05:00Z", + updated_at: "2026-03-18T00:06:00Z", + }, + summary: { + total_count: 1, + total_characters: 240, + media_type: "text/plain", + chunking_rule: "normalized_utf8_text_fixed_window_1000_chars_v1", + order: ["sequence_no_asc", "id_asc"], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + await connectCalendarAccount("https://api.example.com", { + user_id: "user-1", + provider_account_id: "acct-owner-001", + email_address: "owner@gmail.example", + display_name: "Owner", + scope: "https://www.googleapis.com/auth/calendar.readonly", + access_token: "access-token-1", + }); + await listCalendarAccounts("https://api.example.com", "user-1"); + await getCalendarAccountDetail("https://api.example.com", "calendar-account-1", "user-1"); + await listCalendarEvents("https://api.example.com", "calendar-account-1", "user-1", { + limit: 20, + timeMin: "2026-03-20T00:00:00Z", + timeMax: "2026-03-21T00:00:00Z", + }); + await ingestCalendarEvent( + "https://api.example.com", + "calendar-account-1", + "evt-001", + { + user_id: "user-1", + task_workspace_id: "workspace-1", + }, + ); + + expect(fetchMock.mock.calls).toEqual([ + [ + "https://api.example.com/v0/calendar-accounts", + expect.objectContaining({ + method: "POST", + cache: "no-store", + }), + ], + [ + "https://api.example.com/v0/calendar-accounts?user_id=user-1", + expect.objectContaining({ + cache: "no-store", + }), + ], + [ + "https://api.example.com/v0/calendar-accounts/calendar-account-1?user_id=user-1", + expect.objectContaining({ + cache: "no-store", + }), + ], + [ + "https://api.example.com/v0/calendar-accounts/calendar-account-1/events?user_id=user-1&limit=20&time_min=2026-03-20T00%3A00%3A00Z&time_max=2026-03-21T00%3A00%3A00Z", + expect.objectContaining({ + cache: "no-store", + }), + ], + [ + "https://api.example.com/v0/calendar-accounts/calendar-account-1/events/evt-001/ingest", + expect.objectContaining({ + method: "POST", + cache: "no-store", + }), + ], + ]); + + expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ + user_id: "user-1", + provider_account_id: "acct-owner-001", + email_address: "owner@gmail.example", + display_name: "Owner", + scope: "https://www.googleapis.com/auth/calendar.readonly", + access_token: "access-token-1", + }); + expect(JSON.parse(String(fetchMock.mock.calls[4]?.[1]?.body))).toEqual({ + user_id: "user-1", + task_workspace_id: "workspace-1", + }); + }); + + it("reads task workspace and artifact review endpoints with user-scoped query params", async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + items: [ + { + id: "workspace-1", + task_id: "task-1", + status: "active", + local_path: "/tmp/workspace/task-1", + created_at: "2026-03-18T00:00:00Z", + updated_at: "2026-03-18T00:00:00Z", + }, + ], + summary: { + total_count: 1, + order: ["created_at_asc", "id_asc"], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + workspace: { + id: "workspace-1", + task_id: "task-1", + status: "active", + local_path: "/tmp/workspace/task-1", + created_at: "2026-03-18T00:00:00Z", + updated_at: "2026-03-18T00:00:00Z", + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + items: [ + { + id: "artifact-1", + task_id: "task-1", + task_workspace_id: "workspace-1", + status: "registered", + ingestion_status: "ingested", + relative_path: "notes/review.md", + media_type_hint: "text/markdown", + created_at: "2026-03-18T00:00:00Z", + updated_at: "2026-03-18T00:01:00Z", + }, + ], + summary: { + total_count: 1, + order: ["created_at_asc", "id_asc"], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + artifact: { + id: "artifact-1", + task_id: "task-1", + task_workspace_id: "workspace-1", + status: "registered", + ingestion_status: "ingested", + relative_path: "notes/review.md", + media_type_hint: "text/markdown", + created_at: "2026-03-18T00:00:00Z", + updated_at: "2026-03-18T00:01:00Z", + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + items: [ + { + id: "chunk-1", + task_artifact_id: "artifact-1", + sequence_no: 1, + char_start: 0, + char_end_exclusive: 12, + text: "hello world", + created_at: "2026-03-18T00:02:00Z", + updated_at: "2026-03-18T00:02:00Z", + }, + ], + summary: { + total_count: 1, + total_characters: 12, + media_type: "text/markdown", + chunking_rule: "artifact_ingestion_v0", + order: ["sequence_no_asc", "id_asc"], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + await listTaskWorkspaces("https://api.example.com", "user-1"); + await getTaskWorkspaceDetail("https://api.example.com", "workspace-1", "user-1"); + await listTaskArtifacts("https://api.example.com", "user-1"); + await getTaskArtifactDetail("https://api.example.com", "artifact-1", "user-1"); + await listTaskArtifactChunks("https://api.example.com", "artifact-1", "user-1"); + + expect(fetchMock.mock.calls).toEqual([ + [ + "https://api.example.com/v0/task-workspaces?user_id=user-1", + expect.objectContaining({ + cache: "no-store", + }), + ], + [ + "https://api.example.com/v0/task-workspaces/workspace-1?user_id=user-1", + expect.objectContaining({ + cache: "no-store", + }), + ], + [ + "https://api.example.com/v0/task-artifacts?user_id=user-1", + expect.objectContaining({ + cache: "no-store", + }), + ], + [ + "https://api.example.com/v0/task-artifacts/artifact-1?user_id=user-1", + expect.objectContaining({ + cache: "no-store", + }), + ], + [ + "https://api.example.com/v0/task-artifacts/artifact-1/chunks?user_id=user-1", + expect.objectContaining({ + cache: "no-store", + }), + ], + ]); + }); + + it("reads memory review list, queue, summary, detail, revisions, and labels from shipped endpoints", async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + items: [], + summary: { + status: "active", + limit: 5, + returned_count: 0, + total_count: 0, + has_more: false, + order: ["updated_at_desc", "created_at_desc", "id_desc"], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + items: [], + summary: { + memory_status: "active", + review_state: "unlabeled", + limit: 3, + returned_count: 0, + total_count: 0, + has_more: false, + order: ["updated_at_desc", "created_at_desc", "id_desc"], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + summary: { + total_memory_count: 3, + active_memory_count: 3, + deleted_memory_count: 0, + labeled_memory_count: 1, + unlabeled_memory_count: 2, + total_label_row_count: 2, + label_row_counts_by_value: { + correct: 1, + incorrect: 0, + outdated: 1, + insufficient_evidence: 0, + }, + label_value_order: ["correct", "incorrect", "outdated", "insufficient_evidence"], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + summary: { + status: "needs_review", + precision: 0.8, + precision_target: 0.8, + adjudicated_sample_count: 10, + minimum_adjudicated_sample: 10, + remaining_to_minimum_sample: 0, + unlabeled_memory_count: 1, + high_risk_memory_count: 1, + stale_truth_count: 0, + superseded_active_conflict_count: 0, + counts: { + active_memory_count: 3, + labeled_active_memory_count: 2, + adjudicated_correct_count: 8, + adjudicated_incorrect_count: 2, + outdated_label_count: 0, + insufficient_evidence_label_count: 0, + }, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + memory: { + id: "memory-1", + memory_key: "user.preference.merchant", + value: { merchant: "Thorne" }, + status: "active", + source_event_ids: ["event-1"], + created_at: "2026-03-17T00:00:00Z", + updated_at: "2026-03-18T00:00:00Z", + deleted_at: null, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + items: [], + summary: { + memory_id: "memory-1", + limit: 10, + returned_count: 0, + total_count: 0, + has_more: false, + order: ["sequence_no_asc"], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + items: [], + summary: { + memory_id: "memory-1", + total_count: 0, + counts_by_label: { + correct: 0, + incorrect: 0, + outdated: 0, + insufficient_evidence: 0, + }, + order: ["correct", "incorrect", "outdated", "insufficient_evidence"], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + await listMemories("https://api.example.com", "user-1", { status: "active", limit: 5 }); + await listMemoryReviewQueue("https://api.example.com", "user-1", { + limit: 3, + priorityMode: "high_risk_first", + }); + await getMemoryEvaluationSummary("https://api.example.com", "user-1"); + await getMemoryDetail("https://api.example.com", "memory-1", "user-1"); + await getMemoryRevisions("https://api.example.com", "memory-1", "user-1", 10); + await listMemoryLabels("https://api.example.com", "memory-1", "user-1"); + + expect(fetchMock.mock.calls).toEqual([ + [ + "https://api.example.com/v0/memories?user_id=user-1&status=active&limit=5", + expect.objectContaining({ + cache: "no-store", + }), + ], + [ + "https://api.example.com/v0/memories/review-queue?user_id=user-1&limit=3&priority_mode=high_risk_first", + expect.objectContaining({ + cache: "no-store", + }), + ], + [ + "https://api.example.com/v0/memories/evaluation-summary?user_id=user-1", + expect.objectContaining({ + cache: "no-store", + }), + ], + [ + "https://api.example.com/v0/memories/quality-gate?user_id=user-1", + expect.objectContaining({ + cache: "no-store", + }), + ], + [ + "https://api.example.com/v0/memories/memory-1?user_id=user-1", + expect.objectContaining({ + cache: "no-store", + }), + ], + [ + "https://api.example.com/v0/memories/memory-1/revisions?user_id=user-1&limit=10", + expect.objectContaining({ + cache: "no-store", + }), + ], + [ + "https://api.example.com/v0/memories/memory-1/labels?user_id=user-1", + expect.objectContaining({ + cache: "no-store", + }), + ], + ]); + }); + + it("combines memory evaluation summary with canonical quality-gate payload", async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + summary: { + total_memory_count: 2, + active_memory_count: 2, + deleted_memory_count: 0, + labeled_memory_count: 2, + unlabeled_memory_count: 0, + total_label_row_count: 2, + label_row_counts_by_value: { + correct: 2, + incorrect: 0, + outdated: 0, + insufficient_evidence: 0, + }, + label_value_order: ["correct", "incorrect", "outdated", "insufficient_evidence"], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + summary: { + status: "healthy", + precision: 1, + precision_target: 0.8, + adjudicated_sample_count: 10, + minimum_adjudicated_sample: 10, + remaining_to_minimum_sample: 0, + unlabeled_memory_count: 0, + high_risk_memory_count: 0, + stale_truth_count: 0, + superseded_active_conflict_count: 0, + counts: { + active_memory_count: 2, + labeled_active_memory_count: 2, + adjudicated_correct_count: 10, + adjudicated_incorrect_count: 0, + outdated_label_count: 0, + insufficient_evidence_label_count: 0, + }, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + const payload = await getMemoryEvaluationSummary("https://api.example.com", "user-1"); + + expect(payload.summary.quality_gate?.status).toBe("healthy"); + expect(payload.summary.quality_gate?.precision_target).toBe(0.8); + }); + + it("reads canonical memory trust dashboard payload", async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + dashboard: { + quality_gate: { + status: "needs_review", + precision: 0.9, + precision_target: 0.8, + adjudicated_sample_count: 10, + minimum_adjudicated_sample: 10, + remaining_to_minimum_sample: 0, + unlabeled_memory_count: 2, + high_risk_memory_count: 1, + stale_truth_count: 1, + superseded_active_conflict_count: 0, + counts: { + active_memory_count: 12, + labeled_active_memory_count: 10, + adjudicated_correct_count: 9, + adjudicated_incorrect_count: 1, + outdated_label_count: 0, + insufficient_evidence_label_count: 0, + }, + }, + queue_posture: { + priority_mode: "recent_first", + total_count: 2, + high_risk_count: 1, + stale_truth_count: 1, + priority_reason_counts: { + recent_first: 2, + }, + order: ["updated_at_desc", "created_at_desc", "id_desc"], + aging: { + anchor_updated_at: "2026-03-29T12:00:00Z", + newest_updated_at: "2026-03-29T12:00:00Z", + oldest_updated_at: "2026-03-27T12:00:00Z", + backlog_span_hours: 48, + fresh_within_24h_count: 1, + aging_24h_to_72h_count: 1, + stale_over_72h_count: 0, + }, + }, + retrieval_quality: { + fixture_count: 3, + evaluated_fixture_count: 3, + passing_fixture_count: 3, + precision_at_k_mean: 1, + precision_at_1_mean: 1, + precision_target: 0.8, + status: "pass", + fixture_order: ["fixture_id_asc"], + result_order: ["precision_at_k_desc", "fixture_id_asc"], + }, + correction_freshness: { + total_open_loop_count: 4, + stale_open_loop_count: 1, + correction_recurrence_count: 1, + freshness_drift_count: 1, + }, + recommended_review: { + priority_mode: "high_risk_first", + action: "review_high_risk_queue", + reason: "High-risk unlabeled memories are present; triage those first.", + }, + sources: [ + "memories", + "memory_review_labels", + "continuity_recall", + "continuity_correction_events", + "retrieval_evaluation_fixtures", + ], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + const payload = await getMemoryTrustDashboard("https://api.example.com", "user-1"); + + expect(payload.dashboard.recommended_review.action).toBe("review_high_risk_queue"); + expect(payload.dashboard.queue_posture.total_count).toBe(2); + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.com/v0/memories/trust-dashboard?user_id=user-1", + expect.objectContaining({ + cache: "no-store", + }), + ); + }); + + it("reads and mutates open-loop endpoints with user-scoped routing", async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + items: [ + { + id: "loop-1", + memory_id: "memory-1", + title: "Confirm reorder details", + status: "open", + opened_at: "2026-03-23T09:00:00Z", + due_at: "2026-03-25T09:00:00Z", + resolved_at: null, + resolution_note: null, + created_at: "2026-03-23T09:00:00Z", + updated_at: "2026-03-23T09:00:00Z", + }, + ], + summary: { + status: "open", + limit: 5, + returned_count: 1, + total_count: 1, + has_more: false, + order: ["opened_at_desc", "created_at_desc", "id_desc"], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + open_loop: { + id: "loop-1", + memory_id: "memory-1", + title: "Confirm reorder details", + status: "open", + opened_at: "2026-03-23T09:00:00Z", + due_at: "2026-03-25T09:00:00Z", + resolved_at: null, + resolution_note: null, + created_at: "2026-03-23T09:00:00Z", + updated_at: "2026-03-23T09:00:00Z", + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + open_loop: { + id: "loop-2", + memory_id: "memory-1", + title: "Follow up on confidence", + status: "open", + opened_at: "2026-03-24T09:00:00Z", + due_at: null, + resolved_at: null, + resolution_note: null, + created_at: "2026-03-24T09:00:00Z", + updated_at: "2026-03-24T09:00:00Z", + }, + }), + { status: 201, headers: { "Content-Type": "application/json" } }, + ), + ); + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + open_loop: { + id: "loop-1", + memory_id: "memory-1", + title: "Confirm reorder details", + status: "resolved", + opened_at: "2026-03-23T09:00:00Z", + due_at: "2026-03-25T09:00:00Z", + resolved_at: "2026-03-24T10:00:00Z", + resolution_note: "Resolved", + created_at: "2026-03-23T09:00:00Z", + updated_at: "2026-03-24T10:00:00Z", + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + await listOpenLoops("https://api.example.com", "user-1", { status: "open", limit: 5 }); + await getOpenLoopDetail("https://api.example.com", "loop-1", "user-1"); + await createOpenLoop("https://api.example.com", { + user_id: "user-1", + memory_id: "memory-1", + title: "Follow up on confidence", + }); + await updateOpenLoopStatus("https://api.example.com", "loop-1", { + user_id: "user-1", + status: "resolved", + resolution_note: "Resolved", + }); + + expect(fetchMock.mock.calls).toEqual([ + [ + "https://api.example.com/v0/open-loops?user_id=user-1&status=open&limit=5", + expect.objectContaining({ cache: "no-store" }), + ], + [ + "https://api.example.com/v0/open-loops/loop-1?user_id=user-1", + expect.objectContaining({ cache: "no-store" }), + ], + [ + "https://api.example.com/v0/open-loops", + expect.objectContaining({ method: "POST" }), + ], + [ + "https://api.example.com/v0/open-loops/loop-1/status", + expect.objectContaining({ method: "POST" }), + ], + ]); + expect(JSON.parse(String(fetchMock.mock.calls[2]?.[1]?.body))).toEqual({ + user_id: "user-1", + memory_id: "memory-1", + title: "Follow up on confidence", + }); + expect(JSON.parse(String(fetchMock.mock.calls[3]?.[1]?.body))).toEqual({ + user_id: "user-1", + status: "resolved", + resolution_note: "Resolved", + }); + }); + + it("posts explicit memory admissions to the shipped endpoint", async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + decision: "ADD", + reason: "memory_created", + memory: { + id: "memory-1", + user_id: "user-1", + memory_key: "user.preference.supplement.magnesium", + value: { + merchant: "Thorne", + }, + status: "active", + source_event_ids: ["event-2", "event-1"], + created_at: "2026-03-19T00:00:00Z", + updated_at: "2026-03-19T00:00:00Z", + deleted_at: null, + }, + revision: { + id: "revision-1", + user_id: "user-1", + memory_id: "memory-1", + sequence_no: 1, + action: "ADD", + memory_key: "user.preference.supplement.magnesium", + previous_value: null, + new_value: { + merchant: "Thorne", + }, + source_event_ids: ["event-2", "event-1"], + candidate: { + memory_key: "user.preference.supplement.magnesium", + value: { + merchant: "Thorne", + }, + source_event_ids: ["event-2", "event-1"], + delete_requested: false, + }, + created_at: "2026-03-19T00:00:00Z", + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + await admitMemory("https://api.example.com", { + user_id: "user-1", + memory_key: "user.preference.supplement.magnesium", + value: { + merchant: "Thorne", + }, + source_event_ids: ["event-2", "event-1"], + delete_requested: false, + }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.com/v0/memories/admit", + expect.objectContaining({ + method: "POST", + }), + ); + expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ + user_id: "user-1", + memory_key: "user.preference.supplement.magnesium", + value: { + merchant: "Thorne", + }, + source_event_ids: ["event-2", "event-1"], + delete_requested: false, + }); + }); + + it("posts explicit commitment extraction requests to the shipped endpoint", async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + candidates: [ + { + memory_key: "user.commitment.submit_tax_forms", + value: { + kind: "explicit_commitment", + text: "submit tax forms", + }, + source_event_ids: ["event-1"], + delete_requested: false, + pattern: "remind_me_to", + commitment_text: "submit tax forms", + open_loop_title: "Remember to submit tax forms", + }, + ], + admissions: [ + { + decision: "ADD", + reason: "source_backed_add", + memory: { + id: "memory-1", + user_id: "user-1", + memory_key: "user.commitment.submit_tax_forms", + value: { + kind: "explicit_commitment", + text: "submit tax forms", + }, + status: "active", + source_event_ids: ["event-1"], + created_at: "2026-03-23T09:00:00Z", + updated_at: "2026-03-23T09:00:00Z", + deleted_at: null, + }, + revision: null, + open_loop: { + decision: "CREATED", + reason: "created_open_loop_for_memory", + open_loop: { + id: "loop-1", + memory_id: "memory-1", + title: "Remember to submit tax forms", + status: "open", + opened_at: "2026-03-23T09:00:00Z", + due_at: null, + resolved_at: null, + resolution_note: null, + created_at: "2026-03-23T09:00:00Z", + updated_at: "2026-03-23T09:00:00Z", + }, + }, + }, + ], + summary: { + source_event_id: "event-1", + source_event_kind: "message.user", + candidate_count: 1, + admission_count: 1, + persisted_change_count: 1, + noop_count: 0, + open_loop_created_count: 1, + open_loop_noop_count: 0, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + await extractExplicitCommitments("https://api.example.com", { + user_id: "user-1", + source_event_id: "event-1", + }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.com/v0/open-loops/extract-explicit-commitments", + expect.objectContaining({ + method: "POST", + }), + ); + expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ + user_id: "user-1", + source_event_id: "event-1", + }); + }); + + it("posts unified explicit signal capture requests to the shipped endpoint", async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + preferences: { + candidates: [], + admissions: [], + summary: { + source_event_id: "event-1", + source_event_kind: "message.user", + candidate_count: 0, + admission_count: 0, + persisted_change_count: 0, + noop_count: 0, + }, + }, + commitments: { + candidates: [ + { + memory_key: "user.commitment.submit_tax_forms", + value: { + kind: "explicit_commitment", + text: "submit tax forms", + }, + source_event_ids: ["event-1"], + delete_requested: false, + pattern: "remind_me_to", + commitment_text: "submit tax forms", + open_loop_title: "Remember to submit tax forms", + }, + ], + admissions: [], + summary: { + source_event_id: "event-1", + source_event_kind: "message.user", + candidate_count: 1, + admission_count: 0, + persisted_change_count: 0, + noop_count: 0, + open_loop_created_count: 0, + open_loop_noop_count: 0, + }, + }, + summary: { + source_event_id: "event-1", + source_event_kind: "message.user", + candidate_count: 1, + admission_count: 0, + persisted_change_count: 0, + noop_count: 0, + open_loop_created_count: 0, + open_loop_noop_count: 0, + preference_candidate_count: 0, + preference_admission_count: 0, + commitment_candidate_count: 1, + commitment_admission_count: 0, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + await captureExplicitSignals("https://api.example.com", { + user_id: "user-1", + source_event_id: "event-1", + }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.com/v0/memories/capture-explicit-signals", + expect.objectContaining({ + method: "POST", + }), + ); + expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ + user_id: "user-1", + source_event_id: "event-1", + }); + }); + + it("throws ApiError when unified explicit signal capture returns a backend error envelope", async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + detail: "source_event_id must reference an existing message.user event", + }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ), + ); + + await expect( + captureExplicitSignals("https://api.example.com", { + user_id: "user-1", + source_event_id: "missing-event", + }), + ).rejects.toEqual( + expect.objectContaining<ApiError>({ + message: "source_event_id must reference an existing message.user event", + status: 400, + }), + ); + }); + + it("posts and reads continuity capture inbox endpoints", async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + capture: { + capture_event: { + id: "capture-1", + raw_content: "Finalize launch checklist", + explicit_signal: "task", + admission_posture: "DERIVED", + admission_reason: "explicit_signal_task", + created_at: "2026-03-29T09:00:00Z", + }, + derived_object: { + id: "object-1", + capture_event_id: "capture-1", + object_type: "NextAction", + status: "active", + title: "Next Action: Finalize launch checklist", + body: { + action_text: "Finalize launch checklist", + raw_content: "Finalize launch checklist", + explicit_signal: "task", + }, + provenance: { + capture_event_id: "capture-1", + source_kind: "continuity_capture_event", + }, + confidence: 1, + created_at: "2026-03-29T09:00:00Z", + updated_at: "2026-03-29T09:00:00Z", + }, + }, + }), + { status: 201, headers: { "Content-Type": "application/json" } }, + ), + ); + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + items: [ + { + capture_event: { + id: "capture-1", + raw_content: "Finalize launch checklist", + explicit_signal: "task", + admission_posture: "DERIVED", + admission_reason: "explicit_signal_task", + created_at: "2026-03-29T09:00:00Z", + }, + derived_object: null, + }, + ], + summary: { + limit: 20, + returned_count: 1, + total_count: 1, + derived_count: 1, + triage_count: 0, + order: ["created_at_desc", "id_desc"], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + capture: { + capture_event: { + id: "capture-1", + raw_content: "Finalize launch checklist", + explicit_signal: "task", + admission_posture: "DERIVED", + admission_reason: "explicit_signal_task", + created_at: "2026-03-29T09:00:00Z", + }, + derived_object: null, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + await createContinuityCapture("https://api.example.com", { + user_id: "user-1", + raw_content: "Finalize launch checklist", + explicit_signal: "task", + }); + await listContinuityCaptures("https://api.example.com", "user-1", { limit: 20 }); + await getContinuityCaptureDetail("https://api.example.com", "capture-1", "user-1"); + + expect(fetchMock.mock.calls).toEqual([ + [ + "https://api.example.com/v0/continuity/captures", + expect.objectContaining({ method: "POST" }), + ], + [ + "https://api.example.com/v0/continuity/captures?user_id=user-1&limit=20", + expect.objectContaining({ cache: "no-store" }), + ], + [ + "https://api.example.com/v0/continuity/captures/capture-1?user_id=user-1", + expect.objectContaining({ cache: "no-store" }), + ], + ]); + expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ + user_id: "user-1", + raw_content: "Finalize launch checklist", + explicit_signal: "task", + }); + }); + + it("throws ApiError when memory admission returns a backend error envelope", async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + detail: "source_event_ids must all reference existing events owned by the user", + }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ), + ); + + await expect( + admitMemory("https://api.example.com", { + user_id: "user-1", + memory_key: "user.preference.supplement.magnesium", + value: { + merchant: "Thorne", + }, + source_event_ids: ["missing-event"], + }), + ).rejects.toEqual( + expect.objectContaining<ApiError>({ + message: "source_event_ids must all reference existing events owned by the user", + status: 400, + }), + ); + }); + + it("posts memory review labels to the shipped endpoint", async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + label: { + id: "label-1", + memory_id: "memory-1", + reviewer_user_id: "user-1", + label: "correct", + note: "Still matches latest evidence.", + created_at: "2026-03-18T00:00:00Z", + }, + summary: { + memory_id: "memory-1", + total_count: 1, + counts_by_label: { + correct: 1, + incorrect: 0, + outdated: 0, + insufficient_evidence: 0, + }, + order: ["correct", "incorrect", "outdated", "insufficient_evidence"], + }, + }), + { status: 201, headers: { "Content-Type": "application/json" } }, + ), + ); + + await submitMemoryLabel("https://api.example.com", "memory-1", { + user_id: "user-1", + label: "correct", + note: "Still matches latest evidence.", + }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.com/v0/memories/memory-1/labels", + expect.objectContaining({ + method: "POST", + }), + ); + expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ + user_id: "user-1", + label: "correct", + note: "Still matches latest evidence.", + }); + }); + + it("lists task runs from the shipped task-runs endpoint", async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + items: [ + { + id: "run-1", + task_id: "task-1", + status: "running", + checkpoint: { + cursor: 1, + target_steps: 3, + wait_for_signal: false, + }, + tick_count: 1, + step_count: 1, + max_ticks: 3, + retry_count: 0, + retry_cap: 3, + retry_posture: "none", + failure_class: null, + stop_reason: null, + last_transitioned_at: "2026-03-27T10:05:00Z", + created_at: "2026-03-27T10:00:00Z", + updated_at: "2026-03-27T10:05:00Z", + }, + ], + summary: { + task_id: "task-1", + total_count: 1, + order: ["created_at_asc", "id_asc"], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + await listTaskRuns("https://api.example.com", "task-1", "user-1"); + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.com/v0/tasks/task-1/runs?user_id=user-1", + expect.objectContaining({ cache: "no-store" }), + ); + }); + + it("throws ApiError when task-run listing returns a backend error envelope", async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + detail: "task task-1 was not found", + }), + { status: 404, headers: { "Content-Type": "application/json" } }, + ), + ); + + await expect(listTaskRuns("https://api.example.com", "task-1", "user-1")).rejects.toEqual( + expect.objectContaining<ApiError>({ + message: "task task-1 was not found", + status: 404, + }), + ); + }); + + it("queries continuity recall with scoped filter parameters", async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + items: [], + summary: { + query: "rollout", + filters: { thread_id: "thread-1", since: null, until: null }, + limit: 20, + returned_count: 0, + total_count: 0, + order: ["relevance_desc", "created_at_desc", "id_desc"], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + await queryContinuityRecall("https://api.example.com", "user-1", { + query: "rollout", + threadId: "thread-1", + project: "Project Phoenix", + person: "Alex", + limit: 20, + }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.com/v0/continuity/recall?user_id=user-1&query=rollout&thread_id=thread-1&project=Project+Phoenix&person=Alex&limit=20", + expect.objectContaining({ cache: "no-store" }), + ); + }); + + it("reads continuity retrieval evaluation fixture summary from the shipped endpoint", async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + fixtures: [], + summary: { + fixture_count: 3, + evaluated_fixture_count: 3, + passing_fixture_count: 3, + precision_at_k_mean: 1, + precision_at_1_mean: 1, + precision_target: 0.8, + status: "pass", + fixture_order: ["fixture_id_asc"], + result_order: ["precision_at_k_desc", "fixture_id_asc"], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + await getContinuityRetrievalEvaluation("https://api.example.com", "user-1"); + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.com/v0/continuity/retrieval-evaluation?user_id=user-1", + expect.objectContaining({ cache: "no-store" }), + ); + }); + + it("reads continuity resumption briefs with deterministic section limits", async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + brief: { + assembly_version: "continuity_resumption_brief_v0", + scope: { thread_id: "thread-1", since: null, until: null }, + last_decision: { item: null, empty_state: { is_empty: true, message: "none" } }, + open_loops: { + items: [], + summary: { limit: 3, returned_count: 0, total_count: 0, order: ["created_at_desc", "id_desc"] }, + empty_state: { is_empty: true, message: "none" }, + }, + recent_changes: { + items: [], + summary: { limit: 4, returned_count: 0, total_count: 0, order: ["created_at_desc", "id_desc"] }, + empty_state: { is_empty: true, message: "none" }, + }, + next_action: { item: null, empty_state: { is_empty: true, message: "none" } }, + sources: ["continuity_capture_events", "continuity_objects"], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + await getContinuityResumptionBrief("https://api.example.com", "user-1", { + threadId: "thread-1", + maxRecentChanges: 4, + maxOpenLoops: 3, + }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.com/v0/continuity/resumption-brief?user_id=user-1&thread_id=thread-1&max_recent_changes=4&max_open_loops=3", + expect.objectContaining({ cache: "no-store" }), + ); + }); + + it("reads chief-of-staff priority briefs with deterministic scope parameters", async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + brief: { + assembly_version: "chief_of_staff_priority_brief_v0", + scope: { thread_id: "thread-1", since: null, until: null }, + ranked_items: [], + overdue_items: [], + stale_waiting_for_items: [], + slipped_commitments: [], + escalation_posture: { + posture: "watch", + reason: "No active follow-through escalations are present.", + total_follow_through_count: 0, + nudge_count: 0, + defer_count: 0, + escalate_count: 0, + close_loop_candidate_count: 0, + }, + draft_follow_up: { + status: "none", + mode: "draft_only", + approval_required: true, + auto_send: false, + reason: "No follow-through targets are currently queued for drafting.", + target_metadata: { + continuity_object_id: null, + capture_event_id: null, + object_type: null, + priority_posture: null, + follow_through_posture: null, + recommendation_action: null, + thread_id: "thread-1", + }, + content: { + subject: "", + body: "", + }, + }, + recommended_next_action: { + action_type: "capture_new_priority", + title: "Capture one concrete next action", + target_priority_id: null, + priority_posture: null, + confidence_posture: "low", + reason: "No active priority items are present.", + provenance_references: [], + deterministic_rank_key: "none", + }, + preparation_brief: { + scope: { thread_id: "thread-1", since: null, until: null }, + context_items: [], + last_decision: null, + open_loops: [], + next_action: null, + confidence_posture: "low", + confidence_reason: "Memory quality posture is weak.", + summary: { + limit: 6, + returned_count: 0, + total_count: 0, + order: ["rank_asc", "created_at_desc", "id_desc"], + }, + }, + what_changed_summary: { + items: [], + confidence_posture: "low", + confidence_reason: "Memory quality posture is weak.", + summary: { + limit: 6, + returned_count: 0, + total_count: 0, + order: ["rank_asc", "created_at_desc", "id_desc"], + }, + }, + prep_checklist: { + items: [], + confidence_posture: "low", + confidence_reason: "Memory quality posture is weak.", + summary: { + limit: 6, + returned_count: 0, + total_count: 0, + order: ["rank_asc", "created_at_desc", "id_desc"], + }, + }, + suggested_talking_points: { + items: [], + confidence_posture: "low", + confidence_reason: "Memory quality posture is weak.", + summary: { + limit: 6, + returned_count: 0, + total_count: 0, + order: ["rank_asc", "created_at_desc", "id_desc"], + }, + }, + resumption_supervision: { + recommendations: [], + confidence_posture: "low", + confidence_reason: "Memory quality posture is weak.", + summary: { + limit: 3, + returned_count: 0, + total_count: 0, + order: ["rank_asc"], + }, + }, + weekly_review_brief: { + scope: { thread_id: "thread-1", since: null, until: null }, + rollup: { + total_count: 0, + waiting_for_count: 0, + blocker_count: 0, + stale_count: 0, + correction_recurrence_count: 0, + freshness_drift_count: 0, + next_action_count: 0, + posture_order: ["waiting_for", "blocker", "stale", "next_action"], + }, + guidance: [ + { + rank: 1, + action: "escalate", + signal_count: 0, + rationale: "Escalate where blockers (0) and escalate actions (0) indicate execution risk.", + }, + { + rank: 2, + action: "close", + signal_count: 0, + rationale: + "Close loops where close candidates (0) and actionable next steps (0) support deterministic closure.", + }, + { + rank: 3, + action: "defer", + signal_count: 0, + rationale: + "Defer or park work where defer actions (0), stale items (0), and waiting-for load (0) are concentrated.", + }, + ], + summary: { + guidance_order: ["close", "defer", "escalate"], + guidance_item_order: ["signal_count_desc", "action_desc"], + }, + }, + recommendation_outcomes: { + items: [], + summary: { + returned_count: 0, + total_count: 0, + outcome_counts: { accept: 0, defer: 0, ignore: 0, rewrite: 0 }, + order: ["created_at_desc", "id_desc"], + }, + }, + priority_learning_summary: { + total_count: 0, + accept_count: 0, + defer_count: 0, + ignore_count: 0, + rewrite_count: 0, + acceptance_rate: 0, + override_rate: 0, + defer_hotspots: [], + ignore_hotspots: [], + priority_shift_explanation: + "No recommendation outcomes are captured yet; prioritization remains anchored to current continuity and trust signals.", + hotspot_order: ["count_desc", "key_asc"], + }, + pattern_drift_summary: { + posture: "insufficient_signal", + reason: "No recommendation outcomes are available yet, so drift posture is informational only.", + supporting_signals: [], + }, + summary: { + limit: 7, + returned_count: 0, + total_count: 0, + posture_order: ["urgent", "important", "waiting", "blocked", "stale", "defer"], + order: ["score_desc", "created_at_desc", "id_desc"], + follow_through_posture_order: ["overdue", "stale_waiting_for", "slipped_commitment"], + follow_through_item_order: [ + "recommendation_action_desc", + "age_hours_desc", + "created_at_desc", + "id_desc", + ], + follow_through_total_count: 0, + overdue_count: 0, + stale_waiting_for_count: 0, + slipped_commitment_count: 0, + trust_confidence_posture: "low", + trust_confidence_reason: "Memory quality posture is weak.", + quality_gate_status: "insufficient_sample", + retrieval_status: "pass", + }, + sources: ["continuity_recall", "memory_trust_dashboard"], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + await getChiefOfStaffPriorityBrief("https://api.example.com", "user-1", { + threadId: "thread-1", + limit: 7, + }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.com/v0/chief-of-staff?user_id=user-1&thread_id=thread-1&limit=7", + expect.objectContaining({ cache: "no-store" }), + ); + }); + + it("captures chief-of-staff recommendation outcomes through the deterministic seam", async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + outcome: { + id: "outcome-1", + capture_event_id: "capture-outcome-1", + outcome: "accept", + recommendation_action_type: "execute_next_action", + recommendation_title: "Next Action: Ship dashboard", + rewritten_title: null, + target_priority_id: "priority-1", + rationale: "Accepted in weekly review.", + provenance_references: [], + created_at: "2026-03-31T12:00:00Z", + updated_at: "2026-03-31T12:00:00Z", + }, + recommendation_outcomes: { + items: [], + summary: { + returned_count: 0, + total_count: 1, + outcome_counts: { accept: 1, defer: 0, ignore: 0, rewrite: 0 }, + order: ["created_at_desc", "id_desc"], + }, + }, + priority_learning_summary: { + total_count: 1, + accept_count: 1, + defer_count: 0, + ignore_count: 0, + rewrite_count: 0, + acceptance_rate: 1, + override_rate: 0, + defer_hotspots: [], + ignore_hotspots: [], + priority_shift_explanation: + "Prioritization is reinforcing currently accepted recommendation patterns while tracking defer/override hotspots.", + hotspot_order: ["count_desc", "key_asc"], + }, + pattern_drift_summary: { + posture: "improving", + reason: + "Accepted outcomes are leading with bounded defers/overrides, indicating improving recommendation fit.", + supporting_signals: [], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + await captureChiefOfStaffRecommendationOutcome("https://api.example.com", { + user_id: "user-1", + outcome: "accept", + recommendation_action_type: "execute_next_action", + recommendation_title: "Next Action: Ship dashboard", + target_priority_id: "priority-1", + thread_id: "thread-1", + }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.com/v0/chief-of-staff/recommendation-outcomes", + expect.objectContaining({ + method: "POST", + cache: "no-store", + }), + ); + }); + + it("captures chief-of-staff handoff queue review actions through the deterministic seam", async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + review_action: { + id: "review-1", + capture_event_id: "capture-review-1", + handoff_item_id: "handoff-1", + review_action: "mark_stale", + previous_lifecycle_state: "ready", + next_lifecycle_state: "stale", + reason: "Operator review action moved queue posture to stale.", + note: null, + provenance_references: [], + created_at: "2026-04-01T09:00:00Z", + updated_at: "2026-04-01T09:00:00Z", + }, + handoff_queue_summary: { + total_count: 1, + ready_count: 0, + pending_approval_count: 0, + executed_count: 0, + stale_count: 1, + expired_count: 0, + state_order: ["ready", "pending_approval", "executed", "stale", "expired"], + group_order: ["ready", "pending_approval", "executed", "stale", "expired"], + item_order: ["queue_rank_asc", "handoff_rank_asc", "score_desc", "handoff_item_id_asc"], + review_action_order: ["mark_ready", "mark_pending_approval", "mark_executed", "mark_stale", "mark_expired"], + }, + handoff_queue_groups: { + ready: { + items: [], + summary: { + lifecycle_state: "ready", + returned_count: 0, + total_count: 0, + order: ["queue_rank_asc", "handoff_rank_asc", "score_desc", "handoff_item_id_asc"], + }, + empty_state: { is_empty: true, message: "No ready handoff items for this scope." }, + }, + pending_approval: { + items: [], + summary: { + lifecycle_state: "pending_approval", + returned_count: 0, + total_count: 0, + order: ["queue_rank_asc", "handoff_rank_asc", "score_desc", "handoff_item_id_asc"], + }, + empty_state: { is_empty: true, message: "No handoff items are currently pending approval." }, + }, + executed: { + items: [], + summary: { + lifecycle_state: "executed", + returned_count: 0, + total_count: 0, + order: ["queue_rank_asc", "handoff_rank_asc", "score_desc", "handoff_item_id_asc"], + }, + empty_state: { is_empty: true, message: "No handoff items are currently marked executed." }, + }, + stale: { + items: [ + { + queue_rank: 1, + handoff_rank: 1, + handoff_item_id: "handoff-1", + lifecycle_state: "stale", + state_reason: "Latest operator review action 'mark_stale' set lifecycle state to 'stale'.", + source_kind: "recommended_next_action", + source_reference_id: "priority-1", + title: "Next Action: Ship dashboard", + recommendation_action: "execute_next_action", + priority_posture: "urgent", + confidence_posture: "low", + score: 1650, + age_hours_relative_to_latest: 0, + review_action_order: ["mark_ready", "mark_pending_approval", "mark_executed", "mark_stale", "mark_expired"], + available_review_actions: ["mark_ready", "mark_pending_approval", "mark_executed", "mark_expired"], + last_review_action: null, + provenance_references: [], + }, + ], + summary: { + lifecycle_state: "stale", + returned_count: 1, + total_count: 1, + order: ["queue_rank_asc", "handoff_rank_asc", "score_desc", "handoff_item_id_asc"], + }, + empty_state: { is_empty: false, message: "No stale handoff items are currently surfaced." }, + }, + expired: { + items: [], + summary: { + lifecycle_state: "expired", + returned_count: 0, + total_count: 0, + order: ["queue_rank_asc", "handoff_rank_asc", "score_desc", "handoff_item_id_asc"], + }, + empty_state: { is_empty: true, message: "No expired handoff items are currently surfaced." }, + }, + }, + handoff_review_actions: [], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + await captureChiefOfStaffHandoffReviewAction("https://api.example.com", { + user_id: "user-1", + handoff_item_id: "handoff-1", + review_action: "mark_stale", + thread_id: "thread-1", + }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.com/v0/chief-of-staff/handoff-review-actions", + expect.objectContaining({ + method: "POST", + cache: "no-store", + }), + ); + }); + + it("captures chief-of-staff execution routing actions through the deterministic seam", async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + routing_action: { + id: "routing-1", + capture_event_id: "capture-routing-1", + handoff_item_id: "handoff-1", + route_target: "task_workflow_draft", + transition: "routed", + previously_routed: false, + route_state: true, + reason: "Operator routed handoff 'handoff-1' to 'task_workflow_draft'.", + note: null, + provenance_references: [], + created_at: "2026-04-01T09:30:00Z", + updated_at: "2026-04-01T09:30:00Z", + }, + execution_routing_summary: { + total_handoff_count: 1, + routed_handoff_count: 1, + unrouted_handoff_count: 0, + task_workflow_draft_count: 1, + approval_workflow_draft_count: 0, + follow_up_draft_only_count: 0, + route_target_order: ["task_workflow_draft", "approval_workflow_draft", "follow_up_draft_only"], + routed_item_order: ["handoff_rank_asc", "handoff_item_id_asc"], + audit_order: ["created_at_desc", "id_desc"], + transition_order: ["routed", "reaffirmed"], + approval_required: true, + non_autonomous_guarantee: + "No task, approval, connector send, or external side effect is executed by this endpoint.", + reason: "Routing transitions are explicit and auditable.", + }, + routed_handoff_items: [], + routing_audit_trail: [], + execution_readiness_posture: { + posture: "approval_required_draft_only", + approval_required: true, + autonomous_execution: false, + external_side_effects_allowed: false, + approval_path_visible: true, + route_target_order: ["task_workflow_draft", "approval_workflow_draft", "follow_up_draft_only"], + required_route_targets: ["task_workflow_draft", "approval_workflow_draft"], + transition_order: ["routed", "reaffirmed"], + non_autonomous_guarantee: + "No task, approval, connector send, or external side effect is executed by this endpoint.", + reason: "Execution routing remains draft-only.", + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + await captureChiefOfStaffExecutionRoutingAction("https://api.example.com", { + user_id: "user-1", + handoff_item_id: "handoff-1", + route_target: "task_workflow_draft", + thread_id: "thread-1", + }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.com/v0/chief-of-staff/execution-routing-actions", + expect.objectContaining({ + method: "POST", + cache: "no-store", + }), + ); + }); + + it("captures chief-of-staff handoff outcomes through the deterministic seam", async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + handoff_outcome: { + id: "handoff-outcome-1", + capture_event_id: "capture-handoff-outcome-1", + handoff_item_id: "handoff-1", + outcome_status: "executed", + previous_outcome_status: null, + is_latest_outcome: true, + reason: "Operator captured routed handoff outcome 'executed' for 'handoff-1'.", + note: null, + provenance_references: [], + created_at: "2026-04-07T09:30:00Z", + updated_at: "2026-04-07T09:30:00Z", + }, + handoff_outcome_summary: { + returned_count: 1, + total_count: 1, + latest_total_count: 1, + status_counts: { + reviewed: 0, + approved: 0, + rejected: 0, + rewritten: 0, + executed: 1, + ignored: 0, + expired: 0, + }, + latest_status_counts: { + reviewed: 0, + approved: 0, + rejected: 0, + rewritten: 0, + executed: 1, + ignored: 0, + expired: 0, + }, + status_order: ["reviewed", "approved", "rejected", "rewritten", "executed", "ignored", "expired"], + order: ["created_at_desc", "id_desc"], + }, + handoff_outcomes: [], + closure_quality_summary: { + posture: "healthy", + reason: "Closed-loop outcomes are leading with bounded unresolved and ignored outcomes.", + closed_loop_count: 1, + unresolved_count: 0, + rejected_count: 0, + ignored_count: 0, + expired_count: 0, + closure_rate: 1, + explanation: "Closure quality uses latest immutable outcomes.", + }, + conversion_signal_summary: { + total_handoff_count: 1, + latest_outcome_count: 1, + executed_count: 1, + approved_count: 0, + reviewed_count: 0, + rewritten_count: 0, + rejected_count: 0, + ignored_count: 0, + expired_count: 0, + recommendation_to_execution_conversion_rate: 1, + recommendation_to_closure_conversion_rate: 1, + capture_coverage_rate: 1, + explanation: "Conversion signals are derived from latest immutable outcomes.", + }, + stale_ignored_escalation_posture: { + posture: "watch", + reason: "No stale queue pressure or ignored/expired latest outcomes are currently detected.", + stale_queue_count: 0, + ignored_count: 0, + expired_count: 0, + trigger_count: 0, + guidance_posture_explanation: + "Guidance posture is derived from stale queue load plus ignored/expired latest outcome counts.", + supporting_signals: ["stale_queue_count=0", "ignored_count=0", "expired_count=0", "trigger_count=0"], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + await captureChiefOfStaffHandoffOutcome("https://api.example.com", { + user_id: "user-1", + handoff_item_id: "handoff-1", + outcome_status: "executed", + thread_id: "thread-1", + }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.com/v0/chief-of-staff/handoff-outcomes", + expect.objectContaining({ + method: "POST", + cache: "no-store", + }), + ); + }); + + it("uses continuity review queue/detail/correction endpoints", async () => { + fetchMock + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + items: [], + summary: { + status: "correction_ready", + limit: 20, + returned_count: 0, + total_count: 0, + order: ["updated_at_desc", "created_at_desc", "id_desc"], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + review: { + continuity_object: { + id: "object-1", + capture_event_id: "capture-1", + object_type: "Decision", + status: "active", + title: "Decision: Keep rollout phased", + body: { decision_text: "Keep rollout phased" }, + provenance: {}, + confidence: 0.9, + last_confirmed_at: null, + supersedes_object_id: null, + superseded_by_object_id: null, + created_at: "2026-03-30T10:00:00Z", + updated_at: "2026-03-30T10:00:00Z", + }, + correction_events: [], + supersession_chain: { supersedes: null, superseded_by: null }, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + continuity_object: { + id: "object-1", + capture_event_id: "capture-1", + object_type: "Decision", + status: "active", + title: "Decision: Keep rollout phased", + body: { decision_text: "Keep rollout phased" }, + provenance: {}, + confidence: 0.9, + last_confirmed_at: "2026-03-30T10:01:00Z", + supersedes_object_id: null, + superseded_by_object_id: null, + created_at: "2026-03-30T10:00:00Z", + updated_at: "2026-03-30T10:01:00Z", + }, + correction_event: { + id: "event-1", + continuity_object_id: "object-1", + action: "confirm", + reason: "Reviewed", + before_snapshot: {}, + after_snapshot: {}, + payload: {}, + created_at: "2026-03-30T10:01:00Z", + }, + replacement_object: null, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + await listContinuityReviewQueue("https://api.example.com", "user-1", { + status: "correction_ready", + limit: 20, + }); + await getContinuityReviewDetail("https://api.example.com", "object-1", "user-1"); + await applyContinuityCorrection("https://api.example.com", "object-1", { + user_id: "user-1", + action: "confirm", + reason: "Reviewed", + }); + + expect(fetchMock.mock.calls).toEqual([ + [ + "https://api.example.com/v0/continuity/review-queue?user_id=user-1&status=correction_ready&limit=20", + expect.objectContaining({ cache: "no-store" }), + ], + [ + "https://api.example.com/v0/continuity/review-queue/object-1?user_id=user-1", + expect.objectContaining({ cache: "no-store" }), + ], + [ + "https://api.example.com/v0/continuity/review-queue/object-1/corrections", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ "Content-Type": "application/json" }), + }), + ], + ]); + expect(JSON.parse(String(fetchMock.mock.calls[2]?.[1]?.body))).toEqual({ + user_id: "user-1", + action: "confirm", + reason: "Reviewed", + }); + }); + + it("uses continuity open-loop dashboard, daily/weekly brief, and review-action endpoints", async () => { + fetchMock + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + dashboard: { + scope: { thread_id: "thread-1", since: null, until: null }, + waiting_for: { + items: [], + summary: { limit: 10, returned_count: 0, total_count: 0, order: ["created_at_desc", "id_desc"] }, + empty_state: { is_empty: true, message: "none" }, + }, + blocker: { + items: [], + summary: { limit: 10, returned_count: 0, total_count: 0, order: ["created_at_desc", "id_desc"] }, + empty_state: { is_empty: true, message: "none" }, + }, + stale: { + items: [], + summary: { limit: 10, returned_count: 0, total_count: 0, order: ["created_at_desc", "id_desc"] }, + empty_state: { is_empty: true, message: "none" }, + }, + next_action: { + items: [], + summary: { limit: 10, returned_count: 0, total_count: 0, order: ["created_at_desc", "id_desc"] }, + empty_state: { is_empty: true, message: "none" }, + }, + summary: { + limit: 10, + total_count: 0, + posture_order: ["waiting_for", "blocker", "stale", "next_action"], + item_order: ["created_at_desc", "id_desc"], + }, + sources: ["continuity_capture_events", "continuity_objects", "continuity_correction_events"], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + brief: { + assembly_version: "continuity_daily_brief_v0", + scope: { thread_id: "thread-1", since: null, until: null }, + waiting_for_highlights: { + items: [], + summary: { limit: 3, returned_count: 0, total_count: 0, order: ["created_at_desc", "id_desc"] }, + empty_state: { is_empty: true, message: "none" }, + }, + blocker_highlights: { + items: [], + summary: { limit: 3, returned_count: 0, total_count: 0, order: ["created_at_desc", "id_desc"] }, + empty_state: { is_empty: true, message: "none" }, + }, + stale_items: { + items: [], + summary: { limit: 3, returned_count: 0, total_count: 0, order: ["created_at_desc", "id_desc"] }, + empty_state: { is_empty: true, message: "none" }, + }, + next_suggested_action: { item: null, empty_state: { is_empty: true, message: "none" } }, + sources: ["continuity_capture_events", "continuity_objects", "continuity_correction_events"], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + review: { + assembly_version: "continuity_weekly_review_v0", + scope: { thread_id: "thread-1", since: null, until: null }, + rollup: { + total_count: 0, + waiting_for_count: 0, + blocker_count: 0, + stale_count: 0, + correction_recurrence_count: 0, + freshness_drift_count: 0, + next_action_count: 0, + posture_order: ["waiting_for", "blocker", "stale", "next_action"], + }, + waiting_for: { + items: [], + summary: { limit: 5, returned_count: 0, total_count: 0, order: ["created_at_desc", "id_desc"] }, + empty_state: { is_empty: true, message: "none" }, + }, + blocker: { + items: [], + summary: { limit: 5, returned_count: 0, total_count: 0, order: ["created_at_desc", "id_desc"] }, + empty_state: { is_empty: true, message: "none" }, + }, + stale: { + items: [], + summary: { limit: 5, returned_count: 0, total_count: 0, order: ["created_at_desc", "id_desc"] }, + empty_state: { is_empty: true, message: "none" }, + }, + next_action: { + items: [], + summary: { limit: 5, returned_count: 0, total_count: 0, order: ["created_at_desc", "id_desc"] }, + empty_state: { is_empty: true, message: "none" }, + }, + sources: ["continuity_capture_events", "continuity_objects", "continuity_correction_events"], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + continuity_object: { + id: "object-1", + capture_event_id: "capture-1", + object_type: "WaitingFor", + status: "completed", + title: "Waiting For: Vendor quote", + body: { waiting_for_text: "Vendor quote" }, + provenance: {}, + confidence: 0.9, + last_confirmed_at: null, + supersedes_object_id: null, + superseded_by_object_id: null, + created_at: "2026-03-30T10:00:00Z", + updated_at: "2026-03-30T10:01:00Z", + }, + correction_event: { + id: "event-1", + continuity_object_id: "object-1", + action: "edit", + reason: "done in standup", + before_snapshot: {}, + after_snapshot: {}, + payload: { review_action: "done" }, + created_at: "2026-03-30T10:01:00Z", + }, + review_action: "done", + lifecycle_outcome: "completed", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + await getContinuityOpenLoopDashboard("https://api.example.com", "user-1", { + threadId: "thread-1", + limit: 10, + }); + await getContinuityDailyBrief("https://api.example.com", "user-1", { + threadId: "thread-1", + limit: 3, + }); + await getContinuityWeeklyReview("https://api.example.com", "user-1", { + threadId: "thread-1", + limit: 5, + }); + await applyContinuityOpenLoopReviewAction("https://api.example.com", "object-1", { + user_id: "user-1", + action: "done", + note: "done in standup", + }); + + expect(fetchMock.mock.calls).toEqual([ + [ + "https://api.example.com/v0/continuity/open-loops?user_id=user-1&thread_id=thread-1&limit=10", + expect.objectContaining({ cache: "no-store" }), + ], + [ + "https://api.example.com/v0/continuity/daily-brief?user_id=user-1&thread_id=thread-1&limit=3", + expect.objectContaining({ cache: "no-store" }), + ], + [ + "https://api.example.com/v0/continuity/weekly-review?user_id=user-1&thread_id=thread-1&limit=5", + expect.objectContaining({ cache: "no-store" }), + ], + [ + "https://api.example.com/v0/continuity/open-loops/object-1/review-action", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ "Content-Type": "application/json" }), + }), + ], + ]); + expect(JSON.parse(String(fetchMock.mock.calls[3]?.[1]?.body))).toEqual({ + user_id: "user-1", + action: "done", + note: "done in standup", + }); + }); +}); diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts new file mode 100644 index 0000000..649ee13 --- /dev/null +++ b/apps/web/lib/api.ts @@ -0,0 +1,3482 @@ +export type ApiSource = "live" | "fixture"; +export type PageDataMode = "live" | "fixture" | "mixed"; + +export type ApiConfig = { + apiBaseUrl: string; + userId: string; + defaultThreadId: string; + defaultToolId: string; +}; + +export const DEFAULT_AGENT_PROFILE_ID = "assistant_default"; + +export type ThreadItem = { + id: string; + title: string; + agent_profile_id: string; + created_at: string; + updated_at: string; +}; + +export type ThreadSessionItem = { + id: string; + thread_id: string; + status: string; + started_at: string | null; + ended_at: string | null; + created_at: string; +}; + +export type ThreadEventItem = { + id: string; + thread_id: string; + session_id: string | null; + sequence_no: number; + kind: string; + payload: unknown; + created_at: string; +}; + +export type ThreadCreatePayload = { + user_id: string; + title: string; + agent_profile_id: string; +}; + +export type ThreadListSummary = { + total_count: number; + order: string[]; +}; + +export type AgentProfileItem = { + id: string; + name: string; + description: string; +}; + +export type AgentProfileListSummary = { + total_count: number; + order: string[]; +}; + +export type ThreadSessionListSummary = { + thread_id: string; + total_count: number; + order: string[]; +}; + +export type ThreadEventListSummary = { + thread_id: string; + total_count: number; + order: string[]; +}; + +export type ResumptionBriefSectionSummary = { + limit: number; + returned_count: number; + total_count: number; + order: string[]; +}; + +export type ResumptionBriefConversationSummary = ResumptionBriefSectionSummary & { + kinds: string[]; +}; + +export type ResumptionBriefConversationSection = { + items: ThreadEventItem[]; + summary: ResumptionBriefConversationSummary; +}; + +export type ResumptionBriefOpenLoopSection = { + items: OpenLoopRecord[]; + summary: ResumptionBriefSectionSummary; +}; + +export type ResumptionBriefMemoryHighlightSection = { + items: MemoryReviewRecord[]; + summary: ResumptionBriefSectionSummary; +}; + +export type ResumptionBriefWorkflowSummary = { + present: boolean; + task_order: string[]; + task_step_order: string[]; +}; + +export type ResumptionBriefWorkflowPosture = { + task: TaskItem; + latest_task_step: TaskStepItem | null; + summary: ResumptionBriefWorkflowSummary; +}; + +export type ResumptionBrief = { + assembly_version: string; + thread: ThreadItem; + conversation: ResumptionBriefConversationSection; + open_loops: ResumptionBriefOpenLoopSection; + memory_highlights: ResumptionBriefMemoryHighlightSection; + workflow: ResumptionBriefWorkflowPosture | null; + sources: string[]; +}; + +export type ToolRoutingReason = { + code: string; + source: string; + message: string; + tool_id: string | null; + policy_id: string | null; + consent_key: string | null; +}; + +export type ToolRecord = { + id: string; + tool_key: string; + name: string; + description: string; + version: string; + metadata_version: string; + active: boolean; + tags: string[]; + action_hints: string[]; + scope_hints: string[]; + domain_hints: string[]; + risk_hints: string[]; + metadata: Record<string, unknown>; + created_at: string; +}; + +export type JsonObject = Record<string, unknown>; + +export type GovernedRequestRecord = { + thread_id: string; + tool_id: string; + action: string; + scope: string; + domain_hint: string | null; + risk_hint: string | null; + attributes: Record<string, unknown>; +}; + +export type ApprovalItem = { + id: string; + thread_id: string; + task_run_id: string | null; + task_step_id: string | null; + status: string; + request: GovernedRequestRecord; + tool: ToolRecord; + routing: { + decision: string; + reasons: ToolRoutingReason[]; + trace: { + trace_id: string; + trace_event_count: number; + }; + }; + created_at: string; + resolution: { + resolved_at: string; + resolved_by_user_id: string; + } | null; +}; + +export type TaskItem = { + id: string; + thread_id: string; + tool_id: string; + status: string; + request: GovernedRequestRecord; + tool: ToolRecord; + latest_approval_id: string | null; + latest_execution_id: string | null; + created_at: string; + updated_at: string; +}; + +export type TaskStepItem = { + id: string; + task_id: string; + sequence_no: number; + kind: string; + status: string; + request: GovernedRequestRecord; + outcome: { + routing_decision: string; + approval_id: string | null; + approval_status: string | null; + execution_id: string | null; + execution_status: string | null; + blocked_reason: string | null; + }; + lineage: { + parent_step_id: string | null; + source_approval_id: string | null; + source_execution_id: string | null; + }; + trace: { + trace_id: string; + trace_kind: string; + }; + created_at: string; + updated_at: string; +}; + +export type TaskStepListSummary = { + task_id: string; + total_count: number; + latest_sequence_no: number | null; + latest_status: string | null; + next_sequence_no: number; + append_allowed: boolean; + order: string[]; +}; + +export type TaskRunStatus = + | "queued" + | "running" + | "waiting_approval" + | "waiting_user" + | "paused" + | "failed" + | "done" + | "cancelled"; +export type TaskRunStopReason = + | "waiting_approval" + | "waiting_user" + | "budget_exhausted" + | "paused" + | "approval_rejected" + | "policy_blocked" + | "retry_exhausted" + | "fatal_error" + | "done" + | "cancelled"; +export type TaskRunFailureClass = "transient" | "policy" | "approval" | "budget" | "fatal"; +export type TaskRunRetryPosture = + | "none" + | "retryable" + | "exhausted" + | "terminal" + | "paused" + | "awaiting_approval" + | "awaiting_user"; + +export type TaskRunItem = { + id: string; + task_id: string; + status: TaskRunStatus; + checkpoint: JsonObject; + tick_count: number; + step_count: number; + max_ticks: number; + retry_count: number; + retry_cap: number; + retry_posture: TaskRunRetryPosture; + failure_class: TaskRunFailureClass | null; + stop_reason: TaskRunStopReason | null; + last_transitioned_at: string; + created_at: string; + updated_at: string; +}; + +export type TaskRunListSummary = { + task_id: string; + total_count: number; + order: string[]; +}; + +export type ToolExecutionResult = { + handler_key: string | null; + status: string; + output: JsonObject | null; + reason: string | null; + budget_decision?: JsonObject; +}; + +export type ToolExecutionItem = { + id: string; + approval_id: string; + task_run_id: string | null; + task_step_id: string; + thread_id: string; + tool_id: string; + trace_id: string; + request_event_id: string | null; + result_event_id: string | null; + status: string; + handler_key: string | null; + idempotency_key: string | null; + request: GovernedRequestRecord; + tool: ToolRecord; + result: ToolExecutionResult; + executed_at: string; +}; + +export type ApprovalExecutionResponse = { + request: { + approval_id: string; + task_run_id: string | null; + task_step_id: string; + }; + approval: ApprovalItem; + tool: ToolRecord; + result: ToolExecutionResult; + events: { + request_event_id: string; + request_sequence_no: number; + result_event_id: string; + result_sequence_no: number; + } | null; + trace: { + trace_id: string; + trace_event_count: number; + }; +}; + +export type TraceReviewSummaryItem = { + id: string; + thread_id: string; + kind: string; + compiler_version: string; + status: string; + created_at: string; + trace_event_count: number; +}; + +export type TraceReviewItem = TraceReviewSummaryItem & { + limits: Record<string, unknown>; +}; + +export type TraceReviewEventItem = { + id: string; + trace_id: string; + sequence_no: number; + kind: string; + payload: unknown; + created_at: string; +}; + +export type TraceReviewListSummary = { + total_count: number; + order: string[]; +}; + +export type TraceReviewEventListSummary = { + trace_id: string; + total_count: number; + order: string[]; +}; + +export type MemoryReviewStatus = "active" | "deleted"; +export type MemoryReviewStatusFilter = MemoryReviewStatus | "all"; +export type OpenLoopStatus = "open" | "resolved" | "dismissed"; +export type OpenLoopStatusFilter = OpenLoopStatus | "all"; +export type MemoryReviewLabelValue = + | "correct" + | "incorrect" + | "outdated" + | "insufficient_evidence"; + +export type MemoryType = + | "preference" + | "identity_fact" + | "relationship_fact" + | "project_fact" + | "decision" + | "commitment" + | "routine" + | "constraint" + | "working_style"; + +export type MemoryConfirmationStatus = "unconfirmed" | "confirmed" | "contested"; + +export type MemoryReviewRecord = { + id: string; + memory_key: string; + value: unknown; + status: MemoryReviewStatus; + source_event_ids: string[]; + memory_type?: MemoryType; + confidence?: number | null; + salience?: number | null; + confirmation_status?: MemoryConfirmationStatus; + valid_from?: string | null; + valid_to?: string | null; + last_confirmed_at?: string | null; + created_at: string; + updated_at: string; + deleted_at: string | null; +}; + +export type MemoryReviewListSummary = { + status: MemoryReviewStatusFilter; + limit: number; + returned_count: number; + total_count: number; + has_more: boolean; + order: string[]; +}; + +export type MemoryRevisionReviewRecord = { + id: string; + memory_id: string; + sequence_no: number; + action: string; + memory_key: string; + previous_value: unknown | null; + new_value: unknown | null; + source_event_ids: string[]; + created_at: string; +}; + +export type MemoryRevisionReviewListSummary = { + memory_id: string; + limit: number; + returned_count: number; + total_count: number; + has_more: boolean; + order: string[]; +}; + +export type MemoryReviewLabelCounts = { + correct: number; + incorrect: number; + outdated: number; + insufficient_evidence: number; +}; + +export type MemoryReviewLabelRecord = { + id: string; + memory_id: string; + reviewer_user_id: string; + label: MemoryReviewLabelValue; + note: string | null; + created_at: string; +}; + +export type MemoryReviewLabelSummary = { + memory_id: string; + total_count: number; + counts_by_label: MemoryReviewLabelCounts; + order: MemoryReviewLabelValue[]; +}; + +export type MemoryReviewQueueItem = { + id: string; + memory_key: string; + value: unknown; + status: "active"; + source_event_ids: string[]; + memory_type?: MemoryType; + confidence?: number | null; + salience?: number | null; + confirmation_status?: MemoryConfirmationStatus; + valid_from?: string | null; + valid_to?: string | null; + last_confirmed_at?: string | null; + is_high_risk: boolean; + is_stale_truth: boolean; + queue_priority_mode: MemoryReviewQueuePriorityMode; + priority_reason: string; + created_at: string; + updated_at: string; +}; + +export type MemoryReviewQueuePriorityMode = + | "oldest_first" + | "recent_first" + | "high_risk_first" + | "stale_truth_first"; + +export type MemoryReviewQueueSummary = { + memory_status: "active"; + review_state: "unlabeled"; + priority_mode: MemoryReviewQueuePriorityMode; + available_priority_modes: MemoryReviewQueuePriorityMode[]; + limit: number; + returned_count: number; + total_count: number; + has_more: boolean; + order: string[]; +}; + +export type MemoryQualityGateStatus = + | "healthy" + | "needs_review" + | "insufficient_sample" + | "degraded"; + +export type MemoryQualityReviewAction = + | "adjudicate_minimum_sample" + | "review_high_risk_queue" + | "review_stale_truth_queue" + | "drain_unlabeled_queue" + | "investigate_correction_recurrence" + | "remediate_freshness_drift" + | "monitor_quality_posture"; + +export type MemoryQualityGateSummary = { + status: MemoryQualityGateStatus; + precision: number | null; + precision_target: number; + adjudicated_sample_count: number; + minimum_adjudicated_sample: number; + remaining_to_minimum_sample: number; + unlabeled_memory_count: number; + high_risk_memory_count: number; + stale_truth_count: number; + superseded_active_conflict_count: number; + counts: { + active_memory_count: number; + labeled_active_memory_count: number; + adjudicated_correct_count: number; + adjudicated_incorrect_count: number; + outdated_label_count: number; + insufficient_evidence_label_count: number; + }; +}; + +export type MemoryEvaluationSummary = { + total_memory_count: number; + active_memory_count: number; + deleted_memory_count: number; + labeled_memory_count: number; + unlabeled_memory_count: number; + total_label_row_count: number; + label_row_counts_by_value: MemoryReviewLabelCounts; + label_value_order: MemoryReviewLabelValue[]; + quality_gate?: MemoryQualityGateSummary; +}; + +export type MemoryTrustQueueAgingSummary = { + anchor_updated_at: string | null; + newest_updated_at: string | null; + oldest_updated_at: string | null; + backlog_span_hours: number; + fresh_within_24h_count: number; + aging_24h_to_72h_count: number; + stale_over_72h_count: number; +}; + +export type MemoryTrustQueuePostureSummary = { + priority_mode: MemoryReviewQueuePriorityMode; + total_count: number; + high_risk_count: number; + stale_truth_count: number; + priority_reason_counts: Record<string, number>; + order: string[]; + aging: MemoryTrustQueueAgingSummary; +}; + +export type MemoryTrustCorrectionFreshnessSummary = { + total_open_loop_count: number; + stale_open_loop_count: number; + correction_recurrence_count: number; + freshness_drift_count: number; +}; + +export type MemoryTrustRecommendedReview = { + priority_mode: MemoryReviewQueuePriorityMode; + action: MemoryQualityReviewAction; + reason: string; +}; + +export type MemoryTrustDashboardSummary = { + quality_gate: MemoryQualityGateSummary; + queue_posture: MemoryTrustQueuePostureSummary; + retrieval_quality: RetrievalEvaluationSummary; + correction_freshness: MemoryTrustCorrectionFreshnessSummary; + recommended_review: MemoryTrustRecommendedReview; + sources: string[]; +}; + +export type OpenLoopRecord = { + id: string; + memory_id: string | null; + title: string; + status: OpenLoopStatus; + opened_at: string; + due_at: string | null; + resolved_at: string | null; + resolution_note: string | null; + created_at: string; + updated_at: string; +}; + +export type OpenLoopListSummary = { + status: OpenLoopStatusFilter; + limit: number; + returned_count: number; + total_count: number; + has_more: boolean; + order: string[]; +}; + +export type EntityType = "person" | "merchant" | "product" | "project" | "routine"; + +export type EntityRecord = { + id: string; + entity_type: EntityType; + name: string; + source_memory_ids: string[]; + created_at: string; +}; + +export type EntityListSummary = { + total_count: number; + order: string[]; +}; + +export type EntityEdgeRecord = { + id: string; + from_entity_id: string; + to_entity_id: string; + relationship_type: string; + valid_from: string | null; + valid_to: string | null; + source_memory_ids: string[]; + created_at: string; +}; + +export type EntityEdgeListSummary = { + entity_id: string; + total_count: number; + order: string[]; +}; + +export type GmailReadonlyScope = "https://www.googleapis.com/auth/gmail.readonly"; + +export type GmailAccountRecord = { + id: string; + provider: string; + auth_kind: string; + provider_account_id: string; + email_address: string; + display_name: string | null; + scope: GmailReadonlyScope; + created_at: string; + updated_at: string; +}; + +export type GmailAccountListSummary = { + total_count: number; + order: string[]; +}; + +export type GmailAccountConnectPayload = { + user_id: string; + provider_account_id: string; + email_address: string; + display_name?: string | null; + scope: GmailReadonlyScope; + access_token: string; + refresh_token?: string | null; + client_id?: string | null; + client_secret?: string | null; + access_token_expires_at?: string | null; +}; + +export type GmailMessageIngestPayload = { + user_id: string; + task_workspace_id: string; +}; + +export type GmailMessageIngestionRecord = { + provider_message_id: string; + artifact_relative_path: string; + media_type: string; +}; + +export type GmailMessageIngestionResponse = { + account: GmailAccountRecord; + message: GmailMessageIngestionRecord; + artifact: TaskArtifactRecord; + summary: TaskArtifactChunkListSummary; +}; + +export type CalendarReadonlyScope = "https://www.googleapis.com/auth/calendar.readonly"; + +export type CalendarAccountRecord = { + id: string; + provider: string; + auth_kind: string; + provider_account_id: string; + email_address: string; + display_name: string | null; + scope: CalendarReadonlyScope; + created_at: string; + updated_at: string; +}; + +export type CalendarAccountListSummary = { + total_count: number; + order: string[]; +}; + +export type CalendarAccountConnectPayload = { + user_id: string; + provider_account_id: string; + email_address: string; + display_name?: string | null; + scope: CalendarReadonlyScope; + access_token: string; +}; + +export type CalendarEventIngestPayload = { + user_id: string; + task_workspace_id: string; +}; + +export type CalendarEventIngestionRecord = { + provider_event_id: string; + artifact_relative_path: string; + media_type: string; +}; + +export type CalendarEventIngestionResponse = { + account: CalendarAccountRecord; + event: CalendarEventIngestionRecord; + artifact: TaskArtifactRecord; + summary: TaskArtifactChunkListSummary; +}; + +export type CalendarEventSummaryRecord = { + provider_event_id: string; + status: string | null; + summary: string | null; + start_time: string | null; + end_time: string | null; + html_link: string | null; + updated_at: string | null; +}; + +export type CalendarEventListSummary = { + total_count: number; + limit: number; + order: string[]; + time_min: string | null; + time_max: string | null; +}; + +export type CalendarEventListResponse = { + account: CalendarAccountRecord; + items: CalendarEventSummaryRecord[]; + summary: CalendarEventListSummary; +}; + +export type CalendarEventListQuery = { + limit?: number; + timeMin?: string; + timeMax?: string; +}; + +export type TaskWorkspaceStatus = "active"; + +export type TaskWorkspaceRecord = { + id: string; + task_id: string; + status: TaskWorkspaceStatus; + local_path: string; + created_at: string; + updated_at: string; +}; + +export type TaskWorkspaceListSummary = { + total_count: number; + order: string[]; +}; + +export type TaskArtifactStatus = "registered"; +export type TaskArtifactIngestionStatus = "pending" | "ingested"; + +export type TaskArtifactRecord = { + id: string; + task_id: string; + task_workspace_id: string; + status: TaskArtifactStatus; + ingestion_status: TaskArtifactIngestionStatus; + relative_path: string; + media_type_hint: string | null; + created_at: string; + updated_at: string; +}; + +export type TaskArtifactListSummary = { + total_count: number; + order: string[]; +}; + +export type TaskArtifactChunkRecord = { + id: string; + task_artifact_id: string; + sequence_no: number; + char_start: number; + char_end_exclusive: number; + text: string; + created_at: string; + updated_at: string; +}; + +export type TaskArtifactChunkListSummary = { + total_count: number; + total_characters: number; + media_type: string; + chunking_rule: string; + order: string[]; +}; + +export type MemoryReviewLabelPayload = { + user_id: string; + label: MemoryReviewLabelValue; + note?: string | null; +}; + +export type MemoryAdmitPayload = { + user_id: string; + memory_key: string; + value: unknown | null; + source_event_ids: string[]; + delete_requested?: boolean; + open_loop?: { + title: string; + due_at?: string | null; + }; +}; + +export type ExplicitCommitmentPattern = + | "remind_me_to" + | "i_need_to" + | "dont_let_me_forget_to" + | "remember_to"; + +export type ExplicitCommitmentOpenLoopDecision = + | "CREATED" + | "NOOP_ACTIVE_EXISTS" + | "NOOP_MEMORY_NOT_PERSISTED"; + +export type ExplicitPreferencePattern = + | "i_like" + | "i_dont_like" + | "i_prefer" + | "remember_that_i_like" + | "remember_that_i_dont_like" + | "remember_that_i_prefer"; + +export type ExtractExplicitSignalsPayload = { + user_id: string; + source_event_id: string; +}; + +export type ExtractExplicitCommitmentsPayload = { + user_id: string; + source_event_id: string; +}; + +export type ExtractedPreferenceCandidateRecord = { + memory_key: string; + value: unknown; + source_event_ids: string[]; + delete_requested: boolean; + pattern: ExplicitPreferencePattern; + subject_text: string; +}; + +export type ExplicitPreferenceAdmissionRecord = { + decision: "NOOP" | "ADD" | "UPDATE" | "DELETE"; + reason: string; + memory: PersistedMemoryRecord | null; + revision: PersistedMemoryRevisionRecord | null; +}; + +export type ExplicitPreferenceExtractionResponse = { + candidates: ExtractedPreferenceCandidateRecord[]; + admissions: ExplicitPreferenceAdmissionRecord[]; + summary: { + source_event_id: string; + source_event_kind: string; + candidate_count: number; + admission_count: number; + persisted_change_count: number; + noop_count: number; + }; +}; + +export type ExtractedCommitmentCandidateRecord = { + memory_key: string; + value: unknown; + source_event_ids: string[]; + delete_requested: boolean; + pattern: ExplicitCommitmentPattern; + commitment_text: string; + open_loop_title: string; +}; + +export type ExplicitCommitmentAdmissionRecord = { + decision: "NOOP" | "ADD" | "UPDATE" | "DELETE"; + reason: string; + memory: PersistedMemoryRecord | null; + revision: PersistedMemoryRevisionRecord | null; + open_loop: { + decision: ExplicitCommitmentOpenLoopDecision; + reason: string; + open_loop: OpenLoopRecord | null; + }; +}; + +export type ExplicitCommitmentExtractionResponse = { + candidates: ExtractedCommitmentCandidateRecord[]; + admissions: ExplicitCommitmentAdmissionRecord[]; + summary: { + source_event_id: string; + source_event_kind: string; + candidate_count: number; + admission_count: number; + persisted_change_count: number; + noop_count: number; + open_loop_created_count: number; + open_loop_noop_count: number; + }; +}; + +export type ExplicitSignalCaptureResponse = { + preferences: ExplicitPreferenceExtractionResponse; + commitments: ExplicitCommitmentExtractionResponse; + summary: { + source_event_id: string; + source_event_kind: string; + candidate_count: number; + admission_count: number; + persisted_change_count: number; + noop_count: number; + open_loop_created_count: number; + open_loop_noop_count: number; + preference_candidate_count: number; + preference_admission_count: number; + commitment_candidate_count: number; + commitment_admission_count: number; + }; +}; + +export type ContinuityCaptureExplicitSignal = + | "remember_this" + | "task" + | "decision" + | "commitment" + | "waiting_for" + | "blocker" + | "next_action" + | "note"; + +export type ContinuityCaptureAdmissionPosture = "DERIVED" | "TRIAGE"; + +export type ContinuityObjectType = + | "Note" + | "MemoryFact" + | "Decision" + | "Commitment" + | "WaitingFor" + | "Blocker" + | "NextAction"; + +export type ContinuityCaptureEventRecord = { + id: string; + raw_content: string; + explicit_signal: ContinuityCaptureExplicitSignal | null; + admission_posture: ContinuityCaptureAdmissionPosture; + admission_reason: string; + created_at: string; +}; + +export type ContinuityObjectRecord = { + id: string; + capture_event_id: string; + object_type: ContinuityObjectType; + status: "active" | "completed" | "cancelled" | "superseded" | "stale"; + title: string; + body: JsonObject; + provenance: JsonObject; + confidence: number; + created_at: string; + updated_at: string; +}; + +export type ContinuityReviewStatusFilter = + | "correction_ready" + | "active" + | "stale" + | "superseded" + | "deleted" + | "all"; + +export type ContinuityCorrectionAction = "confirm" | "edit" | "delete" | "supersede" | "mark_stale"; + +export type ContinuityReviewObject = { + id: string; + capture_event_id: string; + object_type: ContinuityObjectType; + status: string; + title: string; + body: JsonObject; + provenance: JsonObject; + confidence: number; + last_confirmed_at: string | null; + supersedes_object_id: string | null; + superseded_by_object_id: string | null; + created_at: string; + updated_at: string; +}; + +export type ContinuityCorrectionEvent = { + id: string; + continuity_object_id: string; + action: ContinuityCorrectionAction; + reason: string | null; + before_snapshot: JsonObject; + after_snapshot: JsonObject; + payload: JsonObject; + created_at: string; +}; + +export type ContinuityReviewQueueSummary = { + status: ContinuityReviewStatusFilter; + limit: number; + returned_count: number; + total_count: number; + order: string[]; +}; + +export type ContinuityReviewDetail = { + continuity_object: ContinuityReviewObject; + correction_events: ContinuityCorrectionEvent[]; + supersession_chain: { + supersedes: ContinuityReviewObject | null; + superseded_by: ContinuityReviewObject | null; + }; +}; + +export type ContinuityCorrectionPayload = { + user_id: string; + action: ContinuityCorrectionAction; + reason?: string | null; + title?: string | null; + body?: JsonObject | null; + provenance?: JsonObject | null; + confidence?: number | null; + replacement_title?: string | null; + replacement_body?: JsonObject | null; + replacement_provenance?: JsonObject | null; + replacement_confidence?: number | null; +}; + +export type ContinuityCaptureInboxItem = { + capture_event: ContinuityCaptureEventRecord; + derived_object: ContinuityObjectRecord | null; +}; + +export type ContinuityCaptureInboxSummary = { + limit: number; + returned_count: number; + total_count: number; + derived_count: number; + triage_count: number; + order: string[]; +}; + +export type ContinuityCaptureCreatePayload = { + user_id: string; + raw_content: string; + explicit_signal?: ContinuityCaptureExplicitSignal | null; +}; + +export type ContinuityRecallScopeKind = "thread" | "task" | "project" | "person"; +export type ContinuityRecallFreshnessPosture = "fresh" | "aging" | "stale" | "superseded" | "unknown"; +export type ContinuityRecallProvenancePosture = "strong" | "partial" | "weak" | "missing"; +export type ContinuityRecallSupersessionPosture = "current" | "historical" | "superseded" | "deleted"; + +export type ContinuityRecallScopeMatch = { + kind: ContinuityRecallScopeKind; + value: string; +}; + +export type ContinuityRecallProvenanceReference = { + source_kind: string; + source_id: string; +}; + +export type ContinuityRecallOrdering = { + scope_match_count: number; + query_term_match_count: number; + confirmation_rank: number; + freshness_posture: ContinuityRecallFreshnessPosture; + freshness_rank: number; + provenance_posture: ContinuityRecallProvenancePosture; + provenance_rank: number; + supersession_posture: ContinuityRecallSupersessionPosture; + supersession_rank: number; + posture_rank: number; + lifecycle_rank: number; + confidence: number; +}; + +export type ContinuityRecallResult = { + id: string; + capture_event_id: string; + object_type: ContinuityObjectType; + status: string; + title: string; + body: JsonObject; + provenance: JsonObject; + confirmation_status: MemoryConfirmationStatus; + admission_posture: ContinuityCaptureAdmissionPosture; + confidence: number; + relevance: number; + last_confirmed_at: string | null; + supersedes_object_id: string | null; + superseded_by_object_id: string | null; + scope_matches: ContinuityRecallScopeMatch[]; + provenance_references: ContinuityRecallProvenanceReference[]; + ordering: ContinuityRecallOrdering; + created_at: string; + updated_at: string; +}; + +export type ContinuityRecallSummary = { + query: string | null; + filters: { + thread_id?: string; + task_id?: string; + project?: string; + person?: string; + since: string | null; + until: string | null; + }; + limit: number; + returned_count: number; + total_count: number; + order: string[]; +}; + +export type RetrievalEvaluationFixtureResult = { + fixture_id: string; + title: string; + query: string; + top_k: number; + expected_relevant_ids: string[]; + returned_ids: string[]; + hit_count: number; + precision_at_k: number; + top_result_id: string | null; + top_result_ordering: ContinuityRecallOrdering | null; +}; + +export type RetrievalEvaluationSummary = { + fixture_count: number; + evaluated_fixture_count: number; + passing_fixture_count: number; + precision_at_k_mean: number; + precision_at_1_mean: number; + precision_target: number; + status: "pass" | "fail"; + fixture_order: string[]; + result_order: string[]; +}; + +export type ContinuityResumptionEmptyState = { + is_empty: boolean; + message: string; +}; + +export type ContinuityResumptionSingleSection = { + item: ContinuityRecallResult | null; + empty_state: ContinuityResumptionEmptyState; +}; + +export type ContinuityResumptionListSection = { + items: ContinuityRecallResult[]; + summary: ResumptionBriefSectionSummary; + empty_state: ContinuityResumptionEmptyState; +}; + +export type ContinuityResumptionBrief = { + assembly_version: string; + scope: ContinuityRecallSummary["filters"]; + last_decision: ContinuityResumptionSingleSection; + open_loops: ContinuityResumptionListSection; + recent_changes: ContinuityResumptionListSection; + next_action: ContinuityResumptionSingleSection; + sources: string[]; +}; + +export type ContinuityOpenLoopPosture = "waiting_for" | "blocker" | "stale" | "next_action"; + +export type ContinuityOpenLoopReviewAction = "done" | "deferred" | "still_blocked"; + +export type ContinuityOpenLoopSectionSummary = { + limit: number; + returned_count: number; + total_count: number; + order: string[]; +}; + +export type ContinuityOpenLoopSection = { + items: ContinuityRecallResult[]; + summary: ContinuityOpenLoopSectionSummary; + empty_state: ContinuityResumptionEmptyState; +}; + +export type ContinuityOpenLoopDashboard = { + scope: ContinuityRecallSummary["filters"]; + waiting_for: ContinuityOpenLoopSection; + blocker: ContinuityOpenLoopSection; + stale: ContinuityOpenLoopSection; + next_action: ContinuityOpenLoopSection; + summary: { + limit: number; + total_count: number; + posture_order: ContinuityOpenLoopPosture[]; + item_order: string[]; + }; + sources: string[]; +}; + +export type ContinuityDailyBrief = { + assembly_version: string; + scope: ContinuityRecallSummary["filters"]; + waiting_for_highlights: ContinuityOpenLoopSection; + blocker_highlights: ContinuityOpenLoopSection; + stale_items: ContinuityOpenLoopSection; + next_suggested_action: ContinuityResumptionSingleSection; + sources: string[]; +}; + +export type ContinuityWeeklyReview = { + assembly_version: string; + scope: ContinuityRecallSummary["filters"]; + rollup: { + total_count: number; + waiting_for_count: number; + blocker_count: number; + stale_count: number; + correction_recurrence_count: number; + freshness_drift_count: number; + next_action_count: number; + posture_order: ContinuityOpenLoopPosture[]; + }; + waiting_for: ContinuityOpenLoopSection; + blocker: ContinuityOpenLoopSection; + stale: ContinuityOpenLoopSection; + next_action: ContinuityOpenLoopSection; + sources: string[]; +}; + +export type ChiefOfStaffPriorityPosture = + | "urgent" + | "important" + | "waiting" + | "blocked" + | "stale" + | "defer"; + +export type ChiefOfStaffRecommendationConfidencePosture = "high" | "medium" | "low"; + +export type ChiefOfStaffRecommendedActionType = + | "execute_next_action" + | "progress_commitment" + | "follow_up_waiting_for" + | "unblock_blocker" + | "refresh_stale_item" + | "review_and_defer" + | "capture_new_priority"; + +export type ChiefOfStaffFollowThroughPosture = "overdue" | "stale_waiting_for" | "slipped_commitment"; + +export type ChiefOfStaffFollowThroughRecommendationAction = + | "nudge" + | "defer" + | "escalate" + | "close_loop_candidate"; + +export type ChiefOfStaffEscalationPosture = "watch" | "elevated" | "critical"; +export type ChiefOfStaffResumptionRecommendationAction = + | "execute_next_action" + | "progress_commitment" + | "follow_up_waiting_for" + | "unblock_blocker" + | "refresh_stale_item" + | "review_and_defer" + | "capture_new_priority" + | "nudge" + | "defer" + | "escalate" + | "close_loop_candidate" + | "review_scope"; + +export type ChiefOfStaffPriorityRankingInputs = { + posture: ChiefOfStaffPriorityPosture; + open_loop_posture: ContinuityOpenLoopPosture | null; + recency_rank: number | null; + age_hours_relative_to_latest: number; + recall_relevance: number; + scope_match_count: number; + query_term_match_count: number; + freshness_posture: ContinuityRecallFreshnessPosture; + provenance_posture: ContinuityRecallProvenancePosture; + supersession_posture: ContinuityRecallSupersessionPosture; +}; + +export type ChiefOfStaffPriorityTrustSignals = { + quality_gate_status: MemoryQualityGateStatus; + retrieval_status: RetrievalEvaluationSummary["status"]; + trust_confidence_cap: ChiefOfStaffRecommendationConfidencePosture; + downgraded_by_trust: boolean; + reason: string; +}; + +export type ChiefOfStaffPriorityRationale = { + reasons: string[]; + ranking_inputs: ChiefOfStaffPriorityRankingInputs; + provenance_references: ContinuityRecallProvenanceReference[]; + trust_signals: ChiefOfStaffPriorityTrustSignals; +}; + +export type ChiefOfStaffPriorityItem = { + rank: number; + id: string; + capture_event_id: string; + object_type: ContinuityObjectType; + status: string; + title: string; + priority_posture: ChiefOfStaffPriorityPosture; + confidence_posture: ChiefOfStaffRecommendationConfidencePosture; + confidence: number; + score: number; + provenance: JsonObject; + created_at: string; + updated_at: string; + rationale: ChiefOfStaffPriorityRationale; +}; + +export type ChiefOfStaffFollowThroughItem = { + rank: number; + id: string; + capture_event_id: string; + object_type: ContinuityObjectType; + status: string; + title: string; + current_priority_posture: ChiefOfStaffPriorityPosture; + follow_through_posture: ChiefOfStaffFollowThroughPosture; + recommendation_action: ChiefOfStaffFollowThroughRecommendationAction; + reason: string; + age_hours: number; + provenance_references: ContinuityRecallProvenanceReference[]; + created_at: string; + updated_at: string; +}; + +export type ChiefOfStaffEscalationPostureRecord = { + posture: ChiefOfStaffEscalationPosture; + reason: string; + total_follow_through_count: number; + nudge_count: number; + defer_count: number; + escalate_count: number; + close_loop_candidate_count: number; +}; + +export type ChiefOfStaffDraftFollowUpTargetMetadata = { + continuity_object_id: string | null; + capture_event_id: string | null; + object_type: ContinuityObjectType | null; + priority_posture: ChiefOfStaffPriorityPosture | null; + follow_through_posture: ChiefOfStaffFollowThroughPosture | null; + recommendation_action: ChiefOfStaffFollowThroughRecommendationAction | null; + thread_id: string | null; +}; + +export type ChiefOfStaffDraftFollowUpContent = { + subject: string; + body: string; +}; + +export type ChiefOfStaffDraftFollowUp = { + status: "drafted" | "none"; + mode: "draft_only"; + approval_required: boolean; + auto_send: boolean; + reason: string; + target_metadata: ChiefOfStaffDraftFollowUpTargetMetadata; + content: ChiefOfStaffDraftFollowUpContent; +}; + +export type ChiefOfStaffRecommendedNextAction = { + action_type: ChiefOfStaffRecommendedActionType; + title: string; + target_priority_id: string | null; + priority_posture: ChiefOfStaffPriorityPosture | null; + confidence_posture: ChiefOfStaffRecommendationConfidencePosture; + reason: string; + provenance_references: ContinuityRecallProvenanceReference[]; + deterministic_rank_key: string; +}; + +export type ChiefOfStaffPrioritySummary = { + limit: number; + returned_count: number; + total_count: number; + posture_order: ChiefOfStaffPriorityPosture[]; + order: string[]; + follow_through_posture_order: ChiefOfStaffFollowThroughPosture[]; + follow_through_item_order: string[]; + follow_through_total_count: number; + overdue_count: number; + stale_waiting_for_count: number; + slipped_commitment_count: number; + trust_confidence_posture: ChiefOfStaffRecommendationConfidencePosture; + trust_confidence_reason: string; + quality_gate_status: MemoryQualityGateStatus; + retrieval_status: RetrievalEvaluationSummary["status"]; + handoff_item_count: number; + handoff_item_order: string[]; + execution_posture_order: ChiefOfStaffExecutionPosture[]; + handoff_queue_total_count: number; + handoff_queue_ready_count: number; + handoff_queue_pending_approval_count: number; + handoff_queue_executed_count: number; + handoff_queue_stale_count: number; + handoff_queue_expired_count: number; + handoff_queue_state_order: ChiefOfStaffHandoffQueueLifecycleState[]; + handoff_queue_group_order: ChiefOfStaffHandoffQueueLifecycleState[]; + handoff_queue_item_order: string[]; + handoff_outcome_total_count: number; + handoff_outcome_latest_count: number; + handoff_outcome_executed_count: number; + handoff_outcome_ignored_count: number; + closure_quality_posture: ChiefOfStaffClosureQualityPosture; + stale_ignored_escalation_posture: ChiefOfStaffEscalationPosture; +}; + +export type ChiefOfStaffPreparationArtifactItem = { + rank: number; + id: string; + capture_event_id: string; + object_type: ContinuityObjectType; + status: string; + title: string; + reason: string; + confidence_posture: ChiefOfStaffRecommendationConfidencePosture; + provenance_references: ContinuityRecallProvenanceReference[]; + created_at: string; +}; + +export type ChiefOfStaffPreparationSectionSummary = { + limit: number; + returned_count: number; + total_count: number; + order: string[]; +}; + +export type ChiefOfStaffPreparationBrief = { + scope: ContinuityRecallSummary["filters"]; + context_items: ChiefOfStaffPreparationArtifactItem[]; + last_decision: ChiefOfStaffPreparationArtifactItem | null; + open_loops: ChiefOfStaffPreparationArtifactItem[]; + next_action: ChiefOfStaffPreparationArtifactItem | null; + confidence_posture: ChiefOfStaffRecommendationConfidencePosture; + confidence_reason: string; + summary: ChiefOfStaffPreparationSectionSummary; +}; + +export type ChiefOfStaffWhatChangedSummary = { + items: ChiefOfStaffPreparationArtifactItem[]; + confidence_posture: ChiefOfStaffRecommendationConfidencePosture; + confidence_reason: string; + summary: ChiefOfStaffPreparationSectionSummary; +}; + +export type ChiefOfStaffPrepChecklist = { + items: ChiefOfStaffPreparationArtifactItem[]; + confidence_posture: ChiefOfStaffRecommendationConfidencePosture; + confidence_reason: string; + summary: ChiefOfStaffPreparationSectionSummary; +}; + +export type ChiefOfStaffSuggestedTalkingPoints = { + items: ChiefOfStaffPreparationArtifactItem[]; + confidence_posture: ChiefOfStaffRecommendationConfidencePosture; + confidence_reason: string; + summary: ChiefOfStaffPreparationSectionSummary; +}; + +export type ChiefOfStaffResumptionSupervisionRecommendation = { + rank: number; + action: ChiefOfStaffResumptionRecommendationAction; + title: string; + reason: string; + confidence_posture: ChiefOfStaffRecommendationConfidencePosture; + target_priority_id: string | null; + provenance_references: ContinuityRecallProvenanceReference[]; +}; + +export type ChiefOfStaffResumptionSupervision = { + recommendations: ChiefOfStaffResumptionSupervisionRecommendation[]; + confidence_posture: ChiefOfStaffRecommendationConfidencePosture; + confidence_reason: string; + summary: ChiefOfStaffPreparationSectionSummary; +}; + +export type ChiefOfStaffRecommendationOutcome = "accept" | "defer" | "ignore" | "rewrite"; + +export type ChiefOfStaffWeeklyReviewGuidanceAction = "close" | "defer" | "escalate"; + +export type ChiefOfStaffPatternDriftPosture = "improving" | "stable" | "drifting" | "insufficient_signal"; +export type ChiefOfStaffActionHandoffSourceKind = + | "recommended_next_action" + | "follow_through" + | "prep_checklist" + | "weekly_review"; +export type ChiefOfStaffActionHandoffAction = + | ChiefOfStaffRecommendedActionType + | "nudge" + | "defer" + | "escalate" + | "close_loop_candidate" + | "review_scope" + | "weekly_review_close" + | "weekly_review_defer" + | "weekly_review_escalate"; +export type ChiefOfStaffExecutionPosture = "approval_bounded_artifact_only"; +export type ChiefOfStaffHandoffQueueLifecycleState = + | "ready" + | "pending_approval" + | "executed" + | "stale" + | "expired"; +export type ChiefOfStaffHandoffReviewAction = + | "mark_ready" + | "mark_pending_approval" + | "mark_executed" + | "mark_stale" + | "mark_expired"; +export type ChiefOfStaffHandoffOutcomeStatus = + | "reviewed" + | "approved" + | "rejected" + | "rewritten" + | "executed" + | "ignored" + | "expired"; +export type ChiefOfStaffClosureQualityPosture = "insufficient_signal" | "healthy" | "watch" | "critical"; + +export type ChiefOfStaffWeeklyReviewBrief = { + scope: ContinuityRecallSummary["filters"]; + rollup: ContinuityWeeklyReview["rollup"]; + guidance: Array<{ + rank: number; + action: ChiefOfStaffWeeklyReviewGuidanceAction; + signal_count: number; + rationale: string; + }>; + summary: { + guidance_order: ChiefOfStaffWeeklyReviewGuidanceAction[]; + guidance_item_order: string[]; + }; +}; + +export type ChiefOfStaffRecommendationOutcomeRecord = { + id: string; + capture_event_id: string; + outcome: ChiefOfStaffRecommendationOutcome; + recommendation_action_type: ChiefOfStaffRecommendedActionType; + recommendation_title: string; + rewritten_title: string | null; + target_priority_id: string | null; + rationale: string | null; + provenance_references: ContinuityRecallProvenanceReference[]; + created_at: string; + updated_at: string; +}; + +export type ChiefOfStaffRecommendationOutcomeSection = { + items: ChiefOfStaffRecommendationOutcomeRecord[]; + summary: { + returned_count: number; + total_count: number; + outcome_counts: Record<ChiefOfStaffRecommendationOutcome, number>; + order: string[]; + }; +}; + +export type ChiefOfStaffOutcomeHotspotRecord = { + key: string; + count: number; +}; + +export type ChiefOfStaffPriorityLearningSummary = { + total_count: number; + accept_count: number; + defer_count: number; + ignore_count: number; + rewrite_count: number; + acceptance_rate: number; + override_rate: number; + defer_hotspots: ChiefOfStaffOutcomeHotspotRecord[]; + ignore_hotspots: ChiefOfStaffOutcomeHotspotRecord[]; + priority_shift_explanation: string; + hotspot_order: string[]; +}; + +export type ChiefOfStaffPatternDriftSummary = { + posture: ChiefOfStaffPatternDriftPosture; + reason: string; + supporting_signals: string[]; +}; + +export type ChiefOfStaffActionHandoffRequestTarget = { + thread_id: string | null; + task_id: string | null; + project: string | null; + person: string | null; +}; + +export type ChiefOfStaffActionHandoffRequestDraft = { + action: string; + scope: string; + domain_hint: string | null; + risk_hint: string | null; + attributes: JsonObject; +}; + +export type ChiefOfStaffActionHandoffTaskDraft = { + status: "draft"; + mode: "governed_request_draft"; + approval_required: boolean; + auto_execute: boolean; + source_handoff_item_id: string; + title: string; + summary: string; + target: ChiefOfStaffActionHandoffRequestTarget; + request: ChiefOfStaffActionHandoffRequestDraft; + rationale: string; + provenance_references: ContinuityRecallProvenanceReference[]; +}; + +export type ChiefOfStaffActionHandoffApprovalDraft = { + status: "draft_only"; + mode: "approval_request_draft"; + decision: "approval_required"; + approval_required: boolean; + auto_submit: boolean; + source_handoff_item_id: string; + request: ChiefOfStaffActionHandoffRequestDraft; + reason: string; + required_checks: string[]; + provenance_references: ContinuityRecallProvenanceReference[]; +}; + +export type ChiefOfStaffActionHandoffItem = { + rank: number; + handoff_item_id: string; + source_kind: ChiefOfStaffActionHandoffSourceKind; + source_reference_id: string | null; + title: string; + recommendation_action: ChiefOfStaffActionHandoffAction; + priority_posture: ChiefOfStaffPriorityPosture | null; + confidence_posture: ChiefOfStaffRecommendationConfidencePosture; + rationale: string; + provenance_references: ContinuityRecallProvenanceReference[]; + score: number; + task_draft: ChiefOfStaffActionHandoffTaskDraft; + approval_draft: ChiefOfStaffActionHandoffApprovalDraft; +}; + +export type ChiefOfStaffActionHandoffBrief = { + summary: string; + confidence_posture: ChiefOfStaffRecommendationConfidencePosture; + non_autonomous_guarantee: string; + order: string[]; + source_order: ChiefOfStaffActionHandoffSourceKind[]; + provenance_references: ContinuityRecallProvenanceReference[]; +}; + +export type ChiefOfStaffExecutionPostureRecord = { + posture: ChiefOfStaffExecutionPosture; + approval_required: boolean; + autonomous_execution: boolean; + external_side_effects_allowed: boolean; + default_routing_decision: "approval_required"; + required_operator_actions: string[]; + non_autonomous_guarantee: string; + reason: string; +}; + +export type ChiefOfStaffExecutionRouteTarget = + | "task_workflow_draft" + | "approval_workflow_draft" + | "follow_up_draft_only"; + +export type ChiefOfStaffExecutionRoutingTransition = "routed" | "reaffirmed"; + +export type ChiefOfStaffExecutionRoutingAuditRecord = { + id: string; + capture_event_id: string; + handoff_item_id: string; + route_target: ChiefOfStaffExecutionRouteTarget; + transition: ChiefOfStaffExecutionRoutingTransition; + previously_routed: boolean; + route_state: boolean; + reason: string; + note: string | null; + provenance_references: ContinuityRecallProvenanceReference[]; + created_at: string; + updated_at: string; +}; + +export type ChiefOfStaffRoutedHandoffItem = { + handoff_rank: number; + handoff_item_id: string; + title: string; + source_kind: ChiefOfStaffActionHandoffSourceKind; + recommendation_action: ChiefOfStaffActionHandoffAction; + route_target_order: ChiefOfStaffExecutionRouteTarget[]; + available_route_targets: ChiefOfStaffExecutionRouteTarget[]; + routed_targets: ChiefOfStaffExecutionRouteTarget[]; + is_routed: boolean; + task_workflow_draft_routed: boolean; + approval_workflow_draft_routed: boolean; + follow_up_draft_only_routed: boolean; + follow_up_draft_only_applicable: boolean; + task_draft: ChiefOfStaffActionHandoffTaskDraft; + approval_draft: ChiefOfStaffActionHandoffApprovalDraft; + follow_up_draft?: ChiefOfStaffDraftFollowUp; + last_routing_transition: ChiefOfStaffExecutionRoutingAuditRecord | null; +}; + +export type ChiefOfStaffExecutionRoutingSummary = { + total_handoff_count: number; + routed_handoff_count: number; + unrouted_handoff_count: number; + task_workflow_draft_count: number; + approval_workflow_draft_count: number; + follow_up_draft_only_count: number; + route_target_order: ChiefOfStaffExecutionRouteTarget[]; + routed_item_order: string[]; + audit_order: string[]; + transition_order: ChiefOfStaffExecutionRoutingTransition[]; + approval_required: boolean; + non_autonomous_guarantee: string; + reason: string; +}; + +export type ChiefOfStaffExecutionReadinessPosture = { + posture: "approval_required_draft_only"; + approval_required: boolean; + autonomous_execution: boolean; + external_side_effects_allowed: boolean; + approval_path_visible: boolean; + route_target_order: ChiefOfStaffExecutionRouteTarget[]; + required_route_targets: ChiefOfStaffExecutionRouteTarget[]; + transition_order: ChiefOfStaffExecutionRoutingTransition[]; + non_autonomous_guarantee: string; + reason: string; +}; + +export type ChiefOfStaffHandoffReviewActionRecord = { + id: string; + capture_event_id: string; + handoff_item_id: string; + review_action: ChiefOfStaffHandoffReviewAction; + previous_lifecycle_state: ChiefOfStaffHandoffQueueLifecycleState | null; + next_lifecycle_state: ChiefOfStaffHandoffQueueLifecycleState; + reason: string; + note: string | null; + provenance_references: ContinuityRecallProvenanceReference[]; + created_at: string; + updated_at: string; +}; + +export type ChiefOfStaffHandoffQueueItem = { + queue_rank: number; + handoff_rank: number; + handoff_item_id: string; + lifecycle_state: ChiefOfStaffHandoffQueueLifecycleState; + state_reason: string; + source_kind: ChiefOfStaffActionHandoffSourceKind; + source_reference_id: string | null; + title: string; + recommendation_action: ChiefOfStaffActionHandoffAction; + priority_posture: ChiefOfStaffPriorityPosture | null; + confidence_posture: ChiefOfStaffRecommendationConfidencePosture; + score: number; + age_hours_relative_to_latest: number | null; + review_action_order: ChiefOfStaffHandoffReviewAction[]; + available_review_actions: ChiefOfStaffHandoffReviewAction[]; + last_review_action: ChiefOfStaffHandoffReviewActionRecord | null; + provenance_references: ContinuityRecallProvenanceReference[]; +}; + +export type ChiefOfStaffHandoffQueueGroup = { + items: ChiefOfStaffHandoffQueueItem[]; + summary: { + lifecycle_state: ChiefOfStaffHandoffQueueLifecycleState; + returned_count: number; + total_count: number; + order: string[]; + }; + empty_state: { + is_empty: boolean; + message: string; + }; +}; + +export type ChiefOfStaffHandoffQueueGroups = { + ready: ChiefOfStaffHandoffQueueGroup; + pending_approval: ChiefOfStaffHandoffQueueGroup; + executed: ChiefOfStaffHandoffQueueGroup; + stale: ChiefOfStaffHandoffQueueGroup; + expired: ChiefOfStaffHandoffQueueGroup; +}; + +export type ChiefOfStaffHandoffQueueSummary = { + total_count: number; + ready_count: number; + pending_approval_count: number; + executed_count: number; + stale_count: number; + expired_count: number; + state_order: ChiefOfStaffHandoffQueueLifecycleState[]; + group_order: ChiefOfStaffHandoffQueueLifecycleState[]; + item_order: string[]; + review_action_order: ChiefOfStaffHandoffReviewAction[]; +}; + +export type ChiefOfStaffHandoffOutcomeRecord = { + id: string; + capture_event_id: string; + handoff_item_id: string; + outcome_status: ChiefOfStaffHandoffOutcomeStatus; + previous_outcome_status: ChiefOfStaffHandoffOutcomeStatus | null; + is_latest_outcome: boolean; + reason: string; + note: string | null; + provenance_references: ContinuityRecallProvenanceReference[]; + created_at: string; + updated_at: string; +}; + +export type ChiefOfStaffHandoffOutcomeSummary = { + returned_count: number; + total_count: number; + latest_total_count: number; + status_counts: Record<ChiefOfStaffHandoffOutcomeStatus, number>; + latest_status_counts: Record<ChiefOfStaffHandoffOutcomeStatus, number>; + status_order: ChiefOfStaffHandoffOutcomeStatus[]; + order: string[]; +}; + +export type ChiefOfStaffClosureQualitySummary = { + posture: ChiefOfStaffClosureQualityPosture; + reason: string; + closed_loop_count: number; + unresolved_count: number; + rejected_count: number; + ignored_count: number; + expired_count: number; + closure_rate: number; + explanation: string; +}; + +export type ChiefOfStaffConversionSignalSummary = { + total_handoff_count: number; + latest_outcome_count: number; + executed_count: number; + approved_count: number; + reviewed_count: number; + rewritten_count: number; + rejected_count: number; + ignored_count: number; + expired_count: number; + recommendation_to_execution_conversion_rate: number; + recommendation_to_closure_conversion_rate: number; + capture_coverage_rate: number; + explanation: string; +}; + +export type ChiefOfStaffStaleIgnoredEscalationPosture = { + posture: ChiefOfStaffEscalationPosture; + reason: string; + stale_queue_count: number; + ignored_count: number; + expired_count: number; + trigger_count: number; + guidance_posture_explanation: string; + supporting_signals: string[]; +}; + +export type ChiefOfStaffPriorityBrief = { + assembly_version: string; + scope: ContinuityRecallSummary["filters"]; + ranked_items: ChiefOfStaffPriorityItem[]; + overdue_items: ChiefOfStaffFollowThroughItem[]; + stale_waiting_for_items: ChiefOfStaffFollowThroughItem[]; + slipped_commitments: ChiefOfStaffFollowThroughItem[]; + escalation_posture: ChiefOfStaffEscalationPostureRecord; + draft_follow_up: ChiefOfStaffDraftFollowUp; + recommended_next_action: ChiefOfStaffRecommendedNextAction; + preparation_brief: ChiefOfStaffPreparationBrief; + what_changed_summary: ChiefOfStaffWhatChangedSummary; + prep_checklist: ChiefOfStaffPrepChecklist; + suggested_talking_points: ChiefOfStaffSuggestedTalkingPoints; + resumption_supervision: ChiefOfStaffResumptionSupervision; + weekly_review_brief: ChiefOfStaffWeeklyReviewBrief; + recommendation_outcomes: ChiefOfStaffRecommendationOutcomeSection; + priority_learning_summary: ChiefOfStaffPriorityLearningSummary; + pattern_drift_summary: ChiefOfStaffPatternDriftSummary; + action_handoff_brief: ChiefOfStaffActionHandoffBrief; + handoff_items: ChiefOfStaffActionHandoffItem[]; + handoff_queue_summary: ChiefOfStaffHandoffQueueSummary; + handoff_queue_groups: ChiefOfStaffHandoffQueueGroups; + handoff_review_actions: ChiefOfStaffHandoffReviewActionRecord[]; + handoff_outcome_summary: ChiefOfStaffHandoffOutcomeSummary; + handoff_outcomes: ChiefOfStaffHandoffOutcomeRecord[]; + closure_quality_summary: ChiefOfStaffClosureQualitySummary; + conversion_signal_summary: ChiefOfStaffConversionSignalSummary; + stale_ignored_escalation_posture: ChiefOfStaffStaleIgnoredEscalationPosture; + execution_routing_summary: ChiefOfStaffExecutionRoutingSummary; + routed_handoff_items: ChiefOfStaffRoutedHandoffItem[]; + routing_audit_trail: ChiefOfStaffExecutionRoutingAuditRecord[]; + execution_readiness_posture: ChiefOfStaffExecutionReadinessPosture; + task_draft: ChiefOfStaffActionHandoffTaskDraft; + approval_draft: ChiefOfStaffActionHandoffApprovalDraft; + execution_posture: ChiefOfStaffExecutionPostureRecord; + summary: ChiefOfStaffPrioritySummary; + sources: string[]; +}; + +export type ChiefOfStaffRecommendationOutcomeCapturePayload = { + user_id: string; + outcome: ChiefOfStaffRecommendationOutcome; + recommendation_action_type: ChiefOfStaffRecommendedActionType; + recommendation_title: string; + rationale?: string | null; + rewritten_title?: string | null; + target_priority_id?: string | null; + thread_id?: string | null; + task_id?: string | null; + project?: string | null; + person?: string | null; +}; + +export type ChiefOfStaffRecommendationOutcomeCaptureResult = { + outcome: ChiefOfStaffRecommendationOutcomeRecord; + recommendation_outcomes: ChiefOfStaffRecommendationOutcomeSection; + priority_learning_summary: ChiefOfStaffPriorityLearningSummary; + pattern_drift_summary: ChiefOfStaffPatternDriftSummary; +}; + +export type ChiefOfStaffHandoffReviewActionCapturePayload = { + user_id: string; + handoff_item_id: string; + review_action: ChiefOfStaffHandoffReviewAction; + note?: string | null; + thread_id?: string | null; + task_id?: string | null; + project?: string | null; + person?: string | null; +}; + +export type ChiefOfStaffHandoffReviewActionCaptureResult = { + review_action: ChiefOfStaffHandoffReviewActionRecord; + handoff_queue_summary: ChiefOfStaffHandoffQueueSummary; + handoff_queue_groups: ChiefOfStaffHandoffQueueGroups; + handoff_review_actions: ChiefOfStaffHandoffReviewActionRecord[]; +}; + +export type ChiefOfStaffExecutionRoutingActionCapturePayload = { + user_id: string; + handoff_item_id: string; + route_target: ChiefOfStaffExecutionRouteTarget; + note?: string | null; + thread_id?: string | null; + task_id?: string | null; + project?: string | null; + person?: string | null; +}; + +export type ChiefOfStaffExecutionRoutingActionCaptureResult = { + routing_action: ChiefOfStaffExecutionRoutingAuditRecord; + execution_routing_summary: ChiefOfStaffExecutionRoutingSummary; + routed_handoff_items: ChiefOfStaffRoutedHandoffItem[]; + routing_audit_trail: ChiefOfStaffExecutionRoutingAuditRecord[]; + execution_readiness_posture: ChiefOfStaffExecutionReadinessPosture; +}; + +export type ChiefOfStaffHandoffOutcomeCapturePayload = { + user_id: string; + handoff_item_id: string; + outcome_status: ChiefOfStaffHandoffOutcomeStatus; + note?: string | null; + thread_id?: string | null; + task_id?: string | null; + project?: string | null; + person?: string | null; +}; + +export type ChiefOfStaffHandoffOutcomeCaptureResult = { + handoff_outcome: ChiefOfStaffHandoffOutcomeRecord; + handoff_outcome_summary: ChiefOfStaffHandoffOutcomeSummary; + handoff_outcomes: ChiefOfStaffHandoffOutcomeRecord[]; + closure_quality_summary: ChiefOfStaffClosureQualitySummary; + conversion_signal_summary: ChiefOfStaffConversionSignalSummary; + stale_ignored_escalation_posture: ChiefOfStaffStaleIgnoredEscalationPosture; +}; + +export type ContinuityOpenLoopReviewActionPayload = { + user_id: string; + action: ContinuityOpenLoopReviewAction; + note?: string | null; +}; + +export type ContinuityOpenLoopReviewActionResult = { + continuity_object: ContinuityReviewObject; + correction_event: ContinuityCorrectionEvent; + review_action: ContinuityOpenLoopReviewAction; + lifecycle_outcome: string; +}; + +export type OpenLoopCreatePayload = { + user_id: string; + memory_id?: string | null; + title: string; + due_at?: string | null; +}; + +export type OpenLoopStatusUpdatePayload = { + user_id: string; + status: OpenLoopStatus; + resolution_note?: string | null; +}; + +export type ApprovalRequestPayload = { + user_id: string; + thread_id: string; + tool_id: string; + action: string; + scope: string; + domain_hint: string | null; + risk_hint: string | null; + attributes: Record<string, unknown>; +}; + +export type ApprovalRequestResponse = { + request: GovernedRequestRecord; + decision: string; + tool: ToolRecord; + reasons: ToolRoutingReason[]; + task: TaskItem; + approval: ApprovalItem | null; + routing_trace: { + trace_id: string; + trace_event_count: number; + }; + trace: { + trace_id: string; + trace_event_count: number; + }; +}; + +export type ThreadWorkflowState = { + approval: ApprovalItem | null; + task: TaskItem | null; + execution: ToolExecutionItem | null; +}; + +export type ApprovalResolutionResponse = { + approval: ApprovalItem; + trace: { + trace_id: string; + trace_event_count: number; + }; +}; + +export type PersistedMemoryRecord = { + id: string; + user_id: string; + memory_key: string; + value: unknown; + status: "active" | "deleted"; + source_event_ids: string[]; + created_at: string; + updated_at: string; + deleted_at: string | null; +}; + +export type PersistedMemoryRevisionRecord = { + id: string; + user_id: string; + memory_id: string; + sequence_no: number; + action: "NOOP" | "ADD" | "UPDATE" | "DELETE"; + memory_key: string; + previous_value: unknown | null; + new_value: unknown | null; + source_event_ids: string[]; + candidate: JsonObject; + created_at: string; +}; + +export type MemoryAdmissionResponse = { + decision: "NOOP" | "ADD" | "UPDATE" | "DELETE"; + reason: string; + memory: PersistedMemoryRecord | null; + revision: PersistedMemoryRevisionRecord | null; + open_loop?: OpenLoopRecord | null; +}; + +export type AssistantResponsePayload = { + user_id: string; + thread_id: string; + message: string; + max_sessions?: number; + max_events?: number; + max_memories?: number; + max_entities?: number; + max_entity_edges?: number; +}; + +export type AssistantResponseTrace = { + compile_trace_id: string; + compile_trace_event_count: number; + response_trace_id: string; + response_trace_event_count: number; +}; + +export type AssistantResponseSuccess = { + assistant: { + event_id: string; + sequence_no: number; + text: string; + model_provider: string; + model: string; + }; + trace: AssistantResponseTrace; +}; + +export type RequestHistoryEntry = { + id: string; + submittedAt: string; + source: ApiSource; + threadId: string; + toolId: string; + toolName: string; + action: string; + scope: string; + domainHint: string | null; + riskHint: string | null; + attributes: Record<string, unknown>; + decision: string; + taskId: string; + taskStatus: string; + approvalId: string | null; + approvalStatus: string | null; + summary: string; + reasons: string[]; + trace: { + routingTraceId: string; + routingTraceEventCount: number; + requestTraceId: string; + requestTraceEventCount: number; + }; +}; + +export type ResponseHistoryEntry = { + id: string; + submittedAt: string; + source: ApiSource; + threadId: string; + message: string; + assistantText: string; + assistantEventId: string; + assistantSequenceNo: number; + modelProvider: string; + model: string; + summary: string; + trace: { + compileTraceId: string; + compileTraceEventCount: number; + responseTraceId: string; + responseTraceEventCount: number; + }; +}; + +export class ApiError extends Error { + status: number; + + constructor(message: string, status: number) { + super(message); + this.name = "ApiError"; + this.status = status; + } +} + +function readEnv(publicValue: string | undefined, serverValue: string | undefined) { + if (typeof window !== "undefined") { + return publicValue ?? ""; + } + + return publicValue ?? serverValue ?? ""; +} + +export function getApiConfig(): ApiConfig { + return { + apiBaseUrl: readEnv( + process.env.NEXT_PUBLIC_ALICEBOT_API_BASE_URL, + process.env.ALICEBOT_API_BASE_URL, + ), + userId: readEnv(process.env.NEXT_PUBLIC_ALICEBOT_USER_ID, process.env.ALICEBOT_USER_ID), + defaultThreadId: readEnv( + process.env.NEXT_PUBLIC_ALICEBOT_THREAD_ID, + process.env.ALICEBOT_THREAD_ID, + ), + defaultToolId: readEnv(process.env.NEXT_PUBLIC_ALICEBOT_TOOL_ID, process.env.ALICEBOT_TOOL_ID), + }; +} + +export function hasLiveApiConfig(config: Pick<ApiConfig, "apiBaseUrl" | "userId">) { + return Boolean(config.apiBaseUrl && config.userId); +} + +export function combinePageModes(...modes: Array<ApiSource | null | undefined>): PageDataMode { + const presentModes = modes.filter(Boolean) as ApiSource[]; + if (presentModes.length === 0) { + return "fixture"; + } + + const uniqueModes = Array.from(new Set(presentModes)); + if (uniqueModes.length === 1) { + return uniqueModes[0]; + } + + return "mixed"; +} + +export function pageModeLabel(mode: PageDataMode) { + if (mode === "live") { + return "Live API"; + } + + if (mode === "mixed") { + return "Mixed fallback"; + } + + return "Fixture-backed"; +} + +function compareIsoDatesDesc(left: string, right: string) { + return new Date(right).getTime() - new Date(left).getTime(); +} + +function pickLatestApproval(items: ApprovalItem[]) { + return [...items].sort((left, right) => compareIsoDatesDesc(left.created_at, right.created_at))[0] ?? null; +} + +function pickLatestTask(items: TaskItem[], approval: ApprovalItem | null) { + if (approval) { + const linkedTask = + [...items] + .filter((item) => item.latest_approval_id === approval.id) + .sort((left, right) => { + const updatedDelta = compareIsoDatesDesc(left.updated_at, right.updated_at); + if (updatedDelta !== 0) { + return updatedDelta; + } + + return compareIsoDatesDesc(left.created_at, right.created_at); + })[0] ?? null; + + if (linkedTask) { + return linkedTask; + } + } + + return [...items].sort((left, right) => { + const updatedDelta = compareIsoDatesDesc(left.updated_at, right.updated_at); + if (updatedDelta !== 0) { + return updatedDelta; + } + + return compareIsoDatesDesc(left.created_at, right.created_at); + })[0] ?? null; +} + +function pickExplicitlyLinkedExecution( + items: ToolExecutionItem[], + task: TaskItem | null, + approval: ApprovalItem | null, +) { + if (task?.latest_execution_id) { + return items.find((item) => item.id === task.latest_execution_id) ?? null; + } + + if (approval) { + return ( + [...items] + .filter((item) => item.approval_id === approval.id) + .sort((left, right) => compareIsoDatesDesc(left.executed_at, right.executed_at))[0] ?? null + ); + } + + return null; +} + +export function deriveThreadWorkflowState( + threadId: string, + approvals: ApprovalItem[], + tasks: TaskItem[], + executions: ToolExecutionItem[], +): ThreadWorkflowState { + const threadApprovals = approvals.filter((item) => item.thread_id === threadId); + const approval = pickLatestApproval(threadApprovals); + const threadTasks = tasks.filter((item) => item.thread_id === threadId); + const task = pickLatestTask(threadTasks, approval); + const threadExecutions = executions.filter((item) => item.thread_id === threadId); + const execution = pickExplicitlyLinkedExecution(threadExecutions, task, approval); + + return { + approval, + task, + execution, + }; +} + +export function shouldExpectThreadExecutionReview( + approval: ApprovalItem | null, + task: TaskItem | null, +) { + const normalizedApprovalStatus = approval?.status.trim().toLowerCase() ?? ""; + const normalizedTaskStatus = task?.status.trim().toLowerCase() ?? ""; + + return Boolean( + task?.latest_execution_id || + ["approved", "executed", "completed"].includes(normalizedApprovalStatus) || + ["executed", "completed"].includes(normalizedTaskStatus), + ); +} + +function buildApiUrl( + apiBaseUrl: string, + path: string, + query?: Record<string, string | undefined>, +) { + const url = new URL(path, `${apiBaseUrl.replace(/\/$/, "")}/`); + for (const [key, value] of Object.entries(query ?? {})) { + if (value) { + url.searchParams.set(key, value); + } + } + return url.toString(); +} + +async function requestJson<T>( + apiBaseUrl: string, + path: string, + init?: RequestInit, + query?: Record<string, string | undefined>, +): Promise<T> { + const response = await fetch(buildApiUrl(apiBaseUrl, path, query), { + cache: "no-store", + ...init, + headers: { + "Content-Type": "application/json", + ...(init?.headers ?? {}), + }, + }); + + const payload = (await response.json().catch(() => null)) as { detail?: string } | null; + if (!response.ok) { + throw new ApiError(payload?.detail ?? "Request failed", response.status); + } + + return payload as T; +} + +export function submitApprovalRequest( + apiBaseUrl: string, + payload: ApprovalRequestPayload, +) { + return requestJson<ApprovalRequestResponse>(apiBaseUrl, "/v0/approvals/requests", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export function submitAssistantResponse( + apiBaseUrl: string, + payload: AssistantResponsePayload, +) { + return requestJson<AssistantResponseSuccess>(apiBaseUrl, "/v0/responses", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export function createThread(apiBaseUrl: string, payload: ThreadCreatePayload) { + return requestJson<{ thread: ThreadItem }>(apiBaseUrl, "/v0/threads", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export function listThreads(apiBaseUrl: string, userId: string) { + return requestJson<{ items: ThreadItem[]; summary: ThreadListSummary }>( + apiBaseUrl, + "/v0/threads", + undefined, + { user_id: userId }, + ); +} + +export function listAgentProfiles(apiBaseUrl: string) { + return requestJson<{ items: AgentProfileItem[]; summary: AgentProfileListSummary }>( + apiBaseUrl, + "/v0/agent-profiles", + ); +} + +export function getThreadDetail(apiBaseUrl: string, threadId: string, userId: string) { + return requestJson<{ thread: ThreadItem }>( + apiBaseUrl, + `/v0/threads/${threadId}`, + undefined, + { user_id: userId }, + ); +} + +export function getThreadSessions(apiBaseUrl: string, threadId: string, userId: string) { + return requestJson<{ items: ThreadSessionItem[]; summary: ThreadSessionListSummary }>( + apiBaseUrl, + `/v0/threads/${threadId}/sessions`, + undefined, + { user_id: userId }, + ); +} + +export function getThreadEvents(apiBaseUrl: string, threadId: string, userId: string) { + return requestJson<{ items: ThreadEventItem[]; summary: ThreadEventListSummary }>( + apiBaseUrl, + `/v0/threads/${threadId}/events`, + undefined, + { user_id: userId }, + ); +} + +export function getThreadResumptionBrief( + apiBaseUrl: string, + threadId: string, + userId: string, + options?: { + maxEvents?: number; + maxOpenLoops?: number; + maxMemories?: number; + }, +) { + return requestJson<{ brief: ResumptionBrief }>( + apiBaseUrl, + `/v0/threads/${threadId}/resumption-brief`, + undefined, + { + user_id: userId, + max_events: + typeof options?.maxEvents === "number" ? String(Math.max(0, options.maxEvents)) : undefined, + max_open_loops: + typeof options?.maxOpenLoops === "number" + ? String(Math.max(0, options.maxOpenLoops)) + : undefined, + max_memories: + typeof options?.maxMemories === "number" ? String(Math.max(0, options.maxMemories)) : undefined, + }, + ); +} + +export function queryContinuityRecall( + apiBaseUrl: string, + userId: string, + options?: { + query?: string; + threadId?: string; + taskId?: string; + project?: string; + person?: string; + since?: string; + until?: string; + limit?: number; + }, +) { + const query = options?.query?.trim(); + const threadId = options?.threadId?.trim(); + const taskId = options?.taskId?.trim(); + const project = options?.project?.trim(); + const person = options?.person?.trim(); + const since = options?.since?.trim(); + const until = options?.until?.trim(); + const limit = + typeof options?.limit === "number" && Number.isFinite(options.limit) && options.limit > 0 + ? String(Math.trunc(options.limit)) + : undefined; + + return requestJson<{ items: ContinuityRecallResult[]; summary: ContinuityRecallSummary }>( + apiBaseUrl, + "/v0/continuity/recall", + undefined, + { + user_id: userId, + query: query || undefined, + thread_id: threadId || undefined, + task_id: taskId || undefined, + project: project || undefined, + person: person || undefined, + since: since || undefined, + until: until || undefined, + limit, + }, + ); +} + +export function getContinuityRetrievalEvaluation(apiBaseUrl: string, userId: string) { + return requestJson<{ fixtures: RetrievalEvaluationFixtureResult[]; summary: RetrievalEvaluationSummary }>( + apiBaseUrl, + "/v0/continuity/retrieval-evaluation", + undefined, + { + user_id: userId, + }, + ); +} + +export function getContinuityResumptionBrief( + apiBaseUrl: string, + userId: string, + options?: { + query?: string; + threadId?: string; + taskId?: string; + project?: string; + person?: string; + since?: string; + until?: string; + maxRecentChanges?: number; + maxOpenLoops?: number; + }, +) { + const query = options?.query?.trim(); + const threadId = options?.threadId?.trim(); + const taskId = options?.taskId?.trim(); + const project = options?.project?.trim(); + const person = options?.person?.trim(); + const since = options?.since?.trim(); + const until = options?.until?.trim(); + const maxRecentChanges = + typeof options?.maxRecentChanges === "number" && + Number.isFinite(options.maxRecentChanges) && + options.maxRecentChanges >= 0 + ? String(Math.trunc(options.maxRecentChanges)) + : undefined; + const maxOpenLoops = + typeof options?.maxOpenLoops === "number" && + Number.isFinite(options.maxOpenLoops) && + options.maxOpenLoops >= 0 + ? String(Math.trunc(options.maxOpenLoops)) + : undefined; + + return requestJson<{ brief: ContinuityResumptionBrief }>( + apiBaseUrl, + "/v0/continuity/resumption-brief", + undefined, + { + user_id: userId, + query: query || undefined, + thread_id: threadId || undefined, + task_id: taskId || undefined, + project: project || undefined, + person: person || undefined, + since: since || undefined, + until: until || undefined, + max_recent_changes: maxRecentChanges, + max_open_loops: maxOpenLoops, + }, + ); +} + +export function getChiefOfStaffPriorityBrief( + apiBaseUrl: string, + userId: string, + options?: { + query?: string; + threadId?: string; + taskId?: string; + project?: string; + person?: string; + since?: string; + until?: string; + limit?: number; + }, +) { + const query = options?.query?.trim(); + const threadId = options?.threadId?.trim(); + const taskId = options?.taskId?.trim(); + const project = options?.project?.trim(); + const person = options?.person?.trim(); + const since = options?.since?.trim(); + const until = options?.until?.trim(); + const limit = + typeof options?.limit === "number" && Number.isFinite(options.limit) && options.limit >= 0 + ? String(Math.trunc(options.limit)) + : undefined; + + return requestJson<{ brief: ChiefOfStaffPriorityBrief }>( + apiBaseUrl, + "/v0/chief-of-staff", + undefined, + { + user_id: userId, + query: query || undefined, + thread_id: threadId || undefined, + task_id: taskId || undefined, + project: project || undefined, + person: person || undefined, + since: since || undefined, + until: until || undefined, + limit, + }, + ); +} + +export function captureChiefOfStaffRecommendationOutcome( + apiBaseUrl: string, + payload: ChiefOfStaffRecommendationOutcomeCapturePayload, +) { + return requestJson<ChiefOfStaffRecommendationOutcomeCaptureResult>( + apiBaseUrl, + "/v0/chief-of-staff/recommendation-outcomes", + { + method: "POST", + body: JSON.stringify(payload), + }, + ); +} + +export function captureChiefOfStaffHandoffReviewAction( + apiBaseUrl: string, + payload: ChiefOfStaffHandoffReviewActionCapturePayload, +) { + return requestJson<ChiefOfStaffHandoffReviewActionCaptureResult>( + apiBaseUrl, + "/v0/chief-of-staff/handoff-review-actions", + { + method: "POST", + body: JSON.stringify(payload), + }, + ); +} + +export function captureChiefOfStaffExecutionRoutingAction( + apiBaseUrl: string, + payload: ChiefOfStaffExecutionRoutingActionCapturePayload, +) { + return requestJson<ChiefOfStaffExecutionRoutingActionCaptureResult>( + apiBaseUrl, + "/v0/chief-of-staff/execution-routing-actions", + { + method: "POST", + body: JSON.stringify(payload), + }, + ); +} + +export function captureChiefOfStaffHandoffOutcome( + apiBaseUrl: string, + payload: ChiefOfStaffHandoffOutcomeCapturePayload, +) { + return requestJson<ChiefOfStaffHandoffOutcomeCaptureResult>( + apiBaseUrl, + "/v0/chief-of-staff/handoff-outcomes", + { + method: "POST", + body: JSON.stringify(payload), + }, + ); +} + +export function getContinuityOpenLoopDashboard( + apiBaseUrl: string, + userId: string, + options?: { + query?: string; + threadId?: string; + taskId?: string; + project?: string; + person?: string; + since?: string; + until?: string; + limit?: number; + }, +) { + const query = options?.query?.trim(); + const threadId = options?.threadId?.trim(); + const taskId = options?.taskId?.trim(); + const project = options?.project?.trim(); + const person = options?.person?.trim(); + const since = options?.since?.trim(); + const until = options?.until?.trim(); + const limit = + typeof options?.limit === "number" && Number.isFinite(options.limit) && options.limit >= 0 + ? String(Math.trunc(options.limit)) + : undefined; + + return requestJson<{ dashboard: ContinuityOpenLoopDashboard }>( + apiBaseUrl, + "/v0/continuity/open-loops", + undefined, + { + user_id: userId, + query: query || undefined, + thread_id: threadId || undefined, + task_id: taskId || undefined, + project: project || undefined, + person: person || undefined, + since: since || undefined, + until: until || undefined, + limit, + }, + ); +} + +export function getContinuityDailyBrief( + apiBaseUrl: string, + userId: string, + options?: { + query?: string; + threadId?: string; + taskId?: string; + project?: string; + person?: string; + since?: string; + until?: string; + limit?: number; + }, +) { + const query = options?.query?.trim(); + const threadId = options?.threadId?.trim(); + const taskId = options?.taskId?.trim(); + const project = options?.project?.trim(); + const person = options?.person?.trim(); + const since = options?.since?.trim(); + const until = options?.until?.trim(); + const limit = + typeof options?.limit === "number" && Number.isFinite(options.limit) && options.limit >= 0 + ? String(Math.trunc(options.limit)) + : undefined; + + return requestJson<{ brief: ContinuityDailyBrief }>( + apiBaseUrl, + "/v0/continuity/daily-brief", + undefined, + { + user_id: userId, + query: query || undefined, + thread_id: threadId || undefined, + task_id: taskId || undefined, + project: project || undefined, + person: person || undefined, + since: since || undefined, + until: until || undefined, + limit, + }, + ); +} + +export function getContinuityWeeklyReview( + apiBaseUrl: string, + userId: string, + options?: { + query?: string; + threadId?: string; + taskId?: string; + project?: string; + person?: string; + since?: string; + until?: string; + limit?: number; + }, +) { + const query = options?.query?.trim(); + const threadId = options?.threadId?.trim(); + const taskId = options?.taskId?.trim(); + const project = options?.project?.trim(); + const person = options?.person?.trim(); + const since = options?.since?.trim(); + const until = options?.until?.trim(); + const limit = + typeof options?.limit === "number" && Number.isFinite(options.limit) && options.limit >= 0 + ? String(Math.trunc(options.limit)) + : undefined; + + return requestJson<{ review: ContinuityWeeklyReview }>( + apiBaseUrl, + "/v0/continuity/weekly-review", + undefined, + { + user_id: userId, + query: query || undefined, + thread_id: threadId || undefined, + task_id: taskId || undefined, + project: project || undefined, + person: person || undefined, + since: since || undefined, + until: until || undefined, + limit, + }, + ); +} + +export function applyContinuityOpenLoopReviewAction( + apiBaseUrl: string, + continuityObjectId: string, + payload: ContinuityOpenLoopReviewActionPayload, +) { + return requestJson<ContinuityOpenLoopReviewActionResult>( + apiBaseUrl, + `/v0/continuity/open-loops/${continuityObjectId}/review-action`, + { + method: "POST", + body: JSON.stringify(payload), + }, + ); +} + +export function listApprovals(apiBaseUrl: string, userId: string) { + return requestJson<{ items: ApprovalItem[]; summary: { total_count: number; order: string[] } }>( + apiBaseUrl, + "/v0/approvals", + undefined, + { user_id: userId }, + ); +} + +export function getApprovalDetail(apiBaseUrl: string, approvalId: string, userId: string) { + return requestJson<{ approval: ApprovalItem }>( + apiBaseUrl, + `/v0/approvals/${approvalId}`, + undefined, + { user_id: userId }, + ); +} + +export function resolveApproval( + apiBaseUrl: string, + approvalId: string, + action: "approve" | "reject", + userId: string, +) { + return requestJson<ApprovalResolutionResponse>(apiBaseUrl, `/v0/approvals/${approvalId}/${action}`, { + method: "POST", + body: JSON.stringify({ user_id: userId }), + }); +} + +export function listTasks(apiBaseUrl: string, userId: string) { + return requestJson<{ items: TaskItem[]; summary: { total_count: number; order: string[] } }>( + apiBaseUrl, + "/v0/tasks", + undefined, + { user_id: userId }, + ); +} + +export function getTaskDetail(apiBaseUrl: string, taskId: string, userId: string) { + return requestJson<{ task: TaskItem }>( + apiBaseUrl, + `/v0/tasks/${taskId}`, + undefined, + { user_id: userId }, + ); +} + +export function getTaskSteps(apiBaseUrl: string, taskId: string, userId: string) { + return requestJson<{ items: TaskStepItem[]; summary: TaskStepListSummary }>( + apiBaseUrl, + `/v0/tasks/${taskId}/steps`, + undefined, + { user_id: userId }, + ); +} + +export function listTaskRuns(apiBaseUrl: string, taskId: string, userId: string) { + return requestJson<{ items: TaskRunItem[]; summary: TaskRunListSummary }>( + apiBaseUrl, + `/v0/tasks/${taskId}/runs`, + undefined, + { user_id: userId }, + ); +} + +export function executeApproval( + apiBaseUrl: string, + approvalId: string, + userId: string, + taskRunId?: string | null, +) { + const payload: { user_id: string; task_run_id?: string } = { + user_id: userId, + }; + if (taskRunId !== null && taskRunId !== undefined) { + payload.task_run_id = taskRunId; + } + return requestJson<ApprovalExecutionResponse>(apiBaseUrl, `/v0/approvals/${approvalId}/execute`, { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export function admitMemory(apiBaseUrl: string, payload: MemoryAdmitPayload) { + return requestJson<MemoryAdmissionResponse>(apiBaseUrl, "/v0/memories/admit", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export function extractExplicitCommitments( + apiBaseUrl: string, + payload: ExtractExplicitCommitmentsPayload, +) { + return requestJson<ExplicitCommitmentExtractionResponse>( + apiBaseUrl, + "/v0/open-loops/extract-explicit-commitments", + { + method: "POST", + body: JSON.stringify(payload), + }, + ); +} + +export function captureExplicitSignals( + apiBaseUrl: string, + payload: ExtractExplicitSignalsPayload, +) { + return requestJson<ExplicitSignalCaptureResponse>( + apiBaseUrl, + "/v0/memories/capture-explicit-signals", + { + method: "POST", + body: JSON.stringify(payload), + }, + ); +} + +export function createContinuityCapture( + apiBaseUrl: string, + payload: ContinuityCaptureCreatePayload, +) { + return requestJson<{ capture: ContinuityCaptureInboxItem }>( + apiBaseUrl, + "/v0/continuity/captures", + { + method: "POST", + body: JSON.stringify(payload), + }, + ); +} + +export function listContinuityCaptures( + apiBaseUrl: string, + userId: string, + options?: { + limit?: number; + }, +) { + return requestJson<{ items: ContinuityCaptureInboxItem[]; summary: ContinuityCaptureInboxSummary }>( + apiBaseUrl, + "/v0/continuity/captures", + undefined, + { + user_id: userId, + limit: options?.limit ? String(options.limit) : undefined, + }, + ); +} + +export function getContinuityCaptureDetail( + apiBaseUrl: string, + captureEventId: string, + userId: string, +) { + return requestJson<{ capture: ContinuityCaptureInboxItem }>( + apiBaseUrl, + `/v0/continuity/captures/${captureEventId}`, + undefined, + { + user_id: userId, + }, + ); +} + +export function listContinuityReviewQueue( + apiBaseUrl: string, + userId: string, + options?: { + status?: ContinuityReviewStatusFilter; + limit?: number; + }, +) { + return requestJson<{ items: ContinuityReviewObject[]; summary: ContinuityReviewQueueSummary }>( + apiBaseUrl, + "/v0/continuity/review-queue", + undefined, + { + user_id: userId, + status: options?.status ?? "correction_ready", + limit: options?.limit ? String(options.limit) : undefined, + }, + ); +} + +export function getContinuityReviewDetail( + apiBaseUrl: string, + continuityObjectId: string, + userId: string, +) { + return requestJson<{ review: ContinuityReviewDetail }>( + apiBaseUrl, + `/v0/continuity/review-queue/${continuityObjectId}`, + undefined, + { + user_id: userId, + }, + ); +} + +export function applyContinuityCorrection( + apiBaseUrl: string, + continuityObjectId: string, + payload: ContinuityCorrectionPayload, +) { + return requestJson<{ + continuity_object: ContinuityReviewObject; + correction_event: ContinuityCorrectionEvent; + replacement_object: ContinuityReviewObject | null; + }>( + apiBaseUrl, + `/v0/continuity/review-queue/${continuityObjectId}/corrections`, + { + method: "POST", + body: JSON.stringify(payload), + }, + ); +} + +export function listOpenLoops( + apiBaseUrl: string, + userId: string, + options?: { + status?: OpenLoopStatusFilter; + limit?: number; + }, +) { + return requestJson<{ items: OpenLoopRecord[]; summary: OpenLoopListSummary }>( + apiBaseUrl, + "/v0/open-loops", + undefined, + { + user_id: userId, + status: options?.status, + limit: options?.limit ? String(options.limit) : undefined, + }, + ); +} + +export function getOpenLoopDetail(apiBaseUrl: string, openLoopId: string, userId: string) { + return requestJson<{ open_loop: OpenLoopRecord }>( + apiBaseUrl, + `/v0/open-loops/${openLoopId}`, + undefined, + { user_id: userId }, + ); +} + +export function createOpenLoop(apiBaseUrl: string, payload: OpenLoopCreatePayload) { + return requestJson<{ open_loop: OpenLoopRecord }>(apiBaseUrl, "/v0/open-loops", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export function updateOpenLoopStatus( + apiBaseUrl: string, + openLoopId: string, + payload: OpenLoopStatusUpdatePayload, +) { + return requestJson<{ open_loop: OpenLoopRecord }>( + apiBaseUrl, + `/v0/open-loops/${openLoopId}/status`, + { + method: "POST", + body: JSON.stringify(payload), + }, + ); +} + +export function listToolExecutions(apiBaseUrl: string, userId: string) { + return requestJson<{ items: ToolExecutionItem[]; summary: { total_count: number; order: string[] } }>( + apiBaseUrl, + "/v0/tool-executions", + undefined, + { user_id: userId }, + ); +} + +export function getToolExecution(apiBaseUrl: string, executionId: string, userId: string) { + return requestJson<{ execution: ToolExecutionItem }>( + apiBaseUrl, + `/v0/tool-executions/${executionId}`, + undefined, + { user_id: userId }, + ); +} + +export function listTraces(apiBaseUrl: string, userId: string) { + return requestJson<{ items: TraceReviewSummaryItem[]; summary: TraceReviewListSummary }>( + apiBaseUrl, + "/v0/traces", + undefined, + { user_id: userId }, + ); +} + +export function getTraceDetail(apiBaseUrl: string, traceId: string, userId: string) { + return requestJson<{ trace: TraceReviewItem }>( + apiBaseUrl, + `/v0/traces/${traceId}`, + undefined, + { user_id: userId }, + ); +} + +export function getTraceEvents(apiBaseUrl: string, traceId: string, userId: string) { + return requestJson<{ items: TraceReviewEventItem[]; summary: TraceReviewEventListSummary }>( + apiBaseUrl, + `/v0/traces/${traceId}/events`, + undefined, + { user_id: userId }, + ); +} + +export function connectGmailAccount( + apiBaseUrl: string, + payload: GmailAccountConnectPayload, +) { + return requestJson<{ account: GmailAccountRecord }>(apiBaseUrl, "/v0/gmail-accounts", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export function listGmailAccounts(apiBaseUrl: string, userId: string) { + return requestJson<{ items: GmailAccountRecord[]; summary: GmailAccountListSummary }>( + apiBaseUrl, + "/v0/gmail-accounts", + undefined, + { user_id: userId }, + ); +} + +export function getGmailAccountDetail(apiBaseUrl: string, gmailAccountId: string, userId: string) { + return requestJson<{ account: GmailAccountRecord }>( + apiBaseUrl, + `/v0/gmail-accounts/${gmailAccountId}`, + undefined, + { user_id: userId }, + ); +} + +export function ingestGmailMessage( + apiBaseUrl: string, + gmailAccountId: string, + providerMessageId: string, + payload: GmailMessageIngestPayload, +) { + return requestJson<GmailMessageIngestionResponse>( + apiBaseUrl, + `/v0/gmail-accounts/${gmailAccountId}/messages/${providerMessageId}/ingest`, + { + method: "POST", + body: JSON.stringify(payload), + }, + ); +} + +export function connectCalendarAccount( + apiBaseUrl: string, + payload: CalendarAccountConnectPayload, +) { + return requestJson<{ account: CalendarAccountRecord }>(apiBaseUrl, "/v0/calendar-accounts", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export function listCalendarAccounts(apiBaseUrl: string, userId: string) { + return requestJson<{ items: CalendarAccountRecord[]; summary: CalendarAccountListSummary }>( + apiBaseUrl, + "/v0/calendar-accounts", + undefined, + { user_id: userId }, + ); +} + +export function getCalendarAccountDetail( + apiBaseUrl: string, + calendarAccountId: string, + userId: string, +) { + return requestJson<{ account: CalendarAccountRecord }>( + apiBaseUrl, + `/v0/calendar-accounts/${calendarAccountId}`, + undefined, + { user_id: userId }, + ); +} + +export function listCalendarEvents( + apiBaseUrl: string, + calendarAccountId: string, + userId: string, + query?: CalendarEventListQuery, +) { + const limitValue = + typeof query?.limit === "number" && Number.isFinite(query.limit) && query.limit > 0 + ? String(Math.trunc(query.limit)) + : undefined; + const timeMin = query?.timeMin?.trim() ? query.timeMin.trim() : undefined; + const timeMax = query?.timeMax?.trim() ? query.timeMax.trim() : undefined; + + return requestJson<CalendarEventListResponse>( + apiBaseUrl, + `/v0/calendar-accounts/${calendarAccountId}/events`, + undefined, + { + user_id: userId, + limit: limitValue, + time_min: timeMin, + time_max: timeMax, + }, + ); +} + +export function ingestCalendarEvent( + apiBaseUrl: string, + calendarAccountId: string, + providerEventId: string, + payload: CalendarEventIngestPayload, +) { + return requestJson<CalendarEventIngestionResponse>( + apiBaseUrl, + `/v0/calendar-accounts/${calendarAccountId}/events/${providerEventId}/ingest`, + { + method: "POST", + body: JSON.stringify(payload), + }, + ); +} + +export function listTaskWorkspaces(apiBaseUrl: string, userId: string) { + return requestJson<{ items: TaskWorkspaceRecord[]; summary: TaskWorkspaceListSummary }>( + apiBaseUrl, + "/v0/task-workspaces", + undefined, + { user_id: userId }, + ); +} + +export function getTaskWorkspaceDetail(apiBaseUrl: string, taskWorkspaceId: string, userId: string) { + return requestJson<{ workspace: TaskWorkspaceRecord }>( + apiBaseUrl, + `/v0/task-workspaces/${taskWorkspaceId}`, + undefined, + { user_id: userId }, + ); +} + +export function listTaskArtifacts(apiBaseUrl: string, userId: string) { + return requestJson<{ items: TaskArtifactRecord[]; summary: TaskArtifactListSummary }>( + apiBaseUrl, + "/v0/task-artifacts", + undefined, + { user_id: userId }, + ); +} + +export function getTaskArtifactDetail(apiBaseUrl: string, taskArtifactId: string, userId: string) { + return requestJson<{ artifact: TaskArtifactRecord }>( + apiBaseUrl, + `/v0/task-artifacts/${taskArtifactId}`, + undefined, + { user_id: userId }, + ); +} + +export function listTaskArtifactChunks(apiBaseUrl: string, taskArtifactId: string, userId: string) { + return requestJson<{ items: TaskArtifactChunkRecord[]; summary: TaskArtifactChunkListSummary }>( + apiBaseUrl, + `/v0/task-artifacts/${taskArtifactId}/chunks`, + undefined, + { user_id: userId }, + ); +} + +export function listEntities(apiBaseUrl: string, userId: string) { + return requestJson<{ items: EntityRecord[]; summary: EntityListSummary }>( + apiBaseUrl, + "/v0/entities", + undefined, + { user_id: userId }, + ); +} + +export function getEntityDetail(apiBaseUrl: string, entityId: string, userId: string) { + return requestJson<{ entity: EntityRecord }>( + apiBaseUrl, + `/v0/entities/${entityId}`, + undefined, + { user_id: userId }, + ); +} + +export function listEntityEdges(apiBaseUrl: string, entityId: string, userId: string) { + return requestJson<{ items: EntityEdgeRecord[]; summary: EntityEdgeListSummary }>( + apiBaseUrl, + `/v0/entities/${entityId}/edges`, + undefined, + { user_id: userId }, + ); +} + +export function listMemories( + apiBaseUrl: string, + userId: string, + options?: { + status?: MemoryReviewStatusFilter; + limit?: number; + }, +) { + return requestJson<{ items: MemoryReviewRecord[]; summary: MemoryReviewListSummary }>( + apiBaseUrl, + "/v0/memories", + undefined, + { + user_id: userId, + status: options?.status, + limit: options?.limit ? String(options.limit) : undefined, + }, + ); +} + +export function listMemoryReviewQueue( + apiBaseUrl: string, + userId: string, + options?: number | { limit?: number; priorityMode?: MemoryReviewQueuePriorityMode }, +) { + const limit = typeof options === "number" ? options : options?.limit; + const priorityMode = typeof options === "number" ? undefined : options?.priorityMode; + return requestJson<{ items: MemoryReviewQueueItem[]; summary: MemoryReviewQueueSummary }>( + apiBaseUrl, + "/v0/memories/review-queue", + undefined, + { + user_id: userId, + limit: limit ? String(limit) : undefined, + priority_mode: priorityMode, + }, + ); +} + +export async function getMemoryEvaluationSummary(apiBaseUrl: string, userId: string) { + const [evaluationPayload, qualityGatePayload] = await Promise.all([ + requestJson<{ summary: Omit<MemoryEvaluationSummary, "quality_gate"> }>( + apiBaseUrl, + "/v0/memories/evaluation-summary", + undefined, + { user_id: userId }, + ), + requestJson<{ summary: MemoryQualityGateSummary }>( + apiBaseUrl, + "/v0/memories/quality-gate", + undefined, + { user_id: userId }, + ), + ]); + + return { + summary: { + ...evaluationPayload.summary, + quality_gate: qualityGatePayload.summary, + }, + }; +} + +export function getMemoryTrustDashboard(apiBaseUrl: string, userId: string) { + return requestJson<{ dashboard: MemoryTrustDashboardSummary }>( + apiBaseUrl, + "/v0/memories/trust-dashboard", + undefined, + { user_id: userId }, + ); +} + +export function getMemoryDetail(apiBaseUrl: string, memoryId: string, userId: string) { + return requestJson<{ memory: MemoryReviewRecord }>( + apiBaseUrl, + `/v0/memories/${memoryId}`, + undefined, + { user_id: userId }, + ); +} + +export function getMemoryRevisions( + apiBaseUrl: string, + memoryId: string, + userId: string, + limit?: number, +) { + return requestJson<{ items: MemoryRevisionReviewRecord[]; summary: MemoryRevisionReviewListSummary }>( + apiBaseUrl, + `/v0/memories/${memoryId}/revisions`, + undefined, + { + user_id: userId, + limit: limit ? String(limit) : undefined, + }, + ); +} + +export function listMemoryLabels(apiBaseUrl: string, memoryId: string, userId: string) { + return requestJson<{ items: MemoryReviewLabelRecord[]; summary: MemoryReviewLabelSummary }>( + apiBaseUrl, + `/v0/memories/${memoryId}/labels`, + undefined, + { user_id: userId }, + ); +} + +export function submitMemoryLabel( + apiBaseUrl: string, + memoryId: string, + payload: MemoryReviewLabelPayload, +) { + return requestJson<{ label: MemoryReviewLabelRecord; summary: MemoryReviewLabelSummary }>( + apiBaseUrl, + `/v0/memories/${memoryId}/labels`, + { + method: "POST", + body: JSON.stringify(payload), + }, + ); +} diff --git a/apps/web/lib/fixtures.ts b/apps/web/lib/fixtures.ts new file mode 100644 index 0000000..c5c3fe3 --- /dev/null +++ b/apps/web/lib/fixtures.ts @@ -0,0 +1,1829 @@ +import type { + AgentProfileItem, + AgentProfileListSummary, + ApprovalItem, + ApprovalRequestPayload, + CalendarAccountListSummary, + CalendarAccountRecord, + CalendarEventListSummary, + CalendarEventSummaryRecord, + EntityEdgeListSummary, + EntityEdgeRecord, + EntityListSummary, + EntityRecord, + GmailAccountListSummary, + GmailAccountRecord, + MemoryEvaluationSummary, + MemoryReviewLabelRecord, + MemoryReviewLabelSummary, + MemoryReviewListSummary, + MemoryReviewQueueItem, + MemoryReviewQueueSummary, + MemoryReviewRecord, + MemoryRevisionReviewRecord, + MemoryRevisionReviewListSummary, + TaskArtifactChunkListSummary, + TaskArtifactChunkRecord, + TaskArtifactListSummary, + TaskArtifactRecord, + TaskWorkspaceListSummary, + TaskWorkspaceRecord, + RequestHistoryEntry, + ResponseHistoryEntry, + ThreadEventItem, + ThreadItem, + ThreadSessionItem, + TaskItem, + TaskStepItem, + TaskStepListSummary, + ToolExecutionItem, + ToolRecord, +} from "./api"; +import { DEFAULT_AGENT_PROFILE_ID } from "./api"; +import type { TraceItem } from "../components/trace-list"; + +const PURCHASE_TOOL: ToolRecord = { + id: "22222222-2222-4222-8222-222222222222", + tool_key: "merchant_proxy", + name: "Merchant Proxy", + description: "Proxy for governed ecommerce actions.", + version: "0.1.0", + metadata_version: "tool_metadata_v0", + active: true, + tags: ["commerce", "approval"], + action_hints: ["place_order"], + scope_hints: ["supplements"], + domain_hints: ["ecommerce"], + risk_hints: ["purchase"], + metadata: {}, + created_at: "2026-03-15T08:00:00Z", +}; + +const THREAD_MAGNESIUM = "11111111-1111-4111-8111-111111111111"; +const THREAD_VITAMIN_D = "11111111-1111-4111-8111-111111111112"; +const THREAD_CLEANUP = "11111111-1111-4111-8111-111111111113"; + +export const threadFixtures: ThreadItem[] = [ + { + id: THREAD_MAGNESIUM, + title: "Magnesium continuity review", + agent_profile_id: "assistant_default", + created_at: "2026-03-17T06:40:00Z", + updated_at: "2026-03-17T08:45:00Z", + }, + { + id: THREAD_VITAMIN_D, + title: "Vitamin D reorder follow-up", + agent_profile_id: "coach_default", + created_at: "2026-03-16T13:58:00Z", + updated_at: "2026-03-16T14:32:00Z", + }, + { + id: THREAD_CLEANUP, + title: "Quarterly routine cleanup", + agent_profile_id: "assistant_default", + created_at: "2026-03-15T09:20:00Z", + updated_at: "2026-03-15T09:20:00Z", + }, +]; + +export const agentProfileFixtures: AgentProfileItem[] = [ + { + id: "assistant_default", + name: "Assistant Default", + description: "General-purpose assistant profile for baseline conversations.", + }, + { + id: "coach_default", + name: "Coach Default", + description: "Coaching-oriented profile focused on guidance and accountability.", + }, +]; + +export const agentProfileFixtureSummary: AgentProfileListSummary = { + total_count: agentProfileFixtures.length, + order: ["id_asc"], +}; + +export const threadSessionFixtures: Record<string, ThreadSessionItem[]> = { + [THREAD_MAGNESIUM]: [ + { + id: "session-magnesium-1", + thread_id: THREAD_MAGNESIUM, + status: "completed", + started_at: "2026-03-17T06:40:00Z", + ended_at: "2026-03-17T06:52:00Z", + created_at: "2026-03-17T06:40:00Z", + }, + { + id: "session-magnesium-2", + thread_id: THREAD_MAGNESIUM, + status: "active", + started_at: "2026-03-17T08:40:00Z", + ended_at: null, + created_at: "2026-03-17T08:40:00Z", + }, + ], + [THREAD_VITAMIN_D]: [ + { + id: "session-vitamin-d-1", + thread_id: THREAD_VITAMIN_D, + status: "completed", + started_at: "2026-03-16T14:00:00Z", + ended_at: "2026-03-16T14:32:00Z", + created_at: "2026-03-16T14:00:00Z", + }, + ], + [THREAD_CLEANUP]: [], +}; + +export const threadEventFixtures: Record<string, ThreadEventItem[]> = { + [THREAD_MAGNESIUM]: [ + { + id: "event-magnesium-1", + thread_id: THREAD_MAGNESIUM, + session_id: "session-magnesium-1", + sequence_no: 1, + kind: "message.user", + payload: { + text: "Review my magnesium reorder context before I place another order.", + }, + created_at: "2026-03-17T06:41:00Z", + }, + { + id: "event-magnesium-2", + thread_id: THREAD_MAGNESIUM, + session_id: "session-magnesium-1", + sequence_no: 2, + kind: "approval.request", + payload: { + action: "place_order", + scope: "supplements", + status: "pending", + }, + created_at: "2026-03-17T06:50:00Z", + }, + { + id: "event-magnesium-3", + thread_id: THREAD_MAGNESIUM, + session_id: "session-magnesium-2", + sequence_no: 3, + kind: "message.user", + payload: { + text: "Summarize what is still waiting for approval.", + }, + created_at: "2026-03-17T08:43:00Z", + }, + { + id: "event-magnesium-4", + thread_id: THREAD_MAGNESIUM, + session_id: "session-magnesium-2", + sequence_no: 4, + kind: "message.assistant", + payload: { + text: "The latest magnesium purchase request is still waiting on approval and keeps the merchant and package details explicit.", + }, + created_at: "2026-03-17T08:45:00Z", + }, + ], + [THREAD_VITAMIN_D]: [ + { + id: "event-vitamin-d-1", + thread_id: THREAD_VITAMIN_D, + session_id: "session-vitamin-d-1", + sequence_no: 1, + kind: "message.user", + payload: { + text: "What happened with my last Vitamin D reorder?", + }, + created_at: "2026-03-16T14:05:00Z", + }, + { + id: "event-vitamin-d-2", + thread_id: THREAD_VITAMIN_D, + session_id: "session-vitamin-d-1", + sequence_no: 2, + kind: "approval.resolution", + payload: { + status: "approved", + summary: "The operator approved the reorder before execution.", + }, + created_at: "2026-03-16T14:22:00Z", + }, + { + id: "event-vitamin-d-3", + thread_id: THREAD_VITAMIN_D, + session_id: "session-vitamin-d-1", + sequence_no: 3, + kind: "tool.execution", + payload: { + status: "completed", + summary: "Merchant proxy completed the approved supplement reorder.", + }, + created_at: "2026-03-16T14:24:00Z", + }, + { + id: "event-vitamin-d-4", + thread_id: THREAD_VITAMIN_D, + session_id: "session-vitamin-d-1", + sequence_no: 4, + kind: "message.assistant", + payload: { + text: "The prior Vitamin D request was approved and executed. Open the task or trace review if you need the full record.", + }, + created_at: "2026-03-16T14:32:00Z", + }, + ], + [THREAD_CLEANUP]: [], +}; + +const MEMORY_MERCHANT = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1"; +const MEMORY_MAGNESIUM = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2"; +const MEMORY_DELIVERY = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa3"; + +const MEMORY_LABEL_VALUE_ORDER: MemoryReviewLabelSummary["order"] = [ + "correct", + "incorrect", + "outdated", + "insufficient_evidence", +]; + +export const memoryFixtures: MemoryReviewRecord[] = [ + { + id: MEMORY_MERCHANT, + memory_key: "user.preference.merchant.supplements", + value: { + merchant: "Thorne", + confidence: "high", + reason: "Previously approved orders favored this merchant for magnesium.", + }, + status: "active", + source_event_ids: ["event-magnesium-2", "event-magnesium-3"], + created_at: "2026-03-16T10:20:00Z", + updated_at: "2026-03-17T08:10:00Z", + deleted_at: null, + }, + { + id: MEMORY_MAGNESIUM, + memory_key: "user.preference.supplement.magnesium_bisglycinate", + value: { + item: "Magnesium Bisglycinate", + quantity: "1", + package: "90 capsules", + }, + status: "active", + source_event_ids: ["event-magnesium-1", "event-magnesium-2"], + created_at: "2026-03-16T10:25:00Z", + updated_at: "2026-03-17T08:12:00Z", + deleted_at: null, + }, + { + id: MEMORY_DELIVERY, + memory_key: "user.preference.delivery.window", + value: { + window: "weekday_morning", + note: "Avoid weekend deliveries for supplements.", + }, + status: "active", + source_event_ids: ["event-vitamin-d-1"], + created_at: "2026-03-14T09:10:00Z", + updated_at: "2026-03-16T09:10:00Z", + deleted_at: null, + }, +]; + +export const memoryReviewQueueFixtures: MemoryReviewQueueItem[] = [ + { + id: MEMORY_MAGNESIUM, + memory_key: "user.preference.supplement.magnesium_bisglycinate", + value: { + item: "Magnesium Bisglycinate", + quantity: "1", + package: "90 capsules", + }, + status: "active", + source_event_ids: ["event-magnesium-1", "event-magnesium-2"], + is_high_risk: true, + is_stale_truth: false, + queue_priority_mode: "recent_first", + priority_reason: "recent_first", + created_at: "2026-03-16T10:25:00Z", + updated_at: "2026-03-17T08:12:00Z", + }, + { + id: MEMORY_DELIVERY, + memory_key: "user.preference.delivery.window", + value: { + window: "weekday_morning", + note: "Avoid weekend deliveries for supplements.", + }, + status: "active", + source_event_ids: ["event-vitamin-d-1"], + is_high_risk: true, + is_stale_truth: false, + queue_priority_mode: "recent_first", + priority_reason: "recent_first", + created_at: "2026-03-14T09:10:00Z", + updated_at: "2026-03-16T09:10:00Z", + }, +]; + +export const memoryReviewListSummaryFixture: MemoryReviewListSummary = { + status: "active", + limit: 20, + returned_count: memoryFixtures.length, + total_count: memoryFixtures.length, + has_more: false, + order: ["updated_at_desc", "created_at_desc", "id_desc"], +}; + +export const memoryReviewQueueSummaryFixture: MemoryReviewQueueSummary = { + memory_status: "active", + review_state: "unlabeled", + priority_mode: "recent_first", + available_priority_modes: [ + "oldest_first", + "recent_first", + "high_risk_first", + "stale_truth_first", + ], + limit: 20, + returned_count: memoryReviewQueueFixtures.length, + total_count: memoryReviewQueueFixtures.length, + has_more: false, + order: ["updated_at_desc", "created_at_desc", "id_desc"], +}; + +export const memoryEvaluationSummaryFixture: MemoryEvaluationSummary = { + total_memory_count: 4, + active_memory_count: 3, + deleted_memory_count: 1, + labeled_memory_count: 1, + unlabeled_memory_count: 3, + total_label_row_count: 2, + label_row_counts_by_value: { + correct: 1, + incorrect: 0, + outdated: 1, + insufficient_evidence: 0, + }, + label_value_order: [...MEMORY_LABEL_VALUE_ORDER], + quality_gate: { + status: "insufficient_sample", + precision: 1, + precision_target: 0.8, + adjudicated_sample_count: 1, + minimum_adjudicated_sample: 10, + remaining_to_minimum_sample: 9, + unlabeled_memory_count: 3, + high_risk_memory_count: 3, + stale_truth_count: 0, + superseded_active_conflict_count: 0, + counts: { + active_memory_count: 3, + labeled_active_memory_count: 0, + adjudicated_correct_count: 1, + adjudicated_incorrect_count: 0, + outdated_label_count: 1, + insufficient_evidence_label_count: 0, + }, + }, +}; + +export const memoryEvaluationSummaryOnTrackFixture: MemoryEvaluationSummary = { + total_memory_count: 12, + active_memory_count: 10, + deleted_memory_count: 2, + labeled_memory_count: 12, + unlabeled_memory_count: 0, + total_label_row_count: 10, + label_row_counts_by_value: { + correct: 8, + incorrect: 2, + outdated: 1, + insufficient_evidence: 1, + }, + label_value_order: [...MEMORY_LABEL_VALUE_ORDER], + quality_gate: { + status: "healthy", + precision: 0.8, + precision_target: 0.8, + adjudicated_sample_count: 10, + minimum_adjudicated_sample: 10, + remaining_to_minimum_sample: 0, + unlabeled_memory_count: 0, + high_risk_memory_count: 0, + stale_truth_count: 0, + superseded_active_conflict_count: 0, + counts: { + active_memory_count: 10, + labeled_active_memory_count: 10, + adjudicated_correct_count: 8, + adjudicated_incorrect_count: 2, + outdated_label_count: 1, + insufficient_evidence_label_count: 1, + }, + }, +}; + +export const memoryEvaluationSummaryNeedsReviewFixture: MemoryEvaluationSummary = { + total_memory_count: 12, + active_memory_count: 10, + deleted_memory_count: 2, + labeled_memory_count: 12, + unlabeled_memory_count: 3, + total_label_row_count: 10, + label_row_counts_by_value: { + correct: 6, + incorrect: 4, + outdated: 0, + insufficient_evidence: 0, + }, + label_value_order: [...MEMORY_LABEL_VALUE_ORDER], + quality_gate: { + status: "degraded", + precision: 0.6, + precision_target: 0.8, + adjudicated_sample_count: 10, + minimum_adjudicated_sample: 10, + remaining_to_minimum_sample: 0, + unlabeled_memory_count: 3, + high_risk_memory_count: 3, + stale_truth_count: 0, + superseded_active_conflict_count: 0, + counts: { + active_memory_count: 10, + labeled_active_memory_count: 7, + adjudicated_correct_count: 6, + adjudicated_incorrect_count: 4, + outdated_label_count: 0, + insufficient_evidence_label_count: 0, + }, + }, +}; + +export const memoryRevisionFixtures: Record<string, MemoryRevisionReviewRecord[]> = { + [MEMORY_MERCHANT]: [ + { + id: "memory-revision-1", + memory_id: MEMORY_MERCHANT, + sequence_no: 1, + action: "ADD", + memory_key: "user.preference.merchant.supplements", + previous_value: null, + new_value: { + merchant: "Thorne", + }, + source_event_ids: ["event-magnesium-1"], + created_at: "2026-03-16T10:20:00Z", + }, + { + id: "memory-revision-2", + memory_id: MEMORY_MERCHANT, + sequence_no: 2, + action: "UPDATE", + memory_key: "user.preference.merchant.supplements", + previous_value: { + merchant: "Thorne", + }, + new_value: { + merchant: "Thorne", + confidence: "high", + reason: "Previously approved orders favored this merchant for magnesium.", + }, + source_event_ids: ["event-magnesium-2", "event-magnesium-3"], + created_at: "2026-03-17T08:10:00Z", + }, + ], + [MEMORY_MAGNESIUM]: [ + { + id: "memory-revision-3", + memory_id: MEMORY_MAGNESIUM, + sequence_no: 1, + action: "ADD", + memory_key: "user.preference.supplement.magnesium_bisglycinate", + previous_value: null, + new_value: { + item: "Magnesium Bisglycinate", + quantity: "1", + package: "90 capsules", + }, + source_event_ids: ["event-magnesium-1", "event-magnesium-2"], + created_at: "2026-03-16T10:25:00Z", + }, + ], + [MEMORY_DELIVERY]: [ + { + id: "memory-revision-4", + memory_id: MEMORY_DELIVERY, + sequence_no: 1, + action: "ADD", + memory_key: "user.preference.delivery.window", + previous_value: null, + new_value: { + window: "weekday_morning", + note: "Avoid weekend deliveries for supplements.", + }, + source_event_ids: ["event-vitamin-d-1"], + created_at: "2026-03-14T09:10:00Z", + }, + ], +}; + +export const memoryLabelFixtures: Record<string, MemoryReviewLabelRecord[]> = { + [MEMORY_MERCHANT]: [ + { + id: "memory-label-1", + memory_id: MEMORY_MERCHANT, + reviewer_user_id: "99999999-9999-4999-8999-999999999999", + label: "correct", + note: "Still matches the latest approved reorder context.", + created_at: "2026-03-17T08:20:00Z", + }, + { + id: "memory-label-2", + memory_id: MEMORY_MERCHANT, + reviewer_user_id: "99999999-9999-4999-8999-999999999999", + label: "outdated", + note: "Verify against any newer merchant change before next order.", + created_at: "2026-03-17T08:21:00Z", + }, + ], +}; + +export const memoryLabelSummaryFixtures: Record<string, MemoryReviewLabelSummary> = { + [MEMORY_MERCHANT]: { + memory_id: MEMORY_MERCHANT, + total_count: 2, + counts_by_label: { + correct: 1, + incorrect: 0, + outdated: 1, + insufficient_evidence: 0, + }, + order: [...MEMORY_LABEL_VALUE_ORDER], + }, +}; + +const ENTITY_ALICE = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1"; +const ENTITY_THORNE = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb2"; +const ENTITY_MAGNESIUM = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb3"; +const ENTITY_ROUTINE = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb4"; + +const ENTITY_EDGE_A = "cccccccc-cccc-4ccc-8ccc-ccccccccccc1"; +const ENTITY_EDGE_B = "cccccccc-cccc-4ccc-8ccc-ccccccccccc2"; +const ENTITY_EDGE_C = "cccccccc-cccc-4ccc-8ccc-ccccccccccc3"; + +export const entityFixtures: EntityRecord[] = [ + { + id: ENTITY_ALICE, + entity_type: "person", + name: "Alice", + source_memory_ids: [MEMORY_MERCHANT, MEMORY_MAGNESIUM], + created_at: "2026-03-16T10:30:00Z", + }, + { + id: ENTITY_THORNE, + entity_type: "merchant", + name: "Thorne", + source_memory_ids: [MEMORY_MERCHANT], + created_at: "2026-03-16T10:31:00Z", + }, + { + id: ENTITY_MAGNESIUM, + entity_type: "product", + name: "Magnesium Bisglycinate 90 capsules", + source_memory_ids: [MEMORY_MAGNESIUM], + created_at: "2026-03-16T10:32:00Z", + }, + { + id: ENTITY_ROUTINE, + entity_type: "routine", + name: "Morning supplement routine", + source_memory_ids: [MEMORY_MAGNESIUM, MEMORY_DELIVERY], + created_at: "2026-03-16T10:33:00Z", + }, +]; + +export const entityListSummaryFixture: EntityListSummary = { + total_count: entityFixtures.length, + order: ["created_at_asc", "id_asc"], +}; + +const entityEdgeCatalog: EntityEdgeRecord[] = [ + { + id: ENTITY_EDGE_A, + from_entity_id: ENTITY_ALICE, + to_entity_id: ENTITY_THORNE, + relationship_type: "prefers_merchant", + valid_from: "2026-03-16T10:20:00Z", + valid_to: null, + source_memory_ids: [MEMORY_MERCHANT], + created_at: "2026-03-16T10:40:00Z", + }, + { + id: ENTITY_EDGE_B, + from_entity_id: ENTITY_ALICE, + to_entity_id: ENTITY_MAGNESIUM, + relationship_type: "prefers_product", + valid_from: "2026-03-16T10:25:00Z", + valid_to: null, + source_memory_ids: [MEMORY_MAGNESIUM], + created_at: "2026-03-16T10:42:00Z", + }, + { + id: ENTITY_EDGE_C, + from_entity_id: ENTITY_ROUTINE, + to_entity_id: ENTITY_MAGNESIUM, + relationship_type: "includes_product", + valid_from: "2026-03-16T10:33:00Z", + valid_to: null, + source_memory_ids: [MEMORY_MAGNESIUM, MEMORY_DELIVERY], + created_at: "2026-03-16T10:44:00Z", + }, +]; + +export const entityEdgeFixtures: Record<string, EntityEdgeRecord[]> = { + [ENTITY_ALICE]: entityEdgeCatalog.filter( + (edge) => edge.from_entity_id === ENTITY_ALICE || edge.to_entity_id === ENTITY_ALICE, + ), + [ENTITY_THORNE]: entityEdgeCatalog.filter( + (edge) => edge.from_entity_id === ENTITY_THORNE || edge.to_entity_id === ENTITY_THORNE, + ), + [ENTITY_MAGNESIUM]: entityEdgeCatalog.filter( + (edge) => edge.from_entity_id === ENTITY_MAGNESIUM || edge.to_entity_id === ENTITY_MAGNESIUM, + ), + [ENTITY_ROUTINE]: entityEdgeCatalog.filter( + (edge) => edge.from_entity_id === ENTITY_ROUTINE || edge.to_entity_id === ENTITY_ROUTINE, + ), +}; + +const TASK_WORKSPACE_MAGNESIUM = "dddddddd-dddd-4ddd-8ddd-ddddddddddd1"; +const TASK_WORKSPACE_VITAMIN_D = "dddddddd-dddd-4ddd-8ddd-ddddddddddd2"; + +const TASK_ARTIFACT_MAGNESIUM_NOTE = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeee1"; +const TASK_ARTIFACT_VITAMIN_EMAIL = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeee2"; +const TASK_ARTIFACT_PENDING_PDF = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeee3"; + +export const gmailAccountFixtures: GmailAccountRecord[] = [ + { + id: "f1f1f1f1-f1f1-4f1f-8f1f-f1f1f1f1f1f1", + provider: "gmail", + auth_kind: "oauth_access_token", + provider_account_id: "acct-owner-001", + email_address: "owner@gmail.example", + display_name: "Owner", + scope: "https://www.googleapis.com/auth/gmail.readonly", + created_at: "2026-03-16T14:00:00Z", + updated_at: "2026-03-16T14:00:00Z", + }, + { + id: "f2f2f2f2-f2f2-4f2f-8f2f-f2f2f2f2f2f2", + provider: "gmail", + auth_kind: "oauth_access_token", + provider_account_id: "acct-ops-002", + email_address: "ops@gmail.example", + display_name: "Ops", + scope: "https://www.googleapis.com/auth/gmail.readonly", + created_at: "2026-03-17T08:32:00Z", + updated_at: "2026-03-17T08:32:00Z", + }, +]; + +export const gmailAccountListSummaryFixture: GmailAccountListSummary = { + total_count: gmailAccountFixtures.length, + order: ["created_at_asc", "id_asc"], +}; + +export const calendarAccountFixtures: CalendarAccountRecord[] = [ + { + id: "c1c1c1c1-c1c1-4c1c-8c1c-c1c1c1c1c1c1", + provider: "google_calendar", + auth_kind: "oauth_access_token", + provider_account_id: "acct-owner-001", + email_address: "owner@gmail.example", + display_name: "Owner", + scope: "https://www.googleapis.com/auth/calendar.readonly", + created_at: "2026-03-16T14:00:00Z", + updated_at: "2026-03-16T14:00:00Z", + }, + { + id: "c2c2c2c2-c2c2-4c2c-8c2c-c2c2c2c2c2c2", + provider: "google_calendar", + auth_kind: "oauth_access_token", + provider_account_id: "acct-ops-002", + email_address: "ops@gmail.example", + display_name: "Ops", + scope: "https://www.googleapis.com/auth/calendar.readonly", + created_at: "2026-03-17T08:32:00Z", + updated_at: "2026-03-17T08:32:00Z", + }, +]; + +export const calendarAccountListSummaryFixture: CalendarAccountListSummary = { + total_count: calendarAccountFixtures.length, + order: ["created_at_asc", "id_asc"], +}; + +export const calendarEventFixtures: Record<string, CalendarEventSummaryRecord[]> = { + "c1c1c1c1-c1c1-4c1c-8c1c-c1c1c1c1c1c1": [ + { + provider_event_id: "evt-owner-planning", + status: "confirmed", + summary: "Sprint planning review", + start_time: "2026-03-20T09:00:00+00:00", + end_time: "2026-03-20T09:30:00+00:00", + html_link: "https://calendar.google.com/event?eid=evt-owner-planning", + updated_at: "2026-03-19T08:14:00+00:00", + }, + { + provider_event_id: "evt-owner-retro", + status: "tentative", + summary: "Retro prep", + start_time: "2026-03-20T11:00:00+00:00", + end_time: "2026-03-20T11:45:00+00:00", + html_link: "https://calendar.google.com/event?eid=evt-owner-retro", + updated_at: "2026-03-19T08:42:00+00:00", + }, + { + provider_event_id: "evt-owner-all-day", + status: "confirmed", + summary: "Quarterly planning day", + start_time: "2026-03-21", + end_time: "2026-03-22", + html_link: null, + updated_at: "2026-03-18T10:00:00+00:00", + }, + ], + "c2c2c2c2-c2c2-4c2c-8c2c-c2c2c2c2c2c2": [ + { + provider_event_id: "evt-ops-review", + status: "confirmed", + summary: "Ops account audit", + start_time: "2026-03-20T14:00:00+00:00", + end_time: "2026-03-20T14:30:00+00:00", + html_link: "https://calendar.google.com/event?eid=evt-ops-review", + updated_at: "2026-03-19T07:55:00+00:00", + }, + { + provider_event_id: "evt-ops-hand-off", + status: "confirmed", + summary: "Shift hand-off", + start_time: "2026-03-20T16:00:00+00:00", + end_time: "2026-03-20T16:20:00+00:00", + html_link: "https://calendar.google.com/event?eid=evt-ops-hand-off", + updated_at: "2026-03-19T09:20:00+00:00", + }, + ], +}; + +export const taskWorkspaceFixtures: TaskWorkspaceRecord[] = [ + { + id: TASK_WORKSPACE_MAGNESIUM, + task_id: "33333333-3333-4333-8333-333333333333", + status: "active", + local_path: "/var/alicebot/workspaces/33333333-3333-4333-8333-333333333333", + created_at: "2026-03-17T06:48:00Z", + updated_at: "2026-03-17T06:48:00Z", + }, + { + id: TASK_WORKSPACE_VITAMIN_D, + task_id: "33333333-3333-4333-8333-333333333334", + status: "active", + local_path: "/var/alicebot/workspaces/33333333-3333-4333-8333-333333333334", + created_at: "2026-03-16T13:58:00Z", + updated_at: "2026-03-16T13:58:00Z", + }, +]; + +export const taskWorkspaceListSummaryFixture: TaskWorkspaceListSummary = { + total_count: taskWorkspaceFixtures.length, + order: ["created_at_asc", "id_asc"], +}; + +export const taskArtifactFixtures: TaskArtifactRecord[] = [ + { + id: TASK_ARTIFACT_MAGNESIUM_NOTE, + task_id: "33333333-3333-4333-8333-333333333333", + task_workspace_id: TASK_WORKSPACE_MAGNESIUM, + status: "registered", + ingestion_status: "ingested", + relative_path: "notes/magnesium-review.md", + media_type_hint: "text/markdown", + created_at: "2026-03-17T07:10:00Z", + updated_at: "2026-03-17T07:12:00Z", + }, + { + id: TASK_ARTIFACT_VITAMIN_EMAIL, + task_id: "33333333-3333-4333-8333-333333333334", + task_workspace_id: TASK_WORKSPACE_VITAMIN_D, + status: "registered", + ingestion_status: "ingested", + relative_path: "gmail/2026-03-16-order-confirmation.eml", + media_type_hint: "message/rfc822", + created_at: "2026-03-16T14:18:00Z", + updated_at: "2026-03-16T14:19:00Z", + }, + { + id: TASK_ARTIFACT_PENDING_PDF, + task_id: "33333333-3333-4333-8333-333333333333", + task_workspace_id: TASK_WORKSPACE_MAGNESIUM, + status: "registered", + ingestion_status: "pending", + relative_path: "docs/lab-panel.pdf", + media_type_hint: "application/pdf", + created_at: "2026-03-17T08:01:00Z", + updated_at: "2026-03-17T08:01:00Z", + }, +]; + +export const taskArtifactListSummaryFixture: TaskArtifactListSummary = { + total_count: taskArtifactFixtures.length, + order: ["created_at_asc", "id_asc"], +}; + +export const taskArtifactChunkFixtures: Record<string, TaskArtifactChunkRecord[]> = { + [TASK_ARTIFACT_MAGNESIUM_NOTE]: [ + { + id: "ffffffff-ffff-4fff-8fff-fffffffffff1", + task_artifact_id: TASK_ARTIFACT_MAGNESIUM_NOTE, + sequence_no: 1, + char_start: 0, + char_end_exclusive: 196, + text: "## Magnesium review\nLast approved merchant: Thorne.\nPreferred package size: 90 capsules.\nPending approval still blocks any new purchase action.\n", + created_at: "2026-03-17T07:12:00Z", + updated_at: "2026-03-17T07:12:00Z", + }, + { + id: "ffffffff-ffff-4fff-8fff-fffffffffff2", + task_artifact_id: TASK_ARTIFACT_MAGNESIUM_NOTE, + sequence_no: 2, + char_start: 196, + char_end_exclusive: 392, + text: "## Follow-up\nOperator should confirm whether merchant preference still matches current constraints before requesting execution.\n", + created_at: "2026-03-17T07:12:00Z", + updated_at: "2026-03-17T07:12:00Z", + }, + ], + [TASK_ARTIFACT_VITAMIN_EMAIL]: [ + { + id: "ffffffff-ffff-4fff-8fff-fffffffffff3", + task_artifact_id: TASK_ARTIFACT_VITAMIN_EMAIL, + sequence_no: 1, + char_start: 0, + char_end_exclusive: 238, + text: "From: orders@example.com\nSubject: Vitamin D3 + K2 order confirmation\nBody: Your order was approved and fulfilled on 2026-03-16.\n", + created_at: "2026-03-16T14:19:00Z", + updated_at: "2026-03-16T14:19:00Z", + }, + ], +}; + +export const traceFixtures: TraceItem[] = [ + { + id: "trace-ctx-401", + kind: "context.compile", + status: "completed", + title: "Context compile review", + summary: + "Compiled prior task state, admitted memories, and recent thread continuity before assistant response assembly.", + eventCount: 3, + createdAt: "2026-03-17T08:45:00Z", + source: "continuity_v0", + scope: "Thread magnesium review", + related: { + threadId: "thread-magnesium", + compilerVersion: "continuity_v0", + }, + metadata: [ + "Trace: trace-ctx-401", + "Thread: thread-magnesium", + "Compiler: continuity_v0", + "Status: completed", + "Limit max_sessions: 3", + "Limit max_events: 8", + ], + evidence: [ + "Memory evidence admitted for supplement preference and merchant history.", + "Recent approval state included as part of the continuity pack.", + "Task-step lineage referenced before response generation.", + ], + events: [ + { + id: "event-1", + kind: "compiler.scope", + title: "Scope resolved", + detail: "Single-user thread scope and compile limits were established for the request.", + facts: ["Sequence 1", "Captured at Mar 17, 08:45"], + }, + { + id: "event-2", + kind: "memory.retrieve", + title: "Memory evidence attached", + detail: "Preference and purchase-history memories were ranked into the response context pack.", + facts: ["Sequence 2", "Captured at Mar 17, 08:45"], + }, + { + id: "event-3", + kind: "task.retrieve", + title: "Task lifecycle linked", + detail: "Open task and step state were included so the answer could acknowledge the approval dependency.", + facts: ["Sequence 3", "Captured at Mar 17, 08:45"], + }, + ], + detailSource: "fixture", + eventSource: "fixture", + }, + { + id: "trace-response-101", + kind: "response.generate", + status: "completed", + title: "Assistant response review", + summary: + "The assistant response used the compiled thread context and returned a bounded reply with persisted trace metadata.", + eventCount: 2, + createdAt: "2026-03-17T08:45:04Z", + source: "response_generation_v0", + scope: "Thread magnesium response", + related: { + threadId: "thread-magnesium", + compilerVersion: "response_generation_v0", + }, + metadata: [ + "Trace: trace-response-101", + "Thread: thread-magnesium", + "Linked compile trace: trace-ctx-401", + "Compiler: response_generation_v0", + "Status: completed", + ], + evidence: [ + "Prompt assembly stayed inside the shipped no-tools response path.", + "Assistant output was persisted as an immutable continuity event.", + ], + events: [ + { + id: "event-10", + kind: "response.prompt.assembled", + title: "Prompt assembled", + detail: "System, developer, context, and conversation sections were combined into one response prompt.", + facts: ["Sequence 1", "Linked compile trace: trace-ctx-401"], + }, + { + id: "event-11", + kind: "response.model.completed", + title: "Model completed", + detail: "The assistant returned a natural-language summary without invoking tools or hidden routing.", + facts: ["Sequence 2", "Provider: openai_responses"], + }, + ], + detailSource: "fixture", + eventSource: "fixture", + }, + { + id: "trace-approval-101", + kind: "approval.request", + status: "requires_review", + title: "Approval request review", + summary: + "Routing required user approval before the merchant proxy could execute the purchase request.", + eventCount: 3, + createdAt: "2026-03-17T06:50:00Z", + source: "approval_request_v0", + scope: "Supplement purchase review", + related: { + threadId: "thread-magnesium", + taskId: "task-201", + approvalId: "approval-101", + compilerVersion: "approval_request_v0", + }, + metadata: [ + "Trace: trace-approval-101", + "Thread: thread-magnesium", + "Task: task-201", + "Approval: approval-101", + "Compiler: approval_request_v0", + "Status: requires_review", + ], + evidence: [ + "Policy rule marked purchase actions as approval-gated.", + "Tool metadata matched the requested action and scope.", + "Task-step trace link points back to the original governed request.", + ], + events: [ + { + id: "event-4", + kind: "tool.route", + title: "Routing completed", + detail: "The merchant proxy was selected as the governing tool for the request.", + facts: ["Sequence 1", "Captured at Mar 17, 06:50"], + }, + { + id: "event-5", + kind: "approval.state", + title: "Approval opened", + detail: "Approval record persisted with pending resolution state and task-step linkage.", + facts: ["Sequence 2", "Captured at Mar 17, 06:50"], + }, + { + id: "event-6", + kind: "task.lifecycle", + title: "Task updated", + detail: "Task lifecycle moved into a pending approval state while retaining request provenance.", + facts: ["Sequence 3", "Captured at Mar 17, 06:50"], + }, + ], + detailSource: "fixture", + eventSource: "fixture", + }, + { + id: "trace-response-100", + kind: "response.generate", + status: "completed", + title: "Assistant response review", + summary: + "The assistant summarized the last governed Vitamin D action and kept the linked response trace readable for operator review.", + eventCount: 2, + createdAt: "2026-03-16T14:32:00Z", + source: "response_generation_v0", + scope: "Thread vitamin D response", + related: { + threadId: "thread-vitamin-d", + compilerVersion: "response_generation_v0", + }, + metadata: [ + "Trace: trace-response-100", + "Thread: thread-vitamin-d", + "Linked compile trace: trace-ctx-401", + "Compiler: response_generation_v0", + "Status: completed", + ], + evidence: [ + "The response referenced prior approval and execution state already stored on the thread.", + "Trace review remains separated from governed execution controls.", + ], + events: [ + { + id: "event-12", + kind: "response.prompt.assembled", + title: "Prompt assembled", + detail: "Conversation history and prior execution state were assembled into a bounded response prompt.", + facts: ["Sequence 1", "Linked compile trace: trace-ctx-401"], + }, + { + id: "event-13", + kind: "response.model.completed", + title: "Model completed", + detail: "The assistant returned an answer that pointed the operator back to task and trace review instead of acting.", + facts: ["Sequence 2", "Provider: openai_responses"], + }, + ], + detailSource: "fixture", + eventSource: "fixture", + }, + { + id: "trace-exec-311", + kind: "tool.proxy.execute", + status: "completed", + title: "Proxy execution review", + summary: + "Approved supplement purchase request executed through the proxy handler with task and trace linkage preserved.", + eventCount: 3, + createdAt: "2026-03-16T14:24:00Z", + source: "proxy_execution_v0", + scope: "Supplement execution review", + related: { + threadId: "thread-vitamin-d", + taskId: "task-182", + approvalId: "approval-100", + executionId: "execution-311", + compilerVersion: "proxy_execution_v0", + }, + metadata: [ + "Trace: trace-exec-311", + "Thread: thread-vitamin-d", + "Task: task-182", + "Approval: approval-100", + "Execution: execution-311", + "Compiler: proxy_execution_v0", + "Status: completed", + ], + evidence: [ + "Execution occurred only after approval resolution.", + "Handler output and trace references stayed attached to the governed action record.", + "Task and task-step lifecycle traces were appended alongside execution status.", + ], + events: [ + { + id: "event-7", + kind: "approval.check", + title: "Approval validated", + detail: "Execution preflight confirmed the approval was in an executable state.", + facts: ["Sequence 1", "Captured at Mar 16, 14:24"], + }, + { + id: "event-8", + kind: "budget.check", + title: "Budget check passed", + detail: "Execution budget constraints did not block the governed action.", + facts: ["Sequence 2", "Captured at Mar 16, 14:24"], + }, + { + id: "event-9", + kind: "execution.result", + title: "Handler completed", + detail: + "Proxy output was recorded for the approved supplement reorder with a linked execution trace and task-step status update.", + facts: ["Sequence 3", "Captured at Mar 16, 14:24"], + }, + ], + detailSource: "fixture", + eventSource: "fixture", + }, +]; + +export const requestHistoryFixtures: RequestHistoryEntry[] = [ + { + id: "trace-request-101", + submittedAt: "2026-03-17T06:50:00Z", + source: "fixture", + threadId: THREAD_MAGNESIUM, + toolId: PURCHASE_TOOL.id, + toolName: PURCHASE_TOOL.name, + action: "place_order", + scope: "supplements", + domainHint: "ecommerce", + riskHint: "purchase", + attributes: { + merchant: "Thorne", + item: "Magnesium Bisglycinate", + quantity: "1", + package: "90 capsules", + }, + decision: "approval_required", + taskId: "33333333-3333-4333-8333-333333333333", + taskStatus: "pending_approval", + approvalId: "44444444-4444-4444-8444-444444444444", + approvalStatus: "pending", + summary: + "The request persisted a pending approval and created a task that remains paused at explicit operator review.", + reasons: [ + "Purchases require explicit user approval before execution.", + "Merchant proxy matches the requested supplement purchase scope.", + ], + trace: { + routingTraceId: "55555555-5555-4555-8555-555555555555", + routingTraceEventCount: 3, + requestTraceId: "66666666-6666-4666-8666-666666666666", + requestTraceEventCount: 6, + }, + }, + { + id: "trace-request-100", + submittedAt: "2026-03-16T14:10:00Z", + source: "fixture", + threadId: THREAD_VITAMIN_D, + toolId: PURCHASE_TOOL.id, + toolName: PURCHASE_TOOL.name, + action: "place_order", + scope: "supplements", + domainHint: "ecommerce", + riskHint: "purchase", + attributes: { + merchant: "Fullscript", + item: "Vitamin D3 + K2", + quantity: "1", + }, + decision: "approval_required", + taskId: "33333333-3333-4333-8333-333333333334", + taskStatus: "approved", + approvalId: "44444444-4444-4444-8444-444444444445", + approvalStatus: "approved", + summary: + "The governed request produced an approval-linked task, and the operator already resolved the approval as approved.", + reasons: [ + "Repeat supplement purchases remain approval-gated even when the merchant and dosage are known.", + ], + trace: { + routingTraceId: "55555555-5555-4555-8555-555555555556", + routingTraceEventCount: 3, + requestTraceId: "66666666-6666-4666-8666-666666666667", + requestTraceEventCount: 6, + }, + }, +]; + +export const responseHistoryFixtures: ResponseHistoryEntry[] = [ + { + id: "trace-response-101", + submittedAt: "2026-03-17T08:45:00Z", + source: "fixture", + threadId: THREAD_MAGNESIUM, + message: "Summarize my current magnesium supplement context before I decide whether to reorder.", + assistantText: + "You previously reordered Thorne Magnesium Bisglycinate and the latest governed request is still waiting on approval. The current thread context also reflects a preference for keeping merchant and package size explicit before any purchase action.", + assistantEventId: "assistant-event-101", + assistantSequenceNo: 14, + modelProvider: "openai_responses", + model: "gpt-5-mini", + summary: + "Fixture mode shows the assistant-response layout and linked traces without persisting continuity events to the backend.", + trace: { + compileTraceId: "trace-ctx-401", + compileTraceEventCount: 3, + responseTraceId: "trace-response-101", + responseTraceEventCount: 2, + }, + }, + { + id: "trace-response-100", + submittedAt: "2026-03-16T14:32:00Z", + source: "fixture", + threadId: THREAD_VITAMIN_D, + message: "What do I need to know about the last Vitamin D request?", + assistantText: + "The prior Vitamin D3 + K2 request already moved through approval and execution. The trace history shows the approval was resolved before the proxy handler completed, so the remaining question is whether you want to open the task or execution review for detail.", + assistantEventId: "assistant-event-100", + assistantSequenceNo: 11, + modelProvider: "openai_responses", + model: "gpt-5-mini", + summary: + "Response history stays bounded with the operator prompt, assistant answer, and both compile and response trace references visible.", + trace: { + compileTraceId: "trace-ctx-401", + compileTraceEventCount: 3, + responseTraceId: "trace-response-100", + responseTraceEventCount: 2, + }, + }, +]; + +export const approvalFixtures: ApprovalItem[] = [ + { + id: "44444444-4444-4444-8444-444444444444", + thread_id: THREAD_MAGNESIUM, + task_step_id: "77777777-7777-4777-8777-777777777777", + status: "pending", + request: { + thread_id: THREAD_MAGNESIUM, + tool_id: PURCHASE_TOOL.id, + action: "place_order", + scope: "supplements", + domain_hint: "ecommerce", + risk_hint: "purchase", + attributes: { + merchant: "Thorne", + item: "Magnesium Bisglycinate", + quantity: "1", + budget_note: "Prefer previously approved merchant and package size.", + }, + }, + tool: PURCHASE_TOOL, + routing: { + decision: "require_approval", + reasons: [ + { + code: "policy_effect_require_approval", + source: "policy", + message: "Purchases require explicit user approval before execution.", + tool_id: PURCHASE_TOOL.id, + policy_id: "88888888-8888-4888-8888-888888888888", + consent_key: null, + }, + { + code: "tool_metadata_matched", + source: "tool", + message: "Merchant proxy supports the requested purchase scope.", + tool_id: PURCHASE_TOOL.id, + policy_id: null, + consent_key: null, + }, + ], + trace: { + trace_id: "55555555-5555-4555-8555-555555555555", + trace_event_count: 3, + }, + }, + created_at: "2026-03-17T06:50:00Z", + resolution: null, + }, + { + id: "44444444-4444-4444-8444-444444444445", + thread_id: THREAD_VITAMIN_D, + task_step_id: "77777777-7777-4777-8777-777777777778", + status: "approved", + request: { + thread_id: THREAD_VITAMIN_D, + tool_id: PURCHASE_TOOL.id, + action: "place_order", + scope: "supplements", + domain_hint: "ecommerce", + risk_hint: "purchase", + attributes: { + merchant: "Fullscript", + item: "Vitamin D3 + K2", + quantity: "1", + note: "Matched prior merchant and approved dosage plan.", + }, + }, + tool: PURCHASE_TOOL, + routing: { + decision: "require_approval", + reasons: [ + { + code: "matched_policy", + source: "policy", + message: + "Repeat supplement purchases remain approval-gated even when the merchant and dosage are known.", + tool_id: PURCHASE_TOOL.id, + policy_id: "88888888-8888-4888-8888-888888888888", + consent_key: null, + }, + ], + trace: { + trace_id: "55555555-5555-4555-8555-555555555556", + trace_event_count: 3, + }, + }, + created_at: "2026-03-16T14:10:00Z", + resolution: { + resolved_at: "2026-03-16T14:22:00Z", + resolved_by_user_id: "99999999-9999-4999-8999-999999999999", + }, + }, +]; + +export const taskFixtures: TaskItem[] = [ + { + id: "33333333-3333-4333-8333-333333333333", + thread_id: THREAD_MAGNESIUM, + tool_id: PURCHASE_TOOL.id, + status: "pending_approval", + request: { + thread_id: THREAD_MAGNESIUM, + tool_id: PURCHASE_TOOL.id, + action: "place_order", + scope: "supplements", + domain_hint: "ecommerce", + risk_hint: "purchase", + attributes: { + merchant: "Thorne", + item: "Magnesium Bisglycinate", + }, + }, + tool: PURCHASE_TOOL, + latest_approval_id: "44444444-4444-4444-8444-444444444444", + latest_execution_id: null, + created_at: "2026-03-17T06:49:00Z", + updated_at: "2026-03-17T06:50:00Z", + }, + { + id: "33333333-3333-4333-8333-333333333334", + thread_id: THREAD_VITAMIN_D, + tool_id: PURCHASE_TOOL.id, + status: "executed", + request: { + thread_id: THREAD_VITAMIN_D, + tool_id: PURCHASE_TOOL.id, + action: "place_order", + scope: "supplements", + domain_hint: "ecommerce", + risk_hint: "purchase", + attributes: { + merchant: "Fullscript", + item: "Vitamin D3 + K2", + }, + }, + tool: PURCHASE_TOOL, + latest_approval_id: "44444444-4444-4444-8444-444444444445", + latest_execution_id: "99999999-1111-4111-8111-111111111111", + created_at: "2026-03-16T14:00:00Z", + updated_at: "2026-03-16T14:24:00Z", + }, +]; + +export const executionFixtures: ToolExecutionItem[] = [ + { + id: "99999999-1111-4111-8111-111111111111", + approval_id: "44444444-4444-4444-8444-444444444445", + task_step_id: "77777777-7777-4777-8777-777777777778", + thread_id: THREAD_VITAMIN_D, + tool_id: PURCHASE_TOOL.id, + trace_id: "trace-exec-311", + request_event_id: "event-request-311", + result_event_id: "event-result-311", + status: "completed", + handler_key: "proxy.echo", + request: { + thread_id: THREAD_VITAMIN_D, + tool_id: PURCHASE_TOOL.id, + action: "place_order", + scope: "supplements", + domain_hint: "ecommerce", + risk_hint: "purchase", + attributes: { + merchant: "Fullscript", + item: "Vitamin D3 + K2", + quantity: "1", + }, + }, + tool: PURCHASE_TOOL, + result: { + handler_key: "proxy.echo", + status: "completed", + output: { + mode: "no_side_effect", + tool_key: "proxy.echo", + action: "place_order", + scope: "supplements", + merchant: "Fullscript", + item: "Vitamin D3 + K2", + }, + reason: null, + }, + executed_at: "2026-03-16T14:24:00Z", + }, +]; + +export const taskStepFixtures: Record<string, TaskStepItem[]> = { + "33333333-3333-4333-8333-333333333333": [ + { + id: "77777777-7777-4777-8777-777777777777", + task_id: "33333333-3333-4333-8333-333333333333", + sequence_no: 1, + kind: "governed_request", + status: "created", + request: { + thread_id: THREAD_MAGNESIUM, + tool_id: PURCHASE_TOOL.id, + action: "place_order", + scope: "supplements", + domain_hint: "ecommerce", + risk_hint: "purchase", + attributes: { + merchant: "Thorne", + item: "Magnesium Bisglycinate", + package: "90 capsules", + }, + }, + outcome: { + routing_decision: "require_approval", + approval_id: "44444444-4444-4444-8444-444444444444", + approval_status: "pending", + execution_id: null, + execution_status: null, + blocked_reason: null, + }, + lineage: { + parent_step_id: null, + source_approval_id: null, + source_execution_id: null, + }, + trace: { + trace_id: "66666666-6666-4666-8666-666666666666", + trace_kind: "approval_request", + }, + created_at: "2026-03-17T06:49:00Z", + updated_at: "2026-03-17T06:50:00Z", + }, + ], + "33333333-3333-4333-8333-333333333334": [ + { + id: "77777777-7777-4777-8777-777777777778", + task_id: "33333333-3333-4333-8333-333333333334", + sequence_no: 1, + kind: "governed_request", + status: "executed", + request: { + thread_id: THREAD_VITAMIN_D, + tool_id: PURCHASE_TOOL.id, + action: "place_order", + scope: "supplements", + domain_hint: "ecommerce", + risk_hint: "purchase", + attributes: { + merchant: "Fullscript", + item: "Vitamin D3 + K2", + quantity: "1", + }, + }, + outcome: { + routing_decision: "require_approval", + approval_id: "44444444-4444-4444-8444-444444444445", + approval_status: "approved", + execution_id: "99999999-1111-4111-8111-111111111111", + execution_status: "completed", + blocked_reason: null, + }, + lineage: { + parent_step_id: null, + source_approval_id: null, + source_execution_id: "99999999-1111-4111-8111-111111111111", + }, + trace: { + trace_id: "trace-exec-311", + trace_kind: "tool.proxy.execute", + }, + created_at: "2026-03-16T14:00:00Z", + updated_at: "2026-03-16T14:24:00Z", + }, + ], +}; + +export function getFixtureApproval(approvalId: string) { + return approvalFixtures.find((item) => item.id === approvalId) ?? null; +} + +export function getFixtureTrace(traceId: string) { + return traceFixtures.find((item) => item.id === traceId) ?? null; +} + +export function getFixtureTask(taskId: string) { + return taskFixtures.find((item) => item.id === taskId) ?? null; +} + +export function getFixtureExecution(executionId: string) { + return executionFixtures.find((item) => item.id === executionId) ?? null; +} + +export function getFixtureExecutionByApprovalId(approvalId: string) { + return executionFixtures.find((item) => item.approval_id === approvalId) ?? null; +} + +export function getFixtureThread(threadId: string) { + return threadFixtures.find((item) => item.id === threadId) ?? null; +} + +export function getFixtureMemory(memoryId: string) { + return memoryFixtures.find((item) => item.id === memoryId) ?? null; +} + +export function getFixtureEntity(entityId: string) { + return entityFixtures.find((item) => item.id === entityId) ?? null; +} + +export function getFixtureGmailAccount(gmailAccountId: string) { + return gmailAccountFixtures.find((item) => item.id === gmailAccountId) ?? null; +} + +export function getFixtureCalendarAccount(calendarAccountId: string) { + return calendarAccountFixtures.find((item) => item.id === calendarAccountId) ?? null; +} + +function parseFixtureDate(value: string | null | undefined) { + if (!value) { + return null; + } + + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return null; + } + + return parsed; +} + +function normalizeFixtureCalendarEventSortKey(startTime: string | null) { + if (!startTime) { + return "~"; + } + + const parsed = parseFixtureDate(startTime); + return parsed ? parsed.toISOString() : "~"; +} + +export function getFixtureCalendarEvents(calendarAccountId: string) { + return calendarEventFixtures[calendarAccountId] ?? []; +} + +export function getFixtureCalendarEventList( + calendarAccountId: string, + options?: { + limit?: number; + timeMin?: string; + timeMax?: string; + }, +): { + items: CalendarEventSummaryRecord[]; + summary: CalendarEventListSummary; +} { + const boundedLimit = Math.max(1, Math.min(50, options?.limit ?? 20)); + const timeMin = options?.timeMin?.trim() ? options.timeMin.trim() : null; + const timeMax = options?.timeMax?.trim() ? options.timeMax.trim() : null; + const timeMinDate = parseFixtureDate(timeMin); + const timeMaxDate = parseFixtureDate(timeMax); + + const filteredItems = getFixtureCalendarEvents(calendarAccountId).filter((item) => { + const startDate = parseFixtureDate(item.start_time); + + if (timeMinDate && startDate && startDate < timeMinDate) { + return false; + } + if (timeMaxDate && startDate && startDate > timeMaxDate) { + return false; + } + + return true; + }); + + const items = [...filteredItems] + .sort((left, right) => { + const leftStart = normalizeFixtureCalendarEventSortKey(left.start_time); + const rightStart = normalizeFixtureCalendarEventSortKey(right.start_time); + if (leftStart === rightStart) { + return left.provider_event_id.localeCompare(right.provider_event_id); + } + return leftStart.localeCompare(rightStart); + }) + .slice(0, boundedLimit); + + return { + items, + summary: { + total_count: items.length, + limit: boundedLimit, + order: ["start_time_asc", "provider_event_id_asc"], + time_min: timeMin, + time_max: timeMax, + }, + }; +} + +export function getFixtureTaskWorkspace(taskWorkspaceId: string) { + return taskWorkspaceFixtures.find((item) => item.id === taskWorkspaceId) ?? null; +} + +export function getFixtureTaskArtifact(taskArtifactId: string) { + return taskArtifactFixtures.find((item) => item.id === taskArtifactId) ?? null; +} + +export function getFixtureTaskArtifactChunks(taskArtifactId: string) { + return taskArtifactChunkFixtures[taskArtifactId] ?? []; +} + +export function getFixtureTaskArtifactChunkSummary(taskArtifactId: string): TaskArtifactChunkListSummary { + const artifact = getFixtureTaskArtifact(taskArtifactId); + const items = getFixtureTaskArtifactChunks(taskArtifactId); + const totalCharacters = items.reduce((acc, item) => acc + Math.max(0, item.char_end_exclusive - item.char_start), 0); + + return { + total_count: items.length, + total_characters: totalCharacters, + media_type: artifact?.media_type_hint ?? "text/plain", + chunking_rule: "artifact_ingestion_v0", + order: ["sequence_no_asc", "id_asc"], + }; +} + +export function getFixtureEntityEdges(entityId: string) { + return entityEdgeFixtures[entityId] ?? []; +} + +export function getFixtureEntityEdgeSummary(entityId: string): EntityEdgeListSummary { + const items = getFixtureEntityEdges(entityId); + return { + entity_id: entityId, + total_count: items.length, + order: ["created_at_asc", "id_asc"], + }; +} + +export function getFixtureMemoryRevisions(memoryId: string) { + return memoryRevisionFixtures[memoryId] ?? []; +} + +export function getFixtureMemoryRevisionSummary(memoryId: string): MemoryRevisionReviewListSummary { + const items = getFixtureMemoryRevisions(memoryId); + return { + memory_id: memoryId, + limit: 20, + returned_count: items.length, + total_count: items.length, + has_more: false, + order: ["sequence_no_asc"], + }; +} + +export function getFixtureMemoryLabels(memoryId: string) { + return memoryLabelFixtures[memoryId] ?? []; +} + +export function getFixtureMemoryLabelSummary(memoryId: string): MemoryReviewLabelSummary { + const existing = memoryLabelSummaryFixtures[memoryId]; + if (existing) { + return existing; + } + + return { + memory_id: memoryId, + total_count: 0, + counts_by_label: { + correct: 0, + incorrect: 0, + outdated: 0, + insufficient_evidence: 0, + }, + order: [...MEMORY_LABEL_VALUE_ORDER], + }; +} + +export function getFixtureThreadSessions(threadId: string) { + return threadSessionFixtures[threadId] ?? []; +} + +export function getFixtureThreadEvents(threadId: string) { + return threadEventFixtures[threadId] ?? []; +} + +export function getFixtureTaskSteps(taskId: string) { + return taskStepFixtures[taskId] ?? []; +} + +export function getFixtureTaskStepSummary(taskId: string): TaskStepListSummary { + const items = getFixtureTaskSteps(taskId); + const latest = items[items.length - 1]; + + return { + task_id: taskId, + total_count: items.length, + latest_sequence_no: latest?.sequence_no ?? null, + latest_status: latest?.status ?? null, + next_sequence_no: (latest?.sequence_no ?? 0) + 1, + append_allowed: false, + order: items.map((item) => item.id), + }; +} + +export function buildFixtureRequestEntry(payload: ApprovalRequestPayload): RequestHistoryEntry { + const nonce = Date.now().toString(36); + + return { + id: `fixture-request-${nonce}`, + submittedAt: new Date().toISOString(), + source: "fixture", + threadId: payload.thread_id, + toolId: payload.tool_id, + toolName: "Configured tool", + action: payload.action, + scope: payload.scope, + domainHint: payload.domain_hint, + riskHint: payload.risk_hint, + attributes: payload.attributes, + decision: "approval_required", + taskId: `fixture-task-${nonce}`, + taskStatus: "pending_approval", + approvalId: `fixture-approval-${nonce}`, + approvalStatus: "pending", + summary: + "Fixture mode prepared a governed request preview only. Add live API configuration to persist an approval and downstream task.", + reasons: [ + "Fixture mode keeps the approval-request seam explicit without inventing backend state.", + ], + trace: { + routingTraceId: `fixture-route-${nonce}`, + routingTraceEventCount: 3, + requestTraceId: `fixture-trace-${nonce}`, + requestTraceEventCount: 6, + }, + }; +} + +export function buildFixtureResponseEntry( + payload: Pick<ResponseHistoryEntry, "threadId" | "message">, +): ResponseHistoryEntry { + const nonce = Date.now().toString(36); + + return { + id: `fixture-response-${nonce}`, + submittedAt: new Date().toISOString(), + source: "fixture", + threadId: payload.threadId, + message: payload.message, + assistantText: + "Fixture mode generated a preview response only. Add live API configuration to persist the operator message, assistant reply, and linked continuity traces.", + assistantEventId: `fixture-assistant-${nonce}`, + assistantSequenceNo: 0, + modelProvider: "openai_responses", + model: "fixture-preview", + summary: + "The preview keeps the assistant-response seam explicit without inventing stored backend history.", + trace: { + compileTraceId: `fixture-compile-${nonce}`, + compileTraceEventCount: 3, + responseTraceId: `fixture-response-trace-${nonce}`, + responseTraceEventCount: 2, + }, + }; +} + +export function buildFixtureThread(title: string): ThreadItem { + const nonce = Date.now().toString(36); + const timestamp = new Date().toISOString(); + + return { + id: `fixture-thread-${nonce}`, + title, + agent_profile_id: DEFAULT_AGENT_PROFILE_ID, + created_at: timestamp, + updated_at: timestamp, + }; +} diff --git a/apps/web/lib/memory-quality.test.ts b/apps/web/lib/memory-quality.test.ts new file mode 100644 index 0000000..281fdcb --- /dev/null +++ b/apps/web/lib/memory-quality.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; + +import { deriveMemoryQualityGate, formatPrecisionPercent } from "./memory-quality"; + +describe("memory quality gate utility", () => { + it("returns unavailable posture when quality-gate payload is missing", () => { + const gate = deriveMemoryQualityGate(null); + + expect(gate.status).toBe("unavailable"); + expect(gate.precision).toBeNull(); + expect(gate.adjudicatedSampleCount).toBeNull(); + expect(gate.remainingToMinimumSample).toBeNull(); + expect(gate.unlabeledQueueCount).toBeNull(); + expect(gate.precisionTarget).toBeNull(); + expect(gate.minimumAdjudicatedSample).toBeNull(); + }); + + it("maps canonical quality-gate payload values without local threshold recomputation", () => { + const gate = deriveMemoryQualityGate({ + total_memory_count: 12, + active_memory_count: 11, + deleted_memory_count: 1, + labeled_memory_count: 10, + unlabeled_memory_count: 1, + total_label_row_count: 10, + label_row_counts_by_value: { + correct: 9, + incorrect: 1, + outdated: 0, + insufficient_evidence: 0, + }, + label_value_order: ["correct", "incorrect", "outdated", "insufficient_evidence"], + quality_gate: { + status: "needs_review", + precision: 0.9, + precision_target: 0.8, + adjudicated_sample_count: 10, + minimum_adjudicated_sample: 10, + remaining_to_minimum_sample: 0, + unlabeled_memory_count: 1, + high_risk_memory_count: 1, + stale_truth_count: 0, + superseded_active_conflict_count: 0, + counts: { + active_memory_count: 11, + labeled_active_memory_count: 10, + adjudicated_correct_count: 9, + adjudicated_incorrect_count: 1, + outdated_label_count: 0, + insufficient_evidence_label_count: 0, + }, + }, + }); + + expect(gate.status).toBe("needs_review"); + expect(gate.precision).toBe(0.9); + expect(gate.adjudicatedSampleCount).toBe(10); + expect(gate.remainingToMinimumSample).toBe(0); + expect(gate.unlabeledQueueCount).toBe(1); + expect(gate.highRiskMemoryCount).toBe(1); + expect(gate.staleTruthCount).toBe(0); + expect(gate.supersededActiveConflictCount).toBe(0); + expect(gate.precisionTarget).toBe(0.8); + expect(gate.minimumAdjudicatedSample).toBe(10); + }); + + it("formats unavailable precision explicitly", () => { + expect(formatPrecisionPercent(null)).toBe("—"); + }); +}); diff --git a/apps/web/lib/memory-quality.ts b/apps/web/lib/memory-quality.ts new file mode 100644 index 0000000..bd5f1fe --- /dev/null +++ b/apps/web/lib/memory-quality.ts @@ -0,0 +1,55 @@ +import type { MemoryEvaluationSummary, MemoryQualityGateSummary } from "./api"; + +export type MemoryQualityGate = { + status: MemoryQualityGateSummary["status"] | "unavailable"; + precision: number | null; + adjudicatedSampleCount: number | null; + remainingToMinimumSample: number | null; + unlabeledQueueCount: number | null; + highRiskMemoryCount: number | null; + staleTruthCount: number | null; + supersededActiveConflictCount: number | null; + precisionTarget: number | null; + minimumAdjudicatedSample: number | null; +}; + +export function deriveMemoryQualityGate( + summary: MemoryEvaluationSummary | null | undefined, +): MemoryQualityGate { + const qualityGate = summary?.quality_gate; + if (!qualityGate) { + return { + status: "unavailable", + precision: null, + adjudicatedSampleCount: null, + remainingToMinimumSample: null, + unlabeledQueueCount: null, + highRiskMemoryCount: null, + staleTruthCount: null, + supersededActiveConflictCount: null, + precisionTarget: null, + minimumAdjudicatedSample: null, + }; + } + + return { + status: qualityGate.status, + precision: qualityGate.precision, + adjudicatedSampleCount: qualityGate.adjudicated_sample_count, + remainingToMinimumSample: qualityGate.remaining_to_minimum_sample, + unlabeledQueueCount: qualityGate.unlabeled_memory_count, + highRiskMemoryCount: qualityGate.high_risk_memory_count, + staleTruthCount: qualityGate.stale_truth_count, + supersededActiveConflictCount: qualityGate.superseded_active_conflict_count, + precisionTarget: qualityGate.precision_target, + minimumAdjudicatedSample: qualityGate.minimum_adjudicated_sample, + }; +} + +export function formatPrecisionPercent(precision: number | null) { + if (precision === null) { + return "—"; + } + + return `${Math.round(precision * 100)}%`; +} diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts new file mode 100644 index 0000000..1b3be08 --- /dev/null +++ b/apps/web/next-env.d.ts @@ -0,0 +1,5 @@ +/// <reference types="next" /> +/// <reference types="next/image-types/global" /> + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs new file mode 100644 index 0000000..06cd07e --- /dev/null +++ b/apps/web/next.config.mjs @@ -0,0 +1,6 @@ +const nextConfig = { + reactStrictMode: true, +}; + +export default nextConfig; + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..a275db3 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,30 @@ +{ + "name": "@alicebot/web", + "private": true, + "version": "0.1.0", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint . --max-warnings=0", + "test": "vitest run", + "test:mvp:validation-matrix": "vitest run app/chat/page.test.tsx components/approval-actions.test.tsx components/approval-detail.test.tsx components/task-step-list.test.tsx components/thread-workflow-panel.test.tsx app/artifacts/page.test.tsx components/gmail-account-list.test.tsx components/gmail-message-ingest-form.test.tsx app/calendar/page.test.tsx app/memories/page.test.tsx app/entities/page.test.tsx components/trace-list.test.tsx lib/api.test.ts" + }, + "dependencies": { + "next": "15.2.0", + "react": "19.0.0", + "react-dom": "19.0.0" + }, + "devDependencies": { + "@testing-library/jest-dom": "6.6.3", + "@testing-library/react": "16.3.0", + "@types/node": "22.13.10", + "@types/react": "19.0.10", + "@types/react-dom": "19.0.4", + "eslint": "9.22.0", + "eslint-config-next": "15.2.0", + "jsdom": "26.0.0", + "typescript": "5.8.2", + "vitest": "3.0.8" + } +} diff --git a/apps/web/test/setup.ts b/apps/web/test/setup.ts new file mode 100644 index 0000000..f149f27 --- /dev/null +++ b/apps/web/test/setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom/vitest"; diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..b569253 --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,35 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": [ + "dom", + "dom.iterable", + "es2022" + ], + "allowJs": false, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "esModuleInterop": true, + "plugins": [ + { + "name": "next" + } + ] + }, + "include": [ + "**/*.ts", + "**/*.tsx", + "next-env.d.ts", + ".next/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts new file mode 100644 index 0000000..6fd0651 --- /dev/null +++ b/apps/web/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + esbuild: { + jsx: "automatic", + }, + test: { + environment: "jsdom", + setupFiles: ["./test/setup.ts"], + include: ["./**/*.{test,spec}.{ts,tsx}"], + }, +}); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2066a2b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,36 @@ +services: + postgres: + image: pgvector/pgvector:pg16 + container_name: alicebot-postgres + environment: + POSTGRES_USER: alicebot_admin + POSTGRES_PASSWORD: alicebot_admin + POSTGRES_DB: alicebot + ports: + - "127.0.0.1:5432:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + - ./infra/postgres/init:/docker-entrypoint-initdb.d:ro + + redis: + image: redis:7-alpine + container_name: alicebot-redis + ports: + - "127.0.0.1:6379:6379" + + minio: + image: minio/minio:RELEASE.2025-02-28T09-55-16Z + container_name: alicebot-minio + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: alicebot + MINIO_ROOT_PASSWORD: alicebot-secret + ports: + - "127.0.0.1:9000:9000" + - "127.0.0.1:9001:9001" + volumes: + - minio-data:/data + +volumes: + postgres-data: + minio-data: diff --git a/docs/adr/.gitkeep b/docs/adr/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/docs/adr/.gitkeep @@ -0,0 +1 @@ + diff --git a/docs/adr/ADR-001-public-core-package-boundary.md b/docs/adr/ADR-001-public-core-package-boundary.md new file mode 100644 index 0000000..d13011a --- /dev/null +++ b/docs/adr/ADR-001-public-core-package-boundary.md @@ -0,0 +1,56 @@ +# ADR-001: Public Core Package Boundary + +## Status + +Accepted (2026-04-07) + +## Context + +Phase 9 packages an internal continuity and chief-of-staff substrate into a public technical product. The repo currently mixes shipped internal product surfaces, planning docs, and future public surfaces. Without an explicit package boundary, later CLI, MCP, importer, and adapter work will target unstable seams and keep reopening the same scope question. + +## Decision + +Define a narrow public package boundary centered on `alice-core`. + +`alice-core` should own: + +- continuity capture +- recall and retrieval +- resumption-brief generation +- open-loop retrieval +- correction-aware memory update paths +- trust-calibrated retrieval semantics + +Keep these outside the initial public core or explicitly deferred: + +- chief-of-staff UI and operator-specific workspaces +- broad autonomous execution surfaces +- channel integrations +- deep vertical workflows +- internal release-control and review tooling not required for public operation +- OSS license selection (tracked separately as deferred launch governance) + +Public CLI, MCP, importers, and external adapters should depend on the documented `alice-core` boundary rather than reaching through internal app seams ad hoc. + +## Consequences + +Positive: + +- gives Sprint 34 and Sprint 35 stable build targets +- reduces accidental platform sprawl in the first public release +- makes docs, packaging, and testing easier to align + +Negative: + +- may require temporary duplication or wrapper seams while the repo transitions toward cleaner packaging +- leaves some internal product features deliberately outside the first public release + +## Alternatives Considered + +### Expose the full current repo as the public product + +Rejected for Phase 9 because it would blur internal operator surfaces with the public continuity contract and enlarge the support surface too early. + +### Delay package-boundary decisions until CLI and MCP implementation + +Rejected because it would create churn across multiple follow-on sprints and make interop contracts unstable. diff --git a/docs/adr/ADR-002-public-runtime-baseline.md b/docs/adr/ADR-002-public-runtime-baseline.md new file mode 100644 index 0000000..ad2a51d --- /dev/null +++ b/docs/adr/ADR-002-public-runtime-baseline.md @@ -0,0 +1,44 @@ +# ADR-002: Public Runtime Baseline + +## Status + +Accepted (2026-04-07) + +## Context + +Alice currently runs locally with Postgres, `pgvector`, Redis, and MinIO in Docker Compose. Phase 9 needs a public runtime story that external technical users can install and verify without ambiguity. Supporting multiple storage/runtime modes too early would increase docs drift and make retrieval semantics harder to keep consistent. + +## Decision + +Adopt one supported public runtime baseline for Phase 9: + +- Postgres +- `pgvector` +- Docker Compose for local infrastructure + +Treat this as the canonical v0.1 setup path. Do not claim SQLite or other reduced local modes as supported public runtimes unless they preserve Alice’s continuity, retrieval, and correction semantics without special-case behavior. + +Redis and MinIO remain acceptable support services when required by the current product, but the primary public runtime promise is the Postgres-backed local install path. + +## Consequences + +Positive: + +- keeps the public quickstart simple and testable +- preserves retrieval and memory semantics already proven internally +- avoids splitting engineering time across multiple weakly supported install modes + +Negative: + +- raises the minimum local setup bar for some users +- defers simpler single-file runtimes unless they are validated later + +## Alternatives Considered + +### Support SQLite immediately as a public fallback + +Rejected for Phase 9 because it risks semantic drift and extra support burden before the public product contract is stable. + +### Support multiple deployment modes at launch + +Rejected because Phase 9 needs one reliable path more than optional breadth. diff --git a/docs/adr/ADR-003-mcp-tool-surface-contract.md b/docs/adr/ADR-003-mcp-tool-surface-contract.md new file mode 100644 index 0000000..e541c77 --- /dev/null +++ b/docs/adr/ADR-003-mcp-tool-surface-contract.md @@ -0,0 +1,55 @@ +# ADR-003: MCP Tool Surface Contract + +## Status + +Accepted (2026-04-07) + +## Context + +Phase 9 includes exposing Alice through an MCP server so external assistants can use Alice as a memory and continuity layer. A large or unstable MCP surface would increase support burden, make evaluation harder, and encourage clients to depend on accidental internal behavior rather than the intended continuity contract. + +## Decision + +Start with a deliberately small MCP tool surface aligned to the public v0.1 contract. + +Recommended first tools: + +- `alice_capture` +- `alice_recall` +- `alice_resume` +- `alice_open_loops` +- `alice_recent_decisions` +- `alice_recent_changes` +- `alice_memory_review` +- `alice_memory_correct` +- `alice_context_pack` + +Tool design rules: + +- inputs and outputs must be deterministic and provenance-backed where applicable +- names should describe user-facing continuity jobs, not internal implementation details +- tool semantics must stay narrow and stable across early public releases +- do not expose write-capable side effects beyond Alice’s own continuity and correction domain in the initial MCP release + +## Consequences + +Positive: + +- keeps the interop story understandable and testable +- aligns MCP behavior with the product wedge instead of generic agent sprawl +- limits contract churn for early adopters + +Negative: + +- some external-agent use cases will need to wait for later MCP expansion +- pressure may remain to expose internal helper seams that are not yet stable + +## Alternatives Considered + +### Publish a broad MCP surface from the current internal API + +Rejected because it would expose unstable internals and create support obligations before the public contract is proven. + +### Delay MCP boundary decisions until Sprint 35 + +Rejected because Sprint 33 and Sprint 34 need a stable public contract to package and document against. diff --git a/docs/adr/ADR-004-openclaw-integration-boundary.md b/docs/adr/ADR-004-openclaw-integration-boundary.md new file mode 100644 index 0000000..89eb2ac --- /dev/null +++ b/docs/adr/ADR-004-openclaw-integration-boundary.md @@ -0,0 +1,50 @@ +# ADR-004: OpenClaw Integration Boundary + +## Status + +Accepted (2026-04-07) + +## Context + +`P9-S36` is the first external adapter sprint after the shipped public-core (`P9-S33`), CLI (`P9-S34`), and MCP transport (`P9-S35`) seams. The product goal for this sprint is proving Alice can ingest external agent memory while preserving the same continuity semantics already shipped. + +A broad importer framework in this sprint would add contract risk and blur the boundary between adapter work and platform work. + +## Decision + +Adopt a narrow OpenClaw-first integration boundary in `P9-S36`: + +- support file-based OpenClaw import only (JSON file or workspace directory with JSON memory payloads) +- map OpenClaw memory entries into shipped Alice continuity objects (no bypass path) +- preserve explicit imported provenance with `source_kind = openclaw_import` +- apply deterministic dedupe using a stable workspace+payload fingerprint +- keep MCP augmentation limited to existing shipped tools (`alice_recall`, `alice_resume`, etc.) without adding new MCP tools + +Input contract for this sprint is intentionally small: + +- root object payloads with one of `durable_memory`, `memories`, `items`, or `records` +- optional `workspace` metadata object +- optional directory contract using known JSON filenames (`workspace.json`, `openclaw_workspace.json`, `durable_memory.json`, `memories.json`, `openclaw_memories.json`) + +## Consequences + +Positive: + +- proves real external adapter ingestion without reopening continuity semantics +- keeps import behavior auditable and deterministic +- gives a concrete template for future importer work in `P9-S37` + +Negative: + +- does not yet provide generalized multi-source importer abstractions +- non-OpenClaw sources remain out of scope for this sprint + +## Alternatives Considered + +### Introduce a generic importer framework in `P9-S36` + +Rejected because it increases scope and contract surface before the first concrete adapter is proven. + +### Add new MCP import tools for OpenClaw + +Rejected because MCP surface expansion is out of `P9-S36` scope and would dilute parity guarantees with shipped continuity seams. diff --git a/docs/adr/ADR-005-import-provenance-and-dedupe-strategy.md b/docs/adr/ADR-005-import-provenance-and-dedupe-strategy.md new file mode 100644 index 0000000..e6aadf9 --- /dev/null +++ b/docs/adr/ADR-005-import-provenance-and-dedupe-strategy.md @@ -0,0 +1,45 @@ +# ADR-005: Import Provenance and Dedupe Strategy + +## Status + +Accepted (2026-04-08) + +## Context + +`P9-S37` broadens importer coverage from a single OpenClaw adapter to multiple production-usable import paths. Without one shared persistence strategy, importer behavior can drift on provenance fields, dedupe semantics, and replay outcomes. + +The sprint requires deterministic duplicate-memory posture and explicit provenance across every shipped importer. + +## Decision + +Adopt one shared importer persistence strategy for all shipped `P9-S37` importers: + +- all importer writes go through one shared persistence seam (`importers/common.py`) +- each importer must persist explicit `source_kind` +- each importer must persist a source-specific deterministic dedupe key in provenance (`<source>_dedupe_key`) +- each importer must persist source-specific context metadata (`<source>_workspace_id`, `<source>_source_path`, `<source>_source_item_id`, etc.) +- dedupe posture is deterministic and measured by replaying the same fixture and expecting `status=noop` with full duplicate skip counts +- importers map into the same shipped continuity capture/object model; they do not introduce source-specific retrieval semantics + +## Consequences + +Positive: + +- importer behavior stays consistent and auditable across OpenClaw, Markdown, and ChatGPT import paths +- dedupe and provenance posture are testable with one shared expectation model +- future importer additions can reuse the same persistence contract + +Negative: + +- importer-specific provenance key names remain source-prefixed, so field vocabulary is intentionally explicit rather than fully normalized +- importer adapters still need source-specific normalization logic before shared persistence + +## Alternatives Considered + +### Keep per-importer persistence logic fully separate + +Rejected because it encourages dedupe/provenance drift and makes cross-importer evaluation less reliable. + +### Normalize all importer provenance into one unprefixed schema immediately + +Rejected in `P9-S37` because it increases migration risk and coupling without improving short-term reproducibility goals. diff --git a/docs/adr/ADR-007-public-evaluation-harness-scope.md b/docs/adr/ADR-007-public-evaluation-harness-scope.md new file mode 100644 index 0000000..6d48894 --- /dev/null +++ b/docs/adr/ADR-007-public-evaluation-harness-scope.md @@ -0,0 +1,48 @@ +# ADR-007: Public Evaluation Harness Scope + +## Status + +Accepted (2026-04-08) + +## Context + +`P9-S37` needs reproducible evidence that importer-expanded continuity data improves useful recall/resumption outcomes and remains correction-aware. Prior retrieval evaluation fixtures exist, but importer and correction posture claims require a sprint-specific harness with fixture-backed import replay. + +## Decision + +Define the `P9-S37` public evaluation harness scope as local, fixture-backed, and command-driven: + +- shipped command: `./scripts/run_phase9_eval.sh` +- shipped fixture inputs: OpenClaw, Markdown, and ChatGPT fixture sources in-repo +- shipped report outputs: JSON reports under `eval/reports/` and committed baseline under `eval/baselines/` +- required measured metrics: + - importer success rate + - duplicate-memory posture rate + - recall precision-at-1 on importer-scoped queries + - resumption usefulness rate (decision + next-action usefulness in scoped briefs) + - correction effectiveness rate (supersede correction changing top recall result) + +Harness scope is intentionally local-first and deterministic. It does not include hosted telemetry, external benchmark providers, or remote evaluation infrastructure. + +## Consequences + +Positive: + +- quality claims are reproducible from documented commands and repo-local fixtures +- importer and correction outcomes are measured together, not in isolated success-only checks +- launch docs in `P9-S38` can cite committed baseline evidence directly + +Negative: + +- harness results are scoped to local deterministic fixtures, not production traffic variation +- broader benchmark dimensions remain deferred beyond `P9-S37` + +## Alternatives Considered + +### Keep only retrieval fixture evaluation without importer replay + +Rejected because it would miss importer success/duplicate posture and correction-aware continuity evidence required by sprint acceptance. + +### Build hosted benchmark infrastructure in `P9-S37` + +Rejected because hosted evaluation is out of scope and would delay shipping deterministic local evidence. diff --git a/docs/archive/.gitkeep b/docs/archive/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/docs/archive/.gitkeep @@ -0,0 +1 @@ + diff --git a/docs/examples/phase9-command-walkthrough.md b/docs/examples/phase9-command-walkthrough.md new file mode 100644 index 0000000..ef6250a --- /dev/null +++ b/docs/examples/phase9-command-walkthrough.md @@ -0,0 +1,41 @@ +# Phase 9 Command Walkthrough + +This page provides one reproducible command walkthrough using only shipped local paths. + +## Scenario + +1. Start local runtime. +2. Verify health. +3. Run one-command OpenClaw demo (`before -> import -> replay -> after`). +4. Generate evaluation report. + +## Commands + +```bash +docker compose up -d +./scripts/migrate.sh +./scripts/load_sample_data.sh +APP_RELOAD=false ./scripts/api_dev.sh +``` + +```bash +curl -sS http://127.0.0.1:8000/healthz +./scripts/use_alice_with_openclaw.sh +``` + +```bash +EVAL_USER_ID="$(./.venv/bin/python -c 'import uuid; print(uuid.uuid4())')" +EVAL_USER_EMAIL="phase9-eval-${EVAL_USER_ID}@example.com" +./scripts/run_phase9_eval.sh --user-id "${EVAL_USER_ID}" --user-email "${EVAL_USER_EMAIL}" --display-name "Phase9 Eval" --report-path eval/reports/phase9_eval_latest.json +``` + +## Expected Outcomes + +- API health check returns `status=ok`. +- OpenClaw demo shows before/after recall-resume value and idempotent replay. +- imported provenance includes source label `OpenClaw` and `source_kind=openclaw_import`. +- evaluation harness writes report JSON with summary status and metrics. + +## Notes + +Use a unique `--user-email` and `--user-id` if re-running import/eval flows in the same database and you need a fresh user scope. diff --git a/docs/integrations/assets/hermes/hermes-mcp-test.png b/docs/integrations/assets/hermes/hermes-mcp-test.png new file mode 100644 index 0000000..d93a57a Binary files /dev/null and b/docs/integrations/assets/hermes/hermes-mcp-test.png differ diff --git a/docs/integrations/assets/hermes/hermes-mcp-test.txt b/docs/integrations/assets/hermes/hermes-mcp-test.txt new file mode 100644 index 0000000..e304594 --- /dev/null +++ b/docs/integrations/assets/hermes/hermes-mcp-test.txt @@ -0,0 +1,17 @@ + + Testing 'alice_core'... + Transport: stdio → npx + Auth: none + ✓ Connected (653ms) + ✓ Tools discovered: 9 + + alice_capture Capture continuity input into deterministic continuity ... + alice_recall Recall continuity objects with deterministic ranking an... + alice_resume Compile continuity resumption brief for decisions, open... + alice_open_loops List continuity open loops grouped by deterministic pos... + alice_recent_decisions List most recent continuity decisions in deterministic ... + alice_recent_changes List recent continuity changes from the shipped resumpt... + alice_memory_review List correction review queue or fetch review detail for... + alice_memory_correct Apply deterministic continuity correction actions and r... + alice_context_pack Assemble a deterministic continuity context pack for sc... + diff --git a/docs/integrations/assets/hermes/hermes-runtime-smoke.png b/docs/integrations/assets/hermes/hermes-runtime-smoke.png new file mode 100644 index 0000000..698baef Binary files /dev/null and b/docs/integrations/assets/hermes/hermes-runtime-smoke.png differ diff --git a/docs/integrations/assets/hermes/hermes-runtime-smoke.txt b/docs/integrations/assets/hermes/hermes-runtime-smoke.txt new file mode 100644 index 0000000..b890fe5 --- /dev/null +++ b/docs/integrations/assets/hermes/hermes-runtime-smoke.txt @@ -0,0 +1 @@ +{"open_loop_count":1,"recall_items":2,"registered_tools":["mcp_alice_core_alice_open_loops","mcp_alice_core_alice_recall","mcp_alice_core_alice_resume"],"resume_last_decision_title":"Decision: Keep Alice MCP local-first for Hermes verification."} diff --git a/docs/integrations/cli.md b/docs/integrations/cli.md new file mode 100644 index 0000000..3387f25 --- /dev/null +++ b/docs/integrations/cli.md @@ -0,0 +1,46 @@ +# CLI Integration + +The shipped CLI surface (`P9-S34`) runs against the same local runtime used by API and MCP. + +## Entrypoints + +```bash +./.venv/bin/python -m alicebot_api --help +alicebot --help +``` + +`alicebot` is available after editable install (`pip install -e '.[dev]'`). + +## User Scope + +- default user scope comes from `ALICEBOT_AUTH_USER_ID` +- fallback default if unset: `00000000-0000-0000-0000-000000000001` + +## Core Commands + +```bash +./.venv/bin/python -m alicebot_api status +./.venv/bin/python -m alicebot_api capture "Decision: Keep Alice local-first for verification." --explicit-signal decision +./.venv/bin/python -m alicebot_api recall --query local-first --limit 5 +./.venv/bin/python -m alicebot_api resume --max-recent-changes 5 --max-open-loops 5 +./.venv/bin/python -m alicebot_api open-loops +``` + +## Review and Correction Commands + +```bash +./.venv/bin/python -m alicebot_api review queue --status correction_ready --limit 20 +./.venv/bin/python -m alicebot_api review show <continuity_object_id> +./.venv/bin/python -m alicebot_api review apply <continuity_object_id> --action supersede --replacement-title "Decision: Updated title" --replacement-body-json '{"decision_text":"Updated title"}' --replacement-provenance-json '{"thread_id":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"}' --replacement-confidence 0.97 +``` + +## Determinism Contract + +- output format is deterministic for stable automated validation +- provenance snippets remain visible in recall/resume responses +- correction flow updates future recall/resume results + +See tests: + +- `tests/integration/test_mcp_cli_parity.py` +- `tests/integration/test_mcp_server.py` diff --git a/docs/integrations/hermes-skill-pack.md b/docs/integrations/hermes-skill-pack.md new file mode 100644 index 0000000..1838399 --- /dev/null +++ b/docs/integrations/hermes-skill-pack.md @@ -0,0 +1,86 @@ +# Hermes Skill Pack for Alice Workflows + +This pack provides Hermes-native skills that guide when and how to call Alice MCP tools. + +## What This Pack Includes + +Pack location in this repository: + +- `docs/integrations/hermes-skill-pack/skills/alice-workflows/` + +Skills: + +- `alice-continuity-recall` +- `alice-resumption` +- `alice-open-loop-review` +- `alice-explain-provenance` +- `alice-correction-loop` + +## Skill to Tool Map + +| Skill | Primary Alice MCP tools | +|---|---| +| `alice-continuity-recall` | `alice_recall`, `alice_recent_decisions` | +| `alice-resumption` | `alice_resume`, `alice_context_pack` | +| `alice-open-loop-review` | `alice_open_loops`, `alice_recent_changes` | +| `alice-explain-provenance` | `alice_context_pack`, `alice_recall` | +| `alice-correction-loop` | `alice_memory_review`, `alice_memory_correct`, `alice_recall`/`alice_resume` | + +## Install + +From the Alice repository root: + +```bash +export HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}" +mkdir -p "$HERMES_HOME/skills" +cp -R docs/integrations/hermes-skill-pack/skills/alice-workflows "$HERMES_HOME/skills/" +``` + +## Verify Installation + +```bash +HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}" \ + ./.venv/bin/hermes skills list --source local +``` + +You should see the five `alice-*` skills listed. + +To confirm three core skills quickly: + +```bash +HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}" \ + ./.venv/bin/hermes skills list --source local | \ + rg "alice-continuity-recall|alice-resumption|alice-open-loop-review" +``` + +## Skills vs MCP: Responsibility Split + +- Skills: decision policy and workflow instructions (when to call tools, how to format output, what evidence to include). +- MCP tools: runtime execution and deterministic continuity data retrieval/update. +- Practical rule: use skills to decide behavior; use MCP tools to fetch or mutate continuity state. + +## When Hermes Should Prefer Alice Tools + +Use Alice tools instead of inference-only answers when: + +- the user asks for prior decisions, commitments, or timeline details +- the user asks to continue interrupted work with concrete next actions +- the user asks which blockers/open loops remain +- the user asks for provenance or evidence behind a claim +- the user asks to correct stale or incorrect continuity records + +Concrete examples: + +- "What did we decide about rollout gating last week?" -> prefer `alice_recall` +- "Resume thread `<uuid>` and give next action plus blockers." -> prefer `alice_resume` +- "What is still blocked for this project?" -> prefer `alice_open_loops` +- "Why do you think this is true?" -> prefer `alice_context_pack` + `alice_recall` +- "This memory is outdated, replace it." -> prefer `alice_memory_review` + `alice_memory_correct` + +## Suggested Skill Loading + +```bash +./.venv/bin/hermes -s alice-resumption -s alice-open-loop-review +``` + +Add `alice-explain-provenance` for evidence-first responses and `alice-correction-loop` for correction-heavy sessions. diff --git a/docs/integrations/hermes-skill-pack/skills/alice-workflows/DESCRIPTION.md b/docs/integrations/hermes-skill-pack/skills/alice-workflows/DESCRIPTION.md new file mode 100644 index 0000000..b868da0 --- /dev/null +++ b/docs/integrations/hermes-skill-pack/skills/alice-workflows/DESCRIPTION.md @@ -0,0 +1,3 @@ +--- +description: Alice workflow skills for Hermes. These skills route continuity work to Alice MCP tools for recall, resumption, open-loop review, provenance explanation, and correction updates. +--- diff --git a/docs/integrations/hermes-skill-pack/skills/alice-workflows/alice-continuity-recall/SKILL.md b/docs/integrations/hermes-skill-pack/skills/alice-workflows/alice-continuity-recall/SKILL.md new file mode 100644 index 0000000..2f69156 --- /dev/null +++ b/docs/integrations/hermes-skill-pack/skills/alice-workflows/alice-continuity-recall/SKILL.md @@ -0,0 +1,56 @@ +--- +name: alice-continuity-recall +description: Use Alice MCP recall tools for continuity-grounded answers with provenance when users ask what was decided, what changed, or what to remember. +version: 1.0.0 +author: Alice +license: MIT +metadata: + hermes: + tags: [alice, continuity, recall, mcp] + related_skills: [alice-resumption, alice-explain-provenance] +--- + +# Alice Continuity Recall + +## Goal + +Produce recall answers from Alice continuity records instead of free-form memory. + +## Trigger Cues + +Use this skill when the user asks: +- what was decided +- what happened in a thread/project/person scope +- what should be remembered from prior work + +## Required MCP Tools + +- `mcp_<alice_server>_alice_recall` +- Optional: `mcp_<alice_server>_alice_recent_decisions` + +`<alice_server>` is usually `alice_core`. + +## Workflow + +1. Prefer `alice_recall` over inference-only answers. +2. Use scope filters when available (`thread_id`, `project`, `person`, `since`, `until`). +3. Keep `limit` bounded (normally `3` to `10`). +4. Return summary plus provenance-backed evidence IDs. + +## Tool Call Templates + +```text +mcp_alice_core_alice_recall({"query":"<topic>","thread_id":"<uuid>","limit":5}) +``` + +```text +mcp_alice_core_alice_recent_decisions({"thread_id":"<uuid>","limit":5}) +``` + +## Output Contract + +Always include: +- direct answer +- top evidence items (`id`, `title`, `object_type`) +- provenance notes from the returned item fields +- uncertainty note if evidence is weak or absent diff --git a/docs/integrations/hermes-skill-pack/skills/alice-workflows/alice-correction-loop/SKILL.md b/docs/integrations/hermes-skill-pack/skills/alice-workflows/alice-correction-loop/SKILL.md new file mode 100644 index 0000000..6732705 --- /dev/null +++ b/docs/integrations/hermes-skill-pack/skills/alice-workflows/alice-correction-loop/SKILL.md @@ -0,0 +1,63 @@ +--- +name: alice-correction-loop +description: Run deterministic correction workflows with Alice MCP review and correction tools, then verify that future outputs reflect the update. +version: 1.0.0 +author: Alice +license: MIT +metadata: + hermes: + tags: [alice, continuity, correction, review, mcp] + related_skills: [alice-explain-provenance, alice-open-loop-review] +--- + +# Alice Correction Loop + +## Goal + +Apply corrections through Alice review tools and confirm that recall/resumption behavior updates accordingly. + +## Trigger Cues + +Use this skill when the user asks: +- this is outdated or wrong +- correct this memory +- supersede or mark stale + +## Required MCP Tools + +- `mcp_<alice_server>_alice_memory_review` +- `mcp_<alice_server>_alice_memory_correct` +- Verification: `mcp_<alice_server>_alice_recall` or `mcp_<alice_server>_alice_resume` + +`<alice_server>` is usually `alice_core`. + +## Workflow + +1. Fetch review queue or detail with `alice_memory_review`. +2. Select correction action: + - `confirm` + - `edit` + - `delete` + - `supersede` + - `mark_stale` +3. Apply correction with `alice_memory_correct`. +4. Re-run `alice_recall` or `alice_resume` to verify behavior changed. +5. Report both the correction action and the observed post-correction result. + +## Tool Call Templates + +```text +mcp_alice_core_alice_memory_review({"status":"correction_ready","limit":10}) +``` + +```text +mcp_alice_core_alice_memory_correct({"continuity_object_id":"<uuid>","action":"supersede","replacement_title":"<title>","replacement_body":{}}) +``` + +## Output Contract + +Always include: +- corrected object ID +- action applied +- reason +- post-correction verification result diff --git a/docs/integrations/hermes-skill-pack/skills/alice-workflows/alice-explain-provenance/SKILL.md b/docs/integrations/hermes-skill-pack/skills/alice-workflows/alice-explain-provenance/SKILL.md new file mode 100644 index 0000000..f9b777e --- /dev/null +++ b/docs/integrations/hermes-skill-pack/skills/alice-workflows/alice-explain-provenance/SKILL.md @@ -0,0 +1,56 @@ +--- +name: alice-explain-provenance +description: Explain why an answer is trustworthy by referencing Alice continuity provenance and correction state from MCP tool outputs. +version: 1.0.0 +author: Alice +license: MIT +metadata: + hermes: + tags: [alice, continuity, provenance, explainability, mcp] + related_skills: [alice-continuity-recall, alice-correction-loop] +--- + +# Alice Explain Provenance + +## Goal + +Provide evidence-backed explanations for Alice-based answers. + +## Trigger Cues + +Use this skill when the user asks: +- why are you saying this +- what source supports this +- can you show evidence or provenance + +## Required MCP Tools + +- `mcp_<alice_server>_alice_context_pack` +- Optional: `mcp_<alice_server>_alice_recall` + +`<alice_server>` is usually `alice_core`. + +## Workflow + +1. Start from `alice_context_pack` for a scoped evidence set. +2. If needed, run a focused `alice_recall` query for missing evidence. +3. Explain answer claims by citing returned continuity object IDs and provenance fields. +4. If provenance is thin, state uncertainty and propose the next validating step. + +## Tool Call Templates + +```text +mcp_alice_core_alice_context_pack({"thread_id":"<uuid>","recent_decisions_limit":5,"recent_changes_limit":5,"open_loops_limit":5}) +``` + +```text +mcp_alice_core_alice_recall({"thread_id":"<uuid>","query":"<claim>","limit":5}) +``` + +## Output Contract + +Always include: +- claim +- supporting object IDs +- provenance summary from returned records +- confidence posture (`high`, `medium`, `low`) based on evidence quality diff --git a/docs/integrations/hermes-skill-pack/skills/alice-workflows/alice-open-loop-review/SKILL.md b/docs/integrations/hermes-skill-pack/skills/alice-workflows/alice-open-loop-review/SKILL.md new file mode 100644 index 0000000..1413368 --- /dev/null +++ b/docs/integrations/hermes-skill-pack/skills/alice-workflows/alice-open-loop-review/SKILL.md @@ -0,0 +1,55 @@ +--- +name: alice-open-loop-review +description: Use Alice open-loop MCP tools to review unresolved commitments and prioritize concrete next actions. +version: 1.0.0 +author: Alice +license: MIT +metadata: + hermes: + tags: [alice, continuity, open-loops, review, mcp] + related_skills: [alice-resumption, alice-correction-loop] +--- + +# Alice Open-Loop Review + +## Goal + +Turn open loops into a prioritized action queue grounded in Alice continuity state. + +## Trigger Cues + +Use this skill when the user asks: +- what is still open +- what is blocked or waiting +- what should I do next from unresolved items + +## Required MCP Tools + +- `mcp_<alice_server>_alice_open_loops` +- Optional: `mcp_<alice_server>_alice_recent_changes` + +`<alice_server>` is usually `alice_core`. + +## Workflow + +1. Call `alice_open_loops` for the relevant scope. +2. Keep output grouped by posture: + - `waiting_for` + - `blocker` + - `stale` + - `next_action` +3. Surface top priorities first (blocked and overdue items before low-risk follow-ups). +4. Convert each group into explicit next steps. + +## Tool Call Template + +```text +mcp_alice_core_alice_open_loops({"thread_id":"<uuid>","limit":10}) +``` + +## Output Contract + +Always include: +- grouped open-loop summary +- top 3 priority actions +- per-item rationale tied to returned loop posture diff --git a/docs/integrations/hermes-skill-pack/skills/alice-workflows/alice-resumption/SKILL.md b/docs/integrations/hermes-skill-pack/skills/alice-workflows/alice-resumption/SKILL.md new file mode 100644 index 0000000..f4faff6 --- /dev/null +++ b/docs/integrations/hermes-skill-pack/skills/alice-workflows/alice-resumption/SKILL.md @@ -0,0 +1,61 @@ +--- +name: alice-resumption +description: Build deterministic continuation briefs with Alice MCP resumption tools when users want to pick up interrupted work. +version: 1.0.0 +author: Alice +license: MIT +metadata: + hermes: + tags: [alice, continuity, resume, mcp] + related_skills: [alice-continuity-recall, alice-open-loop-review] +--- + +# Alice Resumption + +## Goal + +Resume work from deterministic continuity state instead of reconstructing history manually. + +## Trigger Cues + +Use this skill when the user asks: +- continue where we left off +- give me a restart brief +- summarize decisions, next action, blockers + +## Required MCP Tools + +- `mcp_<alice_server>_alice_resume` +- Optional: `mcp_<alice_server>_alice_context_pack` + +`<alice_server>` is usually `alice_core`. + +## Workflow + +1. Call `alice_resume` with scoped filters and bounded limits. +2. If broader context is needed, call `alice_context_pack`. +3. Prioritize these sections in your answer: + - last decision + - next action + - open loops + - recent changes +4. Keep the final brief actionable and short. + +## Tool Call Templates + +```text +mcp_alice_core_alice_resume({"thread_id":"<uuid>","max_recent_changes":5,"max_open_loops":5}) +``` + +```text +mcp_alice_core_alice_context_pack({"thread_id":"<uuid>","recent_changes_limit":5,"open_loops_limit":5,"recent_decisions_limit":5}) +``` + +## Output Contract + +Always include: +- `last_decision` +- `next_action` +- `blockers_or_waiting_for` +- `recent_changes` +- explicit note when `thread_id` or scope is missing diff --git a/docs/integrations/hermes.md b/docs/integrations/hermes.md new file mode 100644 index 0000000..98ea164 --- /dev/null +++ b/docs/integrations/hermes.md @@ -0,0 +1,180 @@ +# Hermes MCP Integration + +This guide connects Hermes Agent to Alice MCP and verifies the exact tool path +for: + +- `alice_recall` +- `alice_resume` +- `alice_open_loops` + +## Prerequisites + +- Hermes Agent with MCP support (`hermes mcp --help` works). +- Alice local runtime is available (`./.venv/bin/python -m alicebot_api.mcp_server --help` works). +- Postgres is reachable from the machine where Hermes runs. + +## Config (`~/.hermes/config.yaml`) + +Use `mcp_servers` in Hermes config. + +### Option A: local command (direct Python) + +```yaml +mcp_servers: + alice_core: + command: "/ABS/PATH/TO/AliceBot/.venv/bin/python" + args: ["-m", "alicebot_api.mcp_server"] + env: + DATABASE_URL: "postgresql://alicebot_app:alicebot_app@localhost:5432/alicebot" + ALICEBOT_AUTH_USER_ID: "00000000-0000-0000-0000-000000000001" + PYTHONPATH: "/ABS/PATH/TO/AliceBot/apps/api/src:/ABS/PATH/TO/AliceBot/workers" + tools: + include: [alice_recall, alice_resume, alice_open_loops] + resources: false + prompts: false +``` + +### Option B: `npx` command (via `alice-cli` package) + +```yaml +mcp_servers: + alice_core: + command: "npx" + args: ["-y", "--package", "/ABS/PATH/TO/AliceBot/packages/alice-cli", "alice", "mcp"] + env: + NPM_CONFIG_CACHE: "/tmp/alice-npm-cache" + ALICEBOT_PYTHON: "/ABS/PATH/TO/AliceBot/.venv/bin/python" + DATABASE_URL: "postgresql://alicebot_app:alicebot_app@localhost:5432/alicebot" + ALICEBOT_AUTH_USER_ID: "00000000-0000-0000-0000-000000000001" + PYTHONPATH: "/ABS/PATH/TO/AliceBot/apps/api/src:/ABS/PATH/TO/AliceBot/workers" + tools: + include: [alice_recall, alice_resume, alice_open_loops] + resources: false + prompts: false +``` + +`alice mcp` shells out to `${ALICEBOT_PYTHON} -m alicebot_api.mcp_server`. + +If you have a published CLI version with `mcp` support, you can replace args +with: + +```yaml +args: ["-y", "@aliceos/alice-cli", "mcp"] +``` + +## Verify Connection + +```bash +hermes mcp test alice_core +``` + +Expected: + +- `Connected` +- `Tools discovered` +- includes `alice_recall`, `alice_resume`, `alice_open_loops` + +## Verify Tool Calls (Hermes Runtime Path) + +Run the smoke script: + +```bash +./scripts/run_hermes_mcp_smoke.py +``` + +Expected JSON output includes: + +- `registered_tools` containing: + - `mcp_alice_core_alice_recall` + - `mcp_alice_core_alice_resume` + - `mcp_alice_core_alice_open_loops` +- non-zero `recall_items` +- `open_loop_count` >= `1` + +## Sample Hermes Prompts + +Hermes prefixes MCP tools as `mcp_<server>_<tool>`. With server name +`alice_core`, the names are: + +- `mcp_alice_core_alice_recall` +- `mcp_alice_core_alice_resume` +- `mcp_alice_core_alice_open_loops` + +Prompts: + +```text +Use mcp_alice_core_alice_recall with {"query":"Hermes docs","limit":5} and summarize the top 3 memories. +``` + +```text +Use mcp_alice_core_alice_resume with {"thread_id":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","max_recent_changes":5,"max_open_loops":5}. Return only decisions, next action, and blockers. +``` + +```text +Use mcp_alice_core_alice_open_loops with {"thread_id":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","limit":10}. Group results by waiting_for, blocker, stale, next_action. +``` + +## Alice Workflow Skill Pack + +To make Alice tool usage more consistent in Hermes sessions, install the +Hermes-native Alice skill pack: + +- `docs/integrations/hermes-skill-pack.md` + +The pack includes: + +- `alice-continuity-recall` +- `alice-resumption` +- `alice-open-loop-review` +- `alice-explain-provenance` +- `alice-correction-loop` + +Skills decide when and how to call tools. MCP tools perform deterministic +continuity reads and writes. + +## Troubleshooting + +### `Connection failed` in `hermes mcp test` + +- Confirm `command` points to an existing executable. +- Use absolute paths for `command` and `PYTHONPATH`. +- Run the server command directly: + - `"/ABS/PATH/TO/AliceBot/.venv/bin/python" -m alicebot_api.mcp_server --help` + +### Tool list is missing `alice_recall`/`alice_resume`/`alice_open_loops` + +- Check `tools.include` values are unprefixed tool names: + - `alice_recall`, `alice_resume`, `alice_open_loops` +- Run `/reload-mcp` in Hermes after config changes. +- Re-run `hermes mcp test alice_core`. + +### Tools register but calls fail at runtime + +- Validate `DATABASE_URL` is reachable and points to a migrated DB. +- Validate `ALICEBOT_AUTH_USER_ID` is a UUID string. +- Run `./scripts/run_hermes_mcp_smoke.py` to isolate server/runtime issues. + +### `npx` path fails + +- Check `npx --version`. +- Ensure `args` contains a valid local package path or a published package. +- If npm cache permissions are locked down, set `NPM_CONFIG_CACHE` to a writable path. +- If `npx` is blocked in your environment, use Option A (local command). + +## Demo Screenshots + +`hermes mcp test` against Alice: + +![Hermes MCP test with Alice](assets/hermes/hermes-mcp-test.png) + +Hermes runtime tool-call smoke result: + +![Hermes runtime smoke result](assets/hermes/hermes-runtime-smoke.png) + +## Test Record + +Validated on `2026-04-09`: + +- `HERMES_HOME=/tmp/alice-hermes-home ./.venv/bin/hermes mcp test alice_core` (local command config) +- `HERMES_HOME=/tmp/alice-hermes-home ./.venv/bin/hermes mcp test alice_core` (`npx --package ... alice mcp` config) +- `./scripts/run_hermes_mcp_smoke.py` diff --git a/docs/integrations/importers.md b/docs/integrations/importers.md new file mode 100644 index 0000000..b11f238 --- /dev/null +++ b/docs/integrations/importers.md @@ -0,0 +1,57 @@ +# Importer Integration + +Alice ships three importer paths from `P9-S36` and `P9-S37`. + +## Shipped Importers + +- OpenClaw: `openclaw_import` +- Markdown: `markdown_import` +- ChatGPT export: `chatgpt_import` + +## Canonical Loader Commands + +```bash +./scripts/load_openclaw_sample_data.sh --source fixtures/openclaw/workspace_v1.json +./scripts/load_openclaw_sample_data.sh --source fixtures/openclaw/workspace_dir_v1 +./scripts/load_markdown_sample_data.sh --source fixtures/importers/markdown/workspace_v1.md +./scripts/load_chatgpt_sample_data.sh --source fixtures/importers/chatgpt/workspace_v1.json +``` + +## OpenClaw One-Command Demo + +```bash +./scripts/use_alice_with_openclaw.sh +``` + +See [docs/integrations/openclaw.md](openclaw.md) for end-to-end before/after output and replay expectations. + +## Importer Behavior Contract + +- imported records are queryable through normal recall and resume +- provenance remains explicit with importer-specific `source_kind` +- dedupe posture is deterministic per source payload +- replaying the same fixture returns noop duplicate skips + +## Verification Example + +```bash +./.venv/bin/python -m alicebot_api recall --query "MCP tool surface" --limit 5 +./.venv/bin/python -m alicebot_api resume --max-recent-changes 5 --max-open-loops 5 +``` + +## Evaluation Harness + +```bash +EVAL_USER_ID="$(./.venv/bin/python -c 'import uuid; print(uuid.uuid4())')" +EVAL_USER_EMAIL="phase9-eval-${EVAL_USER_ID}@example.com" +./scripts/run_phase9_eval.sh --user-id "${EVAL_USER_ID}" --user-email "${EVAL_USER_EMAIL}" --display-name "Phase9 Eval" --report-path eval/reports/phase9_eval_latest.json +``` + +Evidence paths: + +- `eval/baselines/phase9_s37_baseline.json` +- `eval/reports/phase9_eval_latest.json` + +## Scope Guard + +No additional importer families are part of `P9-S38`. diff --git a/docs/integrations/mcp.md b/docs/integrations/mcp.md new file mode 100644 index 0000000..0529165 --- /dev/null +++ b/docs/integrations/mcp.md @@ -0,0 +1,70 @@ +# MCP Integration + +The shipped MCP server (`P9-S35`) exposes a deliberately small deterministic tool surface over local Alice continuity seams. + +## Entrypoints + +```bash +./.venv/bin/python -m alicebot_api.mcp_server --help +./.venv/bin/python -m alicebot_api.mcp_server +alicebot-mcp --help +alicebot-mcp +``` + +`alicebot-mcp` is available after editable install. + +## Runtime Scope + +MCP uses the same local runtime scope as CLI: + +- `DATABASE_URL` +- `ALICEBOT_AUTH_USER_ID` + +## Shipped Tool Surface + +- `alice_capture` +- `alice_recall` +- `alice_resume` +- `alice_open_loops` +- `alice_recent_decisions` +- `alice_recent_changes` +- `alice_memory_review` +- `alice_memory_correct` +- `alice_context_pack` + +## Example: Claude Desktop MCP Config + +```json +{ + "mcpServers": { + "alice-core": { + "command": "/ABSOLUTE/PATH/TO/AliceBot/.venv/bin/python", + "args": ["-m", "alicebot_api.mcp_server"], + "cwd": "/ABSOLUTE/PATH/TO/AliceBot", + "env": { + "DATABASE_URL": "postgresql://alicebot_app:alicebot_app@localhost:5432/alicebot", + "ALICEBOT_AUTH_USER_ID": "00000000-0000-0000-0000-000000000001" + } + } + } +} +``` + +## Hermes + +For Hermes Agent-specific setup, prompts, and troubleshooting: + +- `docs/integrations/hermes.md` +- `docs/integrations/hermes-skill-pack.md` + +## Contract Guardrails + +- tool set is intentionally narrow and stable +- tool output is deterministic for parity testing +- MCP does not widen core product semantics + +See tests: + +- `tests/unit/test_mcp.py` +- `tests/integration/test_mcp_server.py` +- `tests/integration/test_openclaw_mcp_integration.py` diff --git a/docs/integrations/openclaw.md b/docs/integrations/openclaw.md new file mode 100644 index 0000000..0e308b4 --- /dev/null +++ b/docs/integrations/openclaw.md @@ -0,0 +1,57 @@ +# Use Alice with OpenClaw + +This guide is the canonical OpenClaw integration path for Alice. + +## One-Command Demo + +Run the full import + recall + resume + idempotent replay demo: + +```bash +./scripts/use_alice_with_openclaw.sh +``` + +The command prints a JSON report. + +Key fields: + +- `before.recall_returned_count`: expected to be `0` for the generated demo user +- `import.first.status`: expected `ok` +- `import.second.status`: expected `noop` (idempotent replay) +- `after.recall_source_labels`: expected to include `OpenClaw` +- `after.resume_last_decision_source_label`: expected `OpenClaw` +- `after.resume_next_action_source_label`: expected `OpenClaw` +- `checks`: all values expected `true` + +## Import-Only Commands + +Import the primary OpenClaw fixture: + +```bash +./scripts/load_openclaw_sample_data.sh --source fixtures/openclaw/workspace_v1.json +``` + +Import the directory-contract fixture: + +```bash +./scripts/load_openclaw_sample_data.sh --source fixtures/openclaw/workspace_dir_v1 +``` + +## Before/After Value + +Before import, recall/resume on the scoped thread usually return no imported continuity signals. +After import: + +- recall returns OpenClaw-backed continuity objects +- resume returns last-decision and next-action objects sourced from OpenClaw +- provenance labels show `OpenClaw` with `source_kind=openclaw_import` + +## Determinism and Idempotency Contract + +- importer dedupe keys are deterministic for stable payloads +- replaying the same source for the same user does not create duplicates +- repeated replay returns `status=noop` with duplicates counted in `skipped_duplicates` + +## Fixture Sources + +- file fixture: `fixtures/openclaw/workspace_v1.json` +- directory fixture: `fixtures/openclaw/workspace_dir_v1/` diff --git a/docs/npm-publish-quickstart.md b/docs/npm-publish-quickstart.md new file mode 100644 index 0000000..eae1c1b --- /dev/null +++ b/docs/npm-publish-quickstart.md @@ -0,0 +1,53 @@ +# NPM Publish Quickstart (`@aliceos`) + +This repo now includes two publish-ready package scaffolds: + +- `packages/alice-core` -> `@aliceos/alice-core` +- `packages/alice-cli` -> `@aliceos/alice-cli` + +## 1) Login + +```bash +npm login +npm whoami +``` + +## 2) Publish core first + +```bash +cd packages/alice-core +npm publish --access public +``` + +## 3) Publish CLI second + +```bash +cd ../alice-cli +npm publish --access public +``` + +Publish order matters because `@aliceos/alice-cli` depends on `@aliceos/alice-core`. + +## 4) Verify + +```bash +npm view @aliceos/alice-core +npm view @aliceos/alice-cli +``` + +## 5) Enable tag-based auto publish (GitHub Actions) + +Set repository secret: + +- `NPM_TOKEN` = npm automation token with publish access to `@aliceos` + +Workflow file: + +- `.github/workflows/publish-npm.yml` + +After that, push a semver tag: + +```bash +git tag v0.1.0 +git push origin v0.1.0 +``` diff --git a/docs/phase5-continuity-object-model.md b/docs/phase5-continuity-object-model.md new file mode 100644 index 0000000..3fbc30f --- /dev/null +++ b/docs/phase5-continuity-object-model.md @@ -0,0 +1,337 @@ +# Phase 5 Continuity Object Model + +## Purpose + +This document defines the minimal typed continuity model for Phase 5. The goal is to support capture, recall, resumption, correction, and open-loop review without overengineering a full graph-native system. + +## Design Rules + +1. Every durable object must have provenance. +2. Every object must support temporal truth without silent overwrite. +3. Every correction must be representable as an event. +4. Retrieval should prefer confirmed, recent, relevant, and unsuperseded objects. +5. Raw capture events and derived continuity objects must remain distinct. + +## Core Layers + +### Capture Event + +Immutable record of what the user said, submitted, or selected. + +Required fields: + +- `capture_event_id` +- `user_id` +- `thread_id` or equivalent scope +- `source_type` +- `raw_content` +- `created_at` +- `source_refs` + +### Continuity Object + +Derived durable object used for retrieval and resumption. + +Shared required fields: + +- `object_id` +- `object_type` +- `user_id` +- `status` +- `title` +- `summary` +- `provenance_refs` +- `confidence` +- `created_at` +- `updated_at` +- `last_confirmed_at` +- `valid_from` +- `valid_to` +- `superseded_by` + +### Correction Event + +Explicit event recording user confirmation, edit, delete, or supersession. + +Required fields: + +- `correction_event_id` +- `object_id` +- `correction_type` +- `before_snapshot` +- `after_snapshot` +- `created_at` +- `actor` + +## Object Types + +### Note + +Use for captured information that is useful but not yet promoted to a stronger durable claim. + +Additional fields: + +- `body` +- `tags` +- `related_scopes` + +### MemoryFact + +Use for durable profile or contextual facts. + +Examples: + +- preference +- relationship fact +- stable work preference +- recurring routine detail + +Additional fields: + +- `fact_category` +- `subject_ref` +- `value` +- `stability` + +### Decision + +Use for resolved choices that affect future work. + +Additional fields: + +- `decision_text` +- `decided_at` +- `decision_scope` +- `participants` +- `rationale_summary` + +### Commitment + +Use for obligations owed by the user or by another party. + +Additional fields: + +- `owner` +- `due_at` +- `status_reason` +- `related_scope` + +### WaitingFor + +Use for dependencies awaiting external action or response. + +Additional fields: + +- `waiting_on` +- `last_ping_at` +- `expected_by` +- `related_scope` + +### Blocker + +Use for conditions preventing progress. + +Additional fields: + +- `blocking_reason` +- `blocked_scope` +- `blocking_entity` +- `severity` + +### NextAction + +Use for the single next concrete step in a scope. + +Additional fields: + +- `action_text` +- `owner` +- `due_at` +- `priority` +- `related_scope` + +## Object Status + +Allowed status values: + +- `unconfirmed` +- `active` +- `completed` +- `cancelled` +- `superseded` +- `stale` + +Not every type will use every status, but all objects should share one consistent posture vocabulary where possible. + +## Temporal Model + +Phase 5 should keep temporal logic simple and explicit. + +Use: + +- `valid_from` +- `valid_to` +- `last_confirmed_at` +- `superseded_by` + +This supports: + +- historical review +- changed preferences +- superseded decisions +- freshness checks + +It avoids destructive overwrite while keeping retrieval logic manageable. + +## Provenance Model + +Every continuity object must link back to source evidence: + +- capture event IDs +- thread/session/event IDs +- artifact IDs +- connector source IDs when relevant + +Retrieval UI must expose provenance so the user can audit why an object exists. + +## Admission Rules + +### Always Create Capture Event + +Every capture creates a capture event. + +### Create Continuity Object Only When + +- user provides explicit durable signal +- classifier confidence is high enough +- the object type is low-risk and reversible +- provenance is sufficient + +### Prefer Triage Instead Of Pollution + +When uncertain: + +- keep the capture event +- do not create a durable object yet +- send item to review or triage + +## Correction Rules + +Correction types: + +- `confirm` +- `edit` +- `delete` +- `supersede` +- `mark_stale` + +Rules: + +- correction event must be recorded before derived state is updated +- retrieval hot path must respect correction immediately +- supersession should preserve historical chain instead of overwrite + +## Retrieval Ranking Inputs + +Base ranking should consider: + +- scope relevance +- recency +- confirmation state +- provenance quality +- object type match +- superseded/stale posture + +Suggested ordering preference: + +1. active confirmed objects +2. active unconfirmed objects +3. stale objects +4. superseded historical objects + +Historical objects should still be retrievable for explicit temporal queries. + +## Resumption Composition Rules + +Resumption should compile from typed objects, not transcript replay. + +Preferred object contribution: + +- `Decision` -> last decision +- `Commitment` and `WaitingFor` -> open loops +- `Blocker` -> blocked state +- `NextAction` -> next step +- `Note` and `MemoryFact` -> supporting context + +## Minimal Implementation Guidance + +Phase 5 does not require a full graph database. + +Good enough first pass: + +- typed tables or typed JSON records on existing storage seams +- explicit relation references by IDs +- deterministic compiler logic over typed objects +- hot correction path +- cold consolidation path later + +## Anti-Patterns To Avoid + +- storing everything as generic memory text +- silently overwriting changed truths +- deriving durable memory without provenance +- mixing raw capture events with durable objects +- ranking stale or superseded objects as current truth + +## Definition Of Good Enough + +The model is good enough for Phase 5 if it supports: + +- fast capture without losing information +- reliable recall with provenance +- deterministic resumption briefs +- correction-driven improvement +- explicit open-loop review + +## P5-S17 Implemented Contract (March 29, 2026) + +### Implemented Object Types + +- `Note` +- `MemoryFact` +- `Decision` +- `Commitment` +- `WaitingFor` +- `Blocker` +- `NextAction` + +### Implemented Capture Signals + +- `remember_this` +- `task` +- `decision` +- `commitment` +- `waiting_for` +- `blocker` +- `next_action` +- `note` + +### Implemented Admission Posture + +- `DERIVED`: typed object admitted from explicit signal or deterministic high-confidence rule +- `TRIAGE`: immutable capture event persisted with no durable object admitted + +### Implemented Provenance Minimum + +Every admitted object carries provenance including: + +- `capture_event_id` +- `source_kind = continuity_capture_event` +- `admission_reason` + +## Deferred Beyond P5-S17 + +- correction-event persistence and supersession chains +- recall ranking UI and faceted recall query UX +- deterministic resumption brief product surfaces +- daily/weekly open-loop review dashboards diff --git a/docs/phase5-product-spec.md b/docs/phase5-product-spec.md new file mode 100644 index 0000000..1a0cbbb --- /dev/null +++ b/docs/phase5-product-spec.md @@ -0,0 +1,348 @@ +# Phase 5 Product Spec + +## Title + +Phase 5: Daily Continuity + +## Executive Summary + +Phase 4 closed the release-control problem. AliceBot now has deterministic qualification, RC rehearsal, archive retention, and sign-off integrity. The next product risk is not governance; it is whether AliceBot becomes indispensable in daily use. + +Phase 5 turns AliceBot into a memory-first personal continuity assistant that users can trust to: + +- capture things quickly +- remember them selectively and correctly +- retrieve the right context at the right time +- resume interrupted work without context reconstruction +- improve when corrected + +The phase is successful when the user stops restating context, relies on AliceBot to recover open loops, and sees clear improvement after correcting memory. + +## Product Thesis + +AliceBot should function as an external continuity layer for one primary user across life and work. It should preserve important context, compile it into useful artifacts, and reduce executive-function load without broadening prematurely into more channels, more tools, or more agent theatrics. + +## Phase Goal + +Make AliceBot daily-drivable as a personal continuity assistant. + +## Non-Goals + +- Telegram or WhatsApp surfaces +- new vertical agents +- public platform or SDK positioning +- broader connector and tool breadth +- new runtime orchestration models +- autonomous side-effect expansion +- major schema work under `apps/api` unrelated to continuity objects + +## Primary User + +- one high-context primary user +- often interrupted +- carries many open loops across work and life +- values retrieval, continuity, and trust more than novelty +- benefits from explicit support for memory and executive function + +## Product Principles + +1. Selective memory beats transcript hoarding. +2. Provenance is required for trust. +3. Resumption is a first-class product artifact. +4. Corrections must change future behavior. +5. Open loops must be explicit, reviewable, and easy to close. +6. Product value matters more than research completeness. + +## Core Pillars + +### 1. Fast Capture + +AliceBot must support low-friction intake for: + +- note +- task +- decision +- commitment +- waiting-for +- blocker +- next action +- remember-this fact +- question +- link or document reference + +Every capture becomes an immutable event. Some captures also produce derived continuity objects when the signal is explicit or confidence is high enough. + +### 2. Recall + +AliceBot must support deliberate recall flows such as: + +- what do you know about X +- what did I decide last week +- what am I waiting on +- what changed in Project Y +- what do I owe Person Z + +Results must be filtered by thread, project, person, topic, and time when relevant, and must show provenance. + +### 3. Resumption + +AliceBot must generate deterministic resumption briefs for: + +- thread +- task +- project +- person + +Each resumption brief should answer: + +- what this is +- what changed recently +- what was last decided +- what is waiting or blocked +- what to do next + +### 4. Review And Correction + +Users must be able to: + +- inspect learned memory +- confirm +- edit +- delete +- mark outdated +- mark superseded + +Corrections must feed admission, consolidation, and retrieval ranking so the same mistake is less likely to recur. + +### 5. Open-Loop Continuity + +AliceBot must support explicit review for: + +- commitments +- waiting-fors +- blockers +- stale items +- next actions + +This becomes the basis for daily and weekly review. + +## Core User Journeys + +1. Capture something quickly and trust it will not disappear. +2. Ask what was decided about a topic and get a provenance-backed answer. +3. Resume an interrupted task without rebuilding context manually. +4. Review open loops and identify what is blocked, waiting, or stale. +5. Correct a memory and see later recall improve immediately. + +## Required Product Surfaces + +### Capture Inbox + +- single fast intake entrypoint +- explicit signal chips such as `remember this`, `task`, `decision` +- triage queue for ambiguous captures +- immutable event provenance + +### Recall Surface + +- natural-language recall query +- faceted filter support +- top results with provenance and confidence +- inline correction actions + +### Resumption Surface + +- deterministic compiled brief +- thread/task/project/person scope +- recent changes +- open loops +- last decision +- next action + +### Memory Review Surface + +- confirmed vs unconfirmed posture +- superseded chain visibility +- last-confirmed metadata +- direct edit/delete/confirm controls + +### Open-Loop Review Surface + +- waiting-for list +- blocker list +- stale items +- daily brief +- weekly review + +## Continuity Objects + +Phase 5 must treat continuity objects as explicit typed records, not loose blobs. The initial object set is: + +- `Note` +- `MemoryFact` +- `Decision` +- `Commitment` +- `WaitingFor` +- `Blocker` +- `NextAction` + +The detailed model is defined in [phase5-continuity-object-model.md](phase5-continuity-object-model.md). + +## Admission Rules + +- default to NOOP for durable memory unless the signal is explicit or high-confidence +- preserve the raw capture event even when no durable object is created +- require strong provenance for memory objects +- create open-loop objects conservatively +- prefer confirmation for ambiguous durable facts + +## Retrieval Requirements + +- retrieval must rank by relevance, recency, confirmation status, and provenance quality +- retrieval must distinguish active truth from superseded truth +- retrieval must support thread, project, person, and time filtering +- retrieval results must expose enough evidence for user trust + +## Resumption Requirements + +- resumption brief generation must be deterministic for a fixed input state +- output must always include: + - last decision + - current open loops + - most recent meaningful changes + - one next suggested action +- missing sections must fail soft with explicit empty states, not silent omission + +## Correction Requirements + +- every correction is stored as an explicit correction event +- retrieval hot path must reflect corrections immediately +- consolidation must respect supersession instead of destructive overwrite +- stale truths should remain historically reviewable + +## Instrumentation Requirements + +Phase 5 should add thin product instrumentation, not broad telemetry expansion. + +Required metrics: + +- context restatement rate +- resumption success rate +- correction uptake rate +- recall precision at top results +- capture latency +- open-loop closure rate + +## Delivery Constraints + +- reuse shipped continuity, memory, task, artifact, and trace seams where possible +- avoid reopening Phase 4 runtime/governance scope +- avoid new channel strategy within this phase + +## Shipped In P5-S17 (March 29, 2026) + +- typed continuity backbone persisted in dedicated capture/object seams +- immutable capture-event guarantee for every continuity intake +- conservative admission posture: + - `DERIVED` only for explicit or high-confidence signals + - `TRIAGE` for ambiguous captures +- provenance-backed derived object visibility in capture detail +- fast capture inbox surface (`/continuity`) with submit/list/detail + +## Shipped In P5-S18 (March 29, 2026) + +- provenance-backed recall API surface: + - `GET /v0/continuity/recall` + - scoped filters (`thread`, `task`, `project`, `person`, `since`, `until`) + - deterministic ordering metadata and confirmation/posture exposure +- deterministic continuity resumption-brief API surface: + - `GET /v0/continuity/resumption-brief` + - always-present sections: + - `last_decision` + - `open_loops` + - `recent_changes` + - `next_action` + - explicit empty-state payloads for missing sections +- `/continuity` workspace expansion for: + - recall query and results panel + - resumption-brief panel + - preserved capture inbox/detail behavior from P5-S17 + +## Shipped In P5-S19 (March 29, 2026) + +- continuity review/correction API surfaces: + - `GET /v0/continuity/review-queue` + - `GET /v0/continuity/review-queue/{continuity_object_id}` + - `POST /v0/continuity/review-queue/{continuity_object_id}/corrections` +- deterministic correction actions: + - `confirm` + - `edit` + - `delete` + - `supersede` + - `mark_stale` +- append-only correction-event ledger: + - immutable `continuity_correction_events` records + - correction event write occurs before lifecycle mutation +- continuity-object freshness and supersession posture: + - `last_confirmed_at` + - `supersedes_object_id` + - `superseded_by_object_id` + - explicit lifecycle posture including `active`, `stale`, `superseded`, and `deleted` +- recall/resumption correction awareness: + - deleted objects are excluded from recall payloads + - lifecycle posture ordering metadata includes `lifecycle_rank` + - superseded and stale posture remains explicit in review + recent-change outputs +- `/continuity` workspace expansion for: + - review queue list/filter + - selected object correction form with supersession chain visibility + - correction event history review + +## Shipped In P5-S20 (March 29, 2026) + +- continuity open-loop/daily/weekly API surfaces: + - `GET /v0/continuity/open-loops` + - `GET /v0/continuity/daily-brief` + - `GET /v0/continuity/weekly-review` + - `POST /v0/continuity/open-loops/{continuity_object_id}/review-action` +- deterministic open-loop posture grouping and ordering: + - explicit posture groups: `waiting_for`, `blocker`, `stale`, `next_action` + - deterministic item ordering metadata: `created_at_desc`, `id_desc` + - explicit empty-state payloads for all sections +- deterministic review-action workflow for open loops: + - `done` -> completed lifecycle outcome + - `deferred` -> stale lifecycle outcome + - `still_blocked` -> active lifecycle outcome with freshness confirmation + - each action appends an auditable correction event payload before lifecycle mutation +- continuity resumption reflects review-action outcomes immediately in open-loop and recent-change sections +- `/continuity` workspace expansion for: + - open-loop dashboard review panel with per-item action controls + - deterministic daily brief panel + - deterministic weekly review panel with posture rollup + +## Acceptance Criteria + +- users can capture notes, tasks, and remember-this facts through one fast intake flow +- recall queries return provenance-backed results without requiring transcript replay +- resumption briefs let a user continue interrupted work with materially less restatement +- memory edits and deletions change future retrieval behavior immediately +- open-loop review surfaces expose waiting, blocked, stale, and next-action states +- Phase 5 product metrics are instrumented and report usable trend data + +## Phase Exit Definition + +Phase 5 is complete when AliceBot is meaningfully useful every day as a personal continuity assistant, with strong evidence that: + +- users restate less context +- interrupted work is easier to resume +- important decisions and commitments are retrievable +- corrections improve future behavior +- open loops are surfaced before they are dropped + +## Recommended Next Phase After Success + +If Phase 5 succeeds, the next strategic choice becomes reasonable: + +- first vertical agent on the same substrate +- or first external surface/channel + +Before Phase 5 succeeds, neither should be prioritized. diff --git a/docs/phase8-product-spec.md b/docs/phase8-product-spec.md new file mode 100644 index 0000000..efc158a --- /dev/null +++ b/docs/phase8-product-spec.md @@ -0,0 +1,287 @@ +# Phase 8 Product Spec + +## Title + +Phase 8: Operational Chief-of-Staff + +## Executive Summary + +Phase 7 proved that AliceBot can function as a trusted chief-of-staff agent for prioritization, follow-through, preparation, and weekly review. + +The next product gap is operational closure. + +AliceBot can now tell the user what matters, what is slipping, and what to prepare. It is not yet strong enough at turning those recommendations into execution-ready handoffs that can move safely through existing governed workflows. + +Phase 8 should close that gap. + +The phase objective is to make the chief-of-staff operational by transforming trusted recommendations into: + +- structured handoff artifacts +- governed task and approval drafts +- visible handoff queues and outcomes +- closure signals that feed back into later prioritization + +This is not an autonomy phase. +It is the execution-bridge phase. + +## Product Thesis + +Alice should move from: + +- telling the user what matters + +to: + +- packaging the next move so it can actually get done safely + +The chief-of-staff becomes truly useful when it not only identifies the right action, but prepares the action in a form that is reviewable, governable, and trackable. + +## Why Phase 8 Exists + +Phases 4 through 7 established: + +- release trust +- continuity +- memory trust calibration +- chief-of-staff guidance + +The largest remaining gap is: + +- recommendation -> execution handoff + +Without this bridge, Alice remains insightful but incomplete. The user still does too much operational reconstruction between deciding and doing. + +## Phase Goal + +Make chief-of-staff recommendations operationally usable through deterministic, approval-bounded action handoffs and follow-through closure. + +## Non-Goals + +- autonomous external side effects without approval +- public platform or SDK exposure +- channel expansion such as Telegram or WhatsApp +- broad connector write expansion as the main story +- orchestration redesign +- multi-agent abstraction as the primary deliverable + +## Product Principles + +1. Handoffs must be more useful than recommendations alone. +2. Every handoff must remain provenance-backed and trust-calibrated. +3. Side effects stay approval-bounded. +4. Outcome tracking matters as much as handoff generation. +5. Closure quality should improve later prioritization. + +## Core Pillars + +### 1. Action Handoff Artifacts + +Chief-of-staff recommendations must be convertible into deterministic action artifacts such as: + +- task handoff +- approval handoff +- follow-up handoff +- preparation handoff +- unblock plan + +Each artifact should contain: + +- title +- rationale +- provenance +- execution posture +- approval posture +- next recommended operator step + +### 2. Handoff Queue + +Alice needs a visible operational queue showing: + +- ready for review +- waiting for approval +- handed off +- executed +- stale handoff +- expired or abandoned + +This queue becomes the execution bridge between chief-of-staff reasoning and real workflow. + +### 3. Governed Execution Preparation + +Phase 8 should connect handoffs into existing governed flows without widening action scope prematurely. + +Initial supported output posture: + +- task draft +- approval draft +- draft-only follow-up package +- execution posture metadata + +The system should prepare execution, not perform it autonomously. + +### 4. Outcome Tracking + +Alice must track what happened after a handoff: + +- reviewed +- approved +- rejected +- rewritten +- executed +- ignored +- expired + +Without this, the chief-of-staff cannot improve or accurately supervise follow-through. + +### 5. Follow-Through Closure + +The phase should close the loop from: + +- recommendation +- to handoff +- to governed execution +- to outcome +- to updated future prioritization + +## Core User Journeys + +### 1. Recommendation To Handoff + +Alice identifies a slipping commitment and creates a ready-to-review handoff artifact rather than leaving the user with only a suggestion. + +### 2. Follow-Up Package + +Alice prepares a follow-up package with: + +- suggested draft +- rationale +- target context +- approval posture + +The user can review and route it without rebuilding context manually. + +### 3. Task Handoff + +Alice converts a chief-of-staff recommendation into a structured task draft that can move into the existing task workflow. + +### 4. Weekly Review To Closure + +During weekly review, Alice not only surfaces problems but proposes closure-ready handoffs for the most important unresolved items. + +### 5. Outcome Learning + +Alice records whether a handoff was executed, ignored, rewritten, or stalled and uses that to improve future recommendation and handoff quality. + +## Required Product Surfaces + +### Chief-of-Staff Action Handoff Panel + +A new panel in `/chief-of-staff` should show: + +- action handoff brief +- handoff items +- execution posture +- approval posture +- provenance-backed rationale + +### Handoff Queue + +A queue or grouped view should show: + +- ready +- pending approval +- executed +- stale +- expired + +### Outcome Capture + +Users must be able to record what happened to a handoff so later recommendations learn from actual execution outcomes. + +## Action Handoff Artifacts + +Primary Phase 8 artifacts: + +- `action_handoff_brief` +- `handoff_item` +- `task_draft` +- `approval_draft` +- `execution_posture` +- `handoff_outcome_record` + +These should be deterministic for fixed input state and should reuse Phase 7 chief-of-staff signals instead of replacing them. + +## Trust And Policy Rules + +The chief-of-staff handoff layer must inherit Phase 6 trust and Phase 4 governance rules. + +### Trust Rules + +- low-trust memory lowers handoff confidence +- stale or superseded truth must not be treated as current execution guidance +- provenance must be visible on every handoff + +### Policy Rules + +Allowed: + +- draft +- package +- recommend +- route +- ask for approval + +Not allowed by default: + +- autonomous external sends +- hidden execution +- bypassing governed task/approval flows + +## Success Metrics + +Phase 8 should measure: + +- handoff acceptance rate +- handoff execution rate +- stale handoff rate +- outcome capture rate +- recommendation-to-execution conversion rate +- follow-through closure rate +- user rewrite rate on handoff artifacts + +## Delivery Constraints + +- reuse shipped P5/P6/P7 contracts +- preserve Phase 4 qualification and release semantics +- keep action scope narrow and governed +- prefer deterministic operational artifacts over broader agent abstraction work + +## Acceptance Criteria + +- chief-of-staff recommendations can produce deterministic handoff artifacts +- handoff artifacts are provenance-backed and trust-calibrated +- handoffs map cleanly into existing task and approval workflows +- handoff status and outcomes are visible +- later prioritization can incorporate handoff outcomes + +## Phase Exit Definition + +Phase 8 is complete when Alice no longer stops at “what should happen.” + +It must be able to: + +- package the next move +- route it safely +- track what happened +- use that result to improve future guidance + +At phase exit, Alice should feel less like a recommendation engine and more like an operational chief-of-staff. + +## Recommended Next Phase After Success + +If Phase 8 succeeds, then the infrastructure is in a much stronger position for: + +- a reusable agent abstraction layer +- or a second first-party vertical agent + +Before Phase 8 succeeds, platformization would still be ahead of product proof. diff --git a/docs/phase9-public-core-boundary.md b/docs/phase9-public-core-boundary.md new file mode 100644 index 0000000..7203981 --- /dev/null +++ b/docs/phase9-public-core-boundary.md @@ -0,0 +1,177 @@ +# Phase 9 Public Core Boundary + +## Purpose + +This document defines what Phase 9 should expose publicly versus what should remain internal or non-launch-critical during the first public release. + +## Public Core Objective + +Expose the minimum stable surface needed to make Alice usable as: + +- a local memory and continuity engine +- a CLI continuity tool +- an MCP-backed memory layer for external assistants + +## Public Release Surface + +### 1. Alice Core + +Public-safe core functionality should include: + +- continuity capture +- recall +- resumption briefs +- open-loop retrieval +- correction-aware memory review +- trust-calibrated retrieval posture + +### 2. Alice CLI + +Public CLI should expose: + +- `alice import` +- `alice capture` +- `alice recall` +- `alice resume` +- `alice open-loops` +- `alice review-memory` +- `alice correct-memory` +- `alice status` + +### 3. Alice MCP Server + +Public MCP surface should stay intentionally small. + +Recommended initial tools: + +- `alice_capture` +- `alice_recall` +- `alice_resume` +- `alice_open_loops` +- `alice_recent_decisions` +- `alice_recent_changes` +- `alice_memory_review` +- `alice_memory_correct` +- `alice_context_pack` + +### 4. Alice Importers + +Public import support should focus on fast adoption: + +- markdown folder import +- ChatGPT export import +- Claude export import +- CSV task/open-loop import +- OpenClaw import + +## Public Runtime Assumptions + +Preferred runtime: + +- Postgres +- pgvector + +Optional fallback: + +- SQLite only if it can be supported cleanly without compromising core semantics + +The first public release should prioritize one reliable documented local startup path over multiple partially supported deployment modes. + +For `P9-S33`, the canonical path is: + +1. `docker compose up -d` +2. `./scripts/migrate.sh` +3. `./scripts/load_sample_data.sh` +4. `./scripts/api_dev.sh` + +## Public Repo Shape + +Recommended public package layout: + +```text +alice/ +├─ apps/ +│ ├─ mcp-server/ +│ └─ cli/ +├─ packages/ +│ ├─ alice-core/ +│ ├─ alice-importers/ +│ ├─ alice-openclaw/ +│ └─ alice-sdk-python/ +├─ docs/ +│ ├─ quickstart/ +│ ├─ architecture/ +│ ├─ integrations/ +│ ├─ mcp/ +│ └─ examples/ +├─ eval/ +├─ examples/ +├─ docker/ +├─ scripts/ +├─ fixtures/ +└─ README.md +``` + +This should be treated as a public packaging target, not necessarily an immediate full repo rewrite in one sprint. + +## Public-Safe Guarantees + +Alice public core should guarantee: + +- deterministic recall/resumption behavior +- provenance-backed outputs +- correction-aware improvement +- open-loop visibility +- documented install path +- stable local-first operation + +## Keep Internal Or Defer + +Do not treat these as Phase 9 launch blockers: + +- Telegram or WhatsApp channels +- browser automation +- broad connector write actions +- hosted SaaS +- deep vertical workflows +- broad agent-platform abstraction + +## Public Documentation Priorities + +Public docs must cover: + +- what Alice is +- why it exists +- 10-minute quickstart +- CLI examples +- MCP setup +- OpenClaw integration +- architecture overview +- evaluation harness + +## OSS Boundary Questions + +Phase 9 needs explicit decisions on: + +- license +- what parts are public-safe +- whether any internal tooling or ops scripts remain private +- whether public examples include full datasets or sanitized fixtures only + +These should be captured as ADRs rather than left implicit in launch docs. + +Current `P9-S33` note: + +- package boundary, runtime baseline, and MCP tool-surface boundaries are ADR-backed +- license selection is explicitly deferred and tracked in sprint evidence + +## Launch Definition + +Phase 9 public launch is good enough when an external technical user can: + +- install Alice locally +- import data +- use CLI recall/resume/open-loop flows +- connect one MCP client +- observe that corrections change future retrieval +- complete the flow from public docs without direct founder support diff --git a/docs/quickstart/local-setup-and-first-result.md b/docs/quickstart/local-setup-and-first-result.md new file mode 100644 index 0000000..f68697c --- /dev/null +++ b/docs/quickstart/local-setup-and-first-result.md @@ -0,0 +1,88 @@ +# Local Setup and First Useful Result + +This quickstart is the canonical `P9-S38` path for external technical testers. + +## Prerequisites + +- Python `3.12+` +- Docker + Docker Compose +- Node + pnpm (only required if you run web tests) + +## 1) Prepare Environment and Install + +```bash +cp .env.example .env +python3 -m venv .venv +./.venv/bin/python -m pip install -e '.[dev]' +``` + +## 2) Start Local Runtime + +```bash +docker compose up -d +./scripts/migrate.sh +./scripts/load_sample_data.sh +``` + +## 3) Start API + +```bash +APP_RELOAD=false ./scripts/api_dev.sh +``` + +## 4) Verify Health + +Run in another terminal: + +```bash +curl -sS http://127.0.0.1:8000/healthz +``` + +Expected: JSON with `"status": "ok"`. + +## 5) Get First Useful Result (CLI) + +```bash +./.venv/bin/python -m alicebot_api status +./.venv/bin/python -m alicebot_api recall --query local-first --limit 5 +./.venv/bin/python -m alicebot_api resume --max-recent-changes 5 --max-open-loops 5 +``` + +- `recall` should return deterministic continuity items with provenance snippets. +- `resume` should return deterministic brief fields (`last_decision`, `next_action`, recent changes/open loops). + +## 6) Optional: Prove Shipped Importer Paths + +```bash +./scripts/use_alice_with_openclaw.sh +./scripts/load_openclaw_sample_data.sh --source fixtures/openclaw/workspace_v1.json +./scripts/load_openclaw_sample_data.sh --source fixtures/openclaw/workspace_dir_v1 +./scripts/load_markdown_sample_data.sh --source fixtures/importers/markdown/workspace_v1.md +./scripts/load_chatgpt_sample_data.sh --source fixtures/importers/chatgpt/workspace_v1.json +``` + +Repeat the same command to verify deterministic dedupe posture (`status=noop`, duplicate skips). +OpenClaw details: `docs/integrations/openclaw.md`. + +## 7) Optional: Generate Evaluation Evidence + +```bash +EVAL_USER_ID="$(./.venv/bin/python -c 'import uuid; print(uuid.uuid4())')" +EVAL_USER_EMAIL="phase9-eval-${EVAL_USER_ID}@example.com" +./scripts/run_phase9_eval.sh --user-id "${EVAL_USER_ID}" --user-email "${EVAL_USER_EMAIL}" --display-name "Phase9 Eval" --report-path eval/reports/phase9_eval_latest.json +``` + +- baseline reference: `eval/baselines/phase9_s37_baseline.json` +- generated report: `eval/reports/phase9_eval_latest.json` + +## 8) Required Validation Commands for Sprint Acceptance + +```bash +./.venv/bin/python -m pytest tests/unit tests/integration +pnpm --dir apps/web test +``` + +## Scope Guard + +This quickstart documents only shipped local runtime behavior (`P9-S33` to `P9-S37`). +It does not promise hosted deployment, new importer families, or MCP tool expansion. diff --git a/docs/release/v0.1.0-release-checklist.md b/docs/release/v0.1.0-release-checklist.md new file mode 100644 index 0000000..31e9be0 --- /dev/null +++ b/docs/release/v0.1.0-release-checklist.md @@ -0,0 +1,69 @@ +# v0.1.0 Release Checklist + +This checklist is scoped to the shipped local product wedge (`P9-S33` through `P9-S37`) and `P9-S38` launch docs. + +## 1) Repo and Environment Prep + +- [ ] On branch: `codex/phase9-sprint-38-launch-and-release` +- [ ] `.env` exists and matches `.env.example` expectations +- [ ] Python deps installed in `.venv` +- [ ] Docker runtime available + +## 2) Required Runtime Verification + +Run in order: + +```bash +docker compose up -d +./scripts/migrate.sh +./scripts/load_sample_data.sh +APP_RELOAD=false ./scripts/api_dev.sh +``` + +In separate terminal: + +```bash +curl -sS http://127.0.0.1:8000/healthz +``` + +## 3) Required Test Verification + +```bash +./.venv/bin/python -m pytest tests/unit tests/integration +pnpm --dir apps/web test +EVAL_USER_ID="$(./.venv/bin/python -c 'import uuid; print(uuid.uuid4())')" +EVAL_USER_EMAIL="phase9-eval-${EVAL_USER_ID}@example.com" +./scripts/run_phase9_eval.sh --user-id "${EVAL_USER_ID}" --user-email "${EVAL_USER_EMAIL}" --display-name "Phase9 Eval" --report-path eval/reports/phase9_eval_latest.json +``` + +## 4) Required Documentation Verification + +- [ ] `README.md` quickstart path is runnable end-to-end +- [ ] `docs/quickstart/local-setup-and-first-result.md` matches shipped commands +- [ ] `docs/integrations/cli.md` matches actual CLI flags/paths +- [ ] `docs/integrations/mcp.md` tool list matches `tests/unit/test_mcp.py` +- [ ] `docs/integrations/importers.md` matches shipped loader scripts +- [ ] release runbook and tag plan are present and coherent + +## 5) Evidence Artifacts + +- [ ] Baseline reference present: `eval/baselines/phase9_s37_baseline.json` +- [ ] Latest generated report present: `eval/reports/phase9_eval_latest.json` +- [ ] `BUILD_REPORT.md` updated with executed commands and outcomes +- [ ] `REVIEW_REPORT.md` updated for this sprint handoff state + +## 6) Public Repo Readiness + +- [ ] `CONTRIBUTING.md` present +- [ ] `SECURITY.md` present +- [ ] `LICENSE` present +- [ ] `CHANGELOG.md` includes `P9-S38` release-doc/update entry + +## 7) Tag-Readiness Gate + +- [ ] No acceptance criterion gaps remain +- [ ] No public-facing claim exceeds shipped behavior +- [ ] Maintainer review verdict is `PASS` +- [ ] Explicit merge/tag approval has been granted + +When all boxes pass, execute the tag procedure in `docs/release/v0.1.0-tag-plan.md`. diff --git a/docs/release/v0.1.0-tag-plan.md b/docs/release/v0.1.0-tag-plan.md new file mode 100644 index 0000000..e50c555 --- /dev/null +++ b/docs/release/v0.1.0-tag-plan.md @@ -0,0 +1,49 @@ +# v0.1.0 Tag Plan + +## Purpose + +Define the first public tag cut for Alice without widening scope beyond shipped `P9-S33` to `P9-S37` functionality. + +## Tag Target + +- semantic version: `v0.1.0` +- target branch: `main` after squash-merge of `codex/phase9-sprint-38-launch-and-release` +- release type: first public release (local-first continuity wedge) + +## Required Inputs + +- passing release checklist: `docs/release/v0.1.0-release-checklist.md` +- `BUILD_REPORT.md` and `REVIEW_REPORT.md` for `P9-S38` +- evidence artifacts: + - `eval/baselines/phase9_s37_baseline.json` + - `eval/reports/phase9_eval_latest.json` + +## Tag Procedure + +1. Confirm release checklist is complete. +2. Confirm `main` contains approved squash merge for sprint branch. +3. Create annotated tag: + +```bash +git checkout main +git pull origin main +git tag -a v0.1.0 -m "Alice v0.1.0: local-first memory continuity core with CLI, MCP, importers, and eval evidence" +git push origin v0.1.0 +``` + +4. Publish release notes from `CHANGELOG.md` and checklist evidence references. + +## Release Notes Scope (must match tag) + +- shipped local runtime/startup path +- shipped CLI and MCP surfaces +- shipped OpenClaw/Markdown/ChatGPT importer paths +- shipped eval harness + baseline evidence +- launch docs/runbook/checklist assets + +## Explicitly Deferred Beyond v0.1.0 + +- hosted deployment and remote auth +- MCP tool-surface expansion +- importer families beyond shipped three +- UI/demo media work requiring new implementation diff --git a/docs/runbooks/.gitkeep b/docs/runbooks/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/docs/runbooks/.gitkeep @@ -0,0 +1 @@ + diff --git a/docs/runbooks/memory-quality-gate.md b/docs/runbooks/memory-quality-gate.md new file mode 100644 index 0000000..08ae94d --- /dev/null +++ b/docs/runbooks/memory-quality-gate.md @@ -0,0 +1,73 @@ +# Memory Quality Gate Runbook + +## Objective +Use `/memories` to read a deterministic ship-gate signal for memory quality before broader MVP testing. + +## Source Of Truth +- Endpoint: `GET /v0/memories/evaluation-summary` +- Queue endpoint: `GET /v0/memories/review-queue` +- Label endpoint: `POST /v0/memories/{memory_id}/labels` +- Gate computes from summary counts only: + - `correct = label_row_counts_by_value.correct` + - `incorrect = label_row_counts_by_value.incorrect` + - `unlabeled = unlabeled_memory_count` + +## Gate Math +- `precision = correct / (correct + incorrect)` (undefined when denominator is `0`) +- `adjudicated_sample = correct + incorrect` +- `remaining_to_minimum_sample = max(0, 20 - adjudicated_sample)` +- Precision target: `> 0.80` (strictly greater) +- Minimum adjudicated sample: `>= 20` + +## Gate States +- `on_track`: precision `> 0.80` and adjudicated sample `>= 20` +- `needs_review`: precision `<= 0.80` and adjudicated sample `>= 20` +- `insufficient_evidence`: adjudicated sample `< 20` +- `unavailable data`: evaluation summary not available for computation + +## Posture Readouts +- Sample posture: + - enough sample when adjudicated sample `>= 20` + - insufficient sample when adjudicated sample `< 20` +- Queue posture: + - queue clear when `unlabeled_memory_count = 0` + - backlog present when `unlabeled_memory_count > 0` + +## Manual Verification On `/memories` +1. Open `/memories`. +2. In `Memory summary`, locate `Memory-quality gate`. +3. Confirm the card shows: + - precision percent + - adjudicated sample count + - remaining labels to minimum sample + - unlabeled queue count + - gate status badge +4. Confirm source badges (`Summary` and `Queue`) are explicit (`Live`, `Fixture`, or `Unavailable`). +5. Verify interpretation copy matches the displayed gate status and counts. + +## Queue Adjudication Workflow +1. Open `/memories?filter=queue`. +2. Select the current queue item. +3. Choose one review label and optional note. +4. Use `Submit review label` to save and stay on the same memory. +5. Use `Submit and next in queue` to save and advance to the next visible queue item in current list order. +6. Continue until the queue clears or minimum sample is reached. + +## Stop Conditions +- Stop when `remaining_to_minimum_sample = 0` and gate status is `on_track` or `needs_review`. +- Stop when queue is clear (`unlabeled_memory_count = 0`) and escalate if minimum sample is still not met. +- Stop and fix source/config first when summary or queue source is unavailable. + +## Notes For MVP Testing +- Treat `on_track` as readiness signal for memory quality sampling. +- Treat `needs_review` and `insufficient_evidence` as stop-and-investigate states before ship decisions. +- If data is unavailable, fix source availability first; do not infer readiness from stale assumptions. + +## Readiness Runner Alignment +- `python3 scripts/run_mvp_readiness_gates.py` uses this same summary math and thresholds. +- Runner mapping: + - `on_track` -> gate `PASS` + - `needs_review` -> gate `FAIL` + - `insufficient_evidence` or unavailable data -> gate `BLOCKED` +- Boundary behavior: + - `precision == 0.80` is `needs_review` and does not pass. diff --git a/docs/runbooks/mvp-acceptance-suite.md b/docs/runbooks/mvp-acceptance-suite.md new file mode 100644 index 0000000..167b4fe --- /dev/null +++ b/docs/runbooks/mvp-acceptance-suite.md @@ -0,0 +1,71 @@ +# MVP Acceptance Suite Runbook + +## Objective +Run one deterministic acceptance suite that validates shipped MVP-critical journeys and returns a single reviewer-ready pass/fail signal. +Canonical implementation is Phase 2 (`run_phase2_acceptance.py`); `run_mvp_acceptance.py` is a compatibility alias. + +## Prerequisites +- Local dependencies installed (`python3 -m venv .venv` and `./.venv/bin/python -m pip install -e '.[dev]'`). +- Postgres stack available (for example `docker compose up -d`). +- Migrations applied (`./scripts/migrate.sh`). + +## Included Acceptance Scenarios +- `response_memory`: + - context-aware response uses admitted memory evidence + - preference correction is reflected in subsequent compile + response flow +- `capture_resumption`: + - explicit-signal capture writes propagate into thread resumption-brief continuity +- `approval_execution`: + - approval-required request lifecycle (pending -> approved -> executed) stays linked + - consequential trace evidence remains queryable +- `magnesium_reorder`: + - canonical magnesium reorder flow preserves explicit memory write-back evidence + +## Exact Command (Normal) +```bash +python3 scripts/run_phase2_acceptance.py +``` + +Expected behavior: +- Runs this bounded subset only: + - `tests/integration/test_mvp_acceptance_suite.py::test_acceptance_response_path_uses_admitted_memory_and_preference_correction` + - `tests/integration/test_mvp_acceptance_suite.py::test_acceptance_approval_lifecycle_resolution_execution_and_trace_availability` + - `tests/integration/test_mvp_acceptance_suite.py::test_acceptance_canonical_magnesium_reorder_flow_with_memory_write_back_evidence` +- Exit code `0` means PASS. +- Any non-zero exit code means FAIL. + +## Exact Command (Induced-Failure Check) +```bash +python3 scripts/run_phase2_acceptance.py --induce-failure approval_execution +``` + +Expected behavior: +- Intentionally fails exactly the selected scenario via `MVP_ACCEPTANCE_INDUCED_FAILURE_SCENARIO`. +- Returns non-zero exit code and prints `Phase 2 acceptance suite result: FAIL (...)`. +- This validates deterministic negative signaling for reviewers. + +Other valid induced-failure scenario names: +- `response_memory` +- `capture_resumption` +- `approval_execution` +- `magnesium_reorder` + +## Compatibility Alias Command +```bash +python3 scripts/run_mvp_acceptance.py +``` + +Expected behavior: +- Prints explicit alias messaging and delegates to `scripts/run_phase2_acceptance.py`. +- Preserves the same arguments, thresholds, scenario coverage, and exit semantics. + +## Optional Direct Pytest Command +```bash +./.venv/bin/python -m pytest tests/integration/test_mvp_acceptance_suite.py +``` + +## Non-Goals / Deferred Criteria +- No backend contract expansion, migrations, or schema changes. +- No UI validation, UI latency metrics, or end-user workflow UX checks. +- No connector breadth expansion or auth/orchestration changes. +- No load/performance characterization beyond deterministic functional acceptance evidence. diff --git a/docs/runbooks/mvp-readiness-gates.md b/docs/runbooks/mvp-readiness-gates.md new file mode 100644 index 0000000..63ec496 --- /dev/null +++ b/docs/runbooks/mvp-readiness-gates.md @@ -0,0 +1,76 @@ +# MVP Readiness Gates Runbook + +## Objective +Run one deterministic command that produces quantitative MVP go/no-go evidence across acceptance, latency, cache reuse, and memory quality gates. +Canonical implementation is Phase 2 (`run_phase2_readiness_gates.py`); `run_mvp_readiness_gates.py` is a compatibility alias. + +This readiness runner is also the first prerequisite step in `python3 scripts/run_phase2_validation_matrix.py`. + +## Prerequisites +- Local dependencies installed (`python3 -m venv .venv` and `./.venv/bin/python -m pip install -e '.[dev]'`). +- Local Postgres available at the configured admin/app URLs. +- No extra API keys required for this readiness runner: model calls are stubbed for deterministic probe evidence. + +## Exact Command +```bash +python3 scripts/run_phase2_readiness_gates.py +``` + +Expected behavior: +- Executes bounded gates in this order: + - `acceptance_suite` (runs `python3 scripts/run_phase2_acceptance.py`) + - `latency_p95` (`p95_seconds < 5.0`) + - `cache_reuse` (`cache_reuse_ratio >= 0.70` when cached-token telemetry is present) + - `memory_quality` (`precision > 0.80` and `adjudicated_sample >= 20`) +- Prints explicit `PASS`, `FAIL`, or `BLOCKED` per gate with measured values and thresholds. +- Returns exit code `0` only when every gate is `PASS`. +- Returns non-zero on any `FAIL` or `BLOCKED` gate. + +## Gate Interpretation +- `acceptance_suite` + - `PASS`: acceptance runner exit code is `0`. + - `FAIL`: acceptance runner returned non-zero. + +- `latency_p95` + - measured from repeated retrieval-plus-response probe calls. + - p95 uses deterministic nearest-rank math on probe durations. + - `PASS` requires strictly `< 5.0` seconds. + +- `cache_reuse` + - ratio = `sum(cached_input_tokens) / sum(input_tokens)`. + - `PASS` requires `>= 0.70`. + - `BLOCKED` when cached-token telemetry is missing/invalid for any probe sample. + +- `memory_quality` + - derived from `/v0/memories/evaluation-summary` semantics. + - `precision = correct / (correct + incorrect)` when denominator > 0. + - `adjudicated_sample = correct + incorrect`. + - `PASS` when precision and sample thresholds are met. + - `FAIL` when adjudicated sample is sufficient but precision is at-or-below target (`<= 0.80`). + - `BLOCKED` when adjudicated sample is below minimum or summary data is unavailable/invalid. + +## Optional Deterministic Negative Checks +```bash +python3 scripts/run_phase2_readiness_gates.py --induce-gate acceptance_fail +python3 scripts/run_phase2_readiness_gates.py --induce-gate latency_fail +python3 scripts/run_phase2_readiness_gates.py --induce-gate cache_fail +python3 scripts/run_phase2_readiness_gates.py --induce-gate cache_blocked +python3 scripts/run_phase2_readiness_gates.py --induce-gate memory_needs_review +python3 scripts/run_phase2_readiness_gates.py --induce-gate memory_insufficient +``` + +These options intentionally force deterministic gate outcomes to validate reviewer signaling. + +## Blocked-State Handling +- Treat any `BLOCKED` gate as no-go until evidence gaps are resolved. +- Do not treat blocked cache/memory gates as implicit pass. +- Re-run the full command after resolving the blocked condition. + +## Compatibility Alias Command +```bash +python3 scripts/run_mvp_readiness_gates.py +``` + +Expected behavior: +- Prints explicit alias messaging and delegates to `scripts/run_phase2_readiness_gates.py`. +- Preserves the same arguments, thresholds, and gate pass/fail/blocked semantics. diff --git a/docs/runbooks/mvp-ship-gate-magnesium-reorder.md b/docs/runbooks/mvp-ship-gate-magnesium-reorder.md new file mode 100644 index 0000000..3ccbddb --- /dev/null +++ b/docs/runbooks/mvp-ship-gate-magnesium-reorder.md @@ -0,0 +1,65 @@ +# MVP Ship-Gate Runbook: Magnesium Reorder + +## Objective +Verify the canonical MVP ship-gate flow remains explicit and bounded: +`request -> approval -> execution -> memory write-back`. + +This scenario is first-class in Phase 4 gates via: + +- `python3 scripts/run_phase4_acceptance.py` +- `python3 scripts/run_phase4_readiness_gates.py` +- `python3 scripts/run_phase4_validation_matrix.py` (`phase4_magnesium_ship_gate` step) + +## Automated Evidence Node + +- `tests/integration/test_mvp_acceptance_suite.py::test_acceptance_canonical_magnesium_reorder_flow_with_memory_write_back_evidence` + +## Preconditions + +- API is running and reachable by `apps/web`. +- `NEXT_PUBLIC_ALICEBOT_API_BASE_URL` is set for `apps/web`. +- API is configured with `ALICEBOT_AUTH_USER_ID` so `/v0/*` requests are server-bound to one authenticated user identity. +- One tool/policy path exists that routes magnesium reorder requests through approval and `proxy.echo` execution. + +## Manual Verification: `/approvals` + +1. Submit a governed magnesium reorder request so one approval enters `pending`. +2. Open `/approvals` and select the new approval. +3. Confirm `Approval action bar` shows `Approve` and `Reject`. +4. Click `Approve` and confirm status transitions to `approved`. +5. Click `Execute approved request` and confirm execution review renders: + - execution status + - request event ID + - result event ID +6. In `Post-execution memory write-back`: + - enter memory key `user.preference.supplement.magnesium_reorder` + - enter JSON value + - submit +7. Confirm success message reports persisted decision (`ADD`/`UPDATE`) and revision sequence. + +## Manual Verification: Embedded `/chat` Workflow Panel + +1. Open `/chat` on the same thread. +2. In `Thread-linked workflow`, confirm embedded approval detail shows: + - existing execution review + - same `Post-execution memory write-back` control +3. Confirm fixture mode keeps write-back read-only and does not permit submission. + +## API Seams Used + +- `POST /v0/approvals/{approval_id}/execute` +- `POST /v0/memories/admit` + +## Expected Evidence + +- Approval created and resolved. +- Execution response includes event evidence (`events.request_event_id`, `events.result_event_id`). +- Memory admission uses execution-linked `source_event_ids`. +- Memory and revision records reflect explicit write-back decisions. + +## Out of Scope (Remain Deferred) + +- Any automatic memory admission after execution. +- New runtime/task-run schema changes. +- Connector/auth/platform expansion. +- Workflow engine/orchestration redesign. diff --git a/docs/runbooks/mvp-validation-matrix.md b/docs/runbooks/mvp-validation-matrix.md new file mode 100644 index 0000000..3a992b6 --- /dev/null +++ b/docs/runbooks/mvp-validation-matrix.md @@ -0,0 +1,62 @@ +# MVP Validation Matrix Runbook + +## Objective +Run one deterministic command that executes MVP readiness prerequisites plus bounded backend and web verification matrices, then emits a clear `PASS` or `NO_GO`. +Use this as the default MVP release-candidate go/no-go gate. +Canonical implementation is Phase 2 (`run_phase2_validation_matrix.py`); `run_mvp_validation_matrix.py` is a compatibility alias. + +## Prerequisites +- Python dependencies installed for backend integration tests (`python3 -m venv .venv` and `./.venv/bin/python -m pip install -e '.[dev]'`). +- Local Postgres available at configured admin/app URLs for integration tests and readiness gates. +- Web dependencies installed (`npm --prefix apps/web install`). + +## Exact Command +```bash +python3 scripts/run_phase2_validation_matrix.py +``` + +The runner executes this deterministic step order: +1. `readiness_gates` + - `python3 scripts/run_phase2_readiness_gates.py` + - includes strict memory-quality ship margin (`precision > 0.80`, `adjudicated_sample >= 20`) +2. `backend_integration_matrix` + - `python3 -m pytest -q tests/integration/test_continuity_api.py tests/integration/test_responses_api.py tests/integration/test_approval_api.py tests/integration/test_proxy_execution_api.py tests/integration/test_tasks_api.py tests/integration/test_traces_api.py tests/integration/test_memory_review_api.py tests/integration/test_entities_api.py tests/integration/test_task_artifacts_api.py tests/integration/test_gmail_accounts_api.py tests/integration/test_calendar_accounts_api.py` +3. `web_validation_matrix` + - `npm --prefix apps/web run test:mvp:validation-matrix` + +Expected behavior: +- Prints per-step status with command, duration, exit code, and coverage. +- Prints explicit `Failing steps: ...` when any step fails. +- Returns exit code `0` only when all steps pass. +- Returns non-zero and final `Phase 2 validation matrix result: NO_GO` when any step fails. + +## Runtime Class +- Typical: medium-to-long local run (roughly 5-20 minutes, machine-dependent). +- Slowest segments are DB-backed backend integration and full web Vitest matrix. + +## Optional Deterministic Negative Check +```bash +python3 scripts/run_phase2_validation_matrix.py --induce-step backend_integration_matrix +``` + +`--induce-step` choices: +- `readiness_gates` +- `backend_integration_matrix` +- `web_validation_matrix` + +This intentionally forces one chosen step to fail and verifies deterministic no-go signaling plus failing-step reporting. + +## Failure Triage Flow +1. Check `Failing steps` in output. +2. Re-run only the failing command shown in that step to inspect detailed test output. +3. Resolve the failing seam/surface. +4. Re-run `python3 scripts/run_phase2_validation_matrix.py` to regenerate full matrix evidence. + +## Compatibility Alias Command +```bash +python3 scripts/run_mvp_validation_matrix.py +``` + +Expected behavior: +- Prints explicit alias messaging and delegates to `scripts/run_phase2_validation_matrix.py`. +- Preserves the same step ordering, command wiring, and pass/no-go semantics. diff --git a/docs/runbooks/phase2-closeout-packet.md b/docs/runbooks/phase2-closeout-packet.md new file mode 100644 index 0000000..2933d77 --- /dev/null +++ b/docs/runbooks/phase2-closeout-packet.md @@ -0,0 +1,43 @@ +# Phase 2 Closeout Packet + +This runbook is the source-of-truth closeout packet for the accepted Phase 2 Sprint 14 baseline. + +## Required Phase 2 Go/No-Go Commands + +Run these commands from repo root in order and retain outputs verbatim in the evidence bundle: + +1. `python3 scripts/check_control_doc_truth.py` +2. `./.venv/bin/python -m pytest tests/unit/test_control_doc_truth.py -q` +3. `python3 scripts/run_phase2_validation_matrix.py` + +Go decision rule: +- `GO` only if all three commands pass in the same verification window. +- `NO_GO` if any command fails, is skipped, or cannot produce deterministic output. + +## Required PASS Evidence Bundle + +Capture the following in one reviewable bundle: + +- command transcript for all required go/no-go commands +- final statuses showing `Control-doc truth check: PASS`, unit test PASS, and validation matrix PASS +- timestamped operator note that canonical docs are aligned through Phase 2 Sprint 14 +- links to current sprint reports at repo root: + - `BUILD_REPORT.md` + - `REVIEW_REPORT.md` + +## Explicit Deferred Scope Entering Next Phase + +The following are deferred and must remain out of this closeout decision: + +- API/runtime feature expansion beyond accepted Phase 2 Sprint 14 behavior +- connector capability broadening (Gmail/Calendar breadth beyond current bounded seams) +- orchestration/worker runtime implementation +- Phase 3 routing implementation +- UI redesign + +## Closeout Checklist + +- Canonical docs do not claim a baseline earlier than Phase 2 Sprint 14. +- This closeout packet file exists and remains referenced by control-doc truth checks. +- Control-doc truth guardrail and unit tests pass. +- Phase 2 validation matrix remains PASS without gate semantic changes. diff --git a/docs/runbooks/phase3-closeout-packet.md b/docs/runbooks/phase3-closeout-packet.md new file mode 100644 index 0000000..9bd77b1 --- /dev/null +++ b/docs/runbooks/phase3-closeout-packet.md @@ -0,0 +1,44 @@ +# Phase 3 Closeout Packet + +This runbook is the source-of-truth closeout packet for the accepted Phase 3 Sprint 9 baseline. + +## Required Phase 3 Go/No-Go Commands + +Run these commands from repo root in order and retain outputs verbatim in the evidence bundle: + +1. `python3 scripts/check_control_doc_truth.py` +2. `./.venv/bin/python -m pytest tests/unit/test_control_doc_truth.py -q` +3. `python3 scripts/run_phase3_validation_matrix.py` +4. `python3 scripts/run_phase2_validation_matrix.py` + +Go decision rule: +- `GO` only if all four commands pass in the same verification window. +- `NO_GO` if any command fails, is skipped, or cannot produce deterministic output. + +## Required PASS Evidence Bundle + +Capture the following in one reviewable bundle: + +- command transcript for all required go/no-go commands +- final statuses showing `Control-doc truth check: PASS`, unit test PASS, `Phase 3 validation matrix` PASS, and `Phase 2 validation matrix` PASS +- timestamped operator note that canonical docs are aligned through Phase 3 Sprint 9 +- links to current sprint reports at repo root: + - `BUILD_REPORT.md` + - `REVIEW_REPORT.md` + +## Explicit Deferred Scope Entering Next Phase + +The following are deferred and must remain out of this closeout decision: + +- API/runtime feature expansion beyond accepted Phase 3 Sprint 9 behavior +- schema and migration expansion +- provider and connector capability broadening +- orchestration/worker runtime implementation +- profile CRUD expansion + +## Closeout Checklist + +- Canonical docs do not claim a baseline earlier than Phase 3 Sprint 9. +- This closeout packet file exists and remains referenced by control-doc truth checks. +- Control-doc truth guardrail and unit tests pass. +- Phase 3 validation matrix and Phase 2 compatibility validation matrix remain PASS without gate semantic changes. diff --git a/docs/runbooks/phase4-acceptance-suite.md b/docs/runbooks/phase4-acceptance-suite.md new file mode 100644 index 0000000..ece0d1f --- /dev/null +++ b/docs/runbooks/phase4-acceptance-suite.md @@ -0,0 +1,26 @@ +# Phase 4 Acceptance Suite + +This runbook defines the canonical Phase 4 acceptance command contract for Sprint 14 MVP ship-gate ownership. + +## Canonical Command + +Run from repo root: + +1. `python3 scripts/run_phase4_acceptance.py` + +## Required Scenario Evidence Mapping + +The acceptance chain is deterministic and must include these scenario-to-evidence mappings: + +- `response_memory`: `tests/integration/test_mvp_acceptance_suite.py::test_acceptance_response_path_uses_admitted_memory_and_preference_correction` +- `capture_resumption`: `tests/integration/test_mvp_acceptance_suite.py::test_acceptance_explicit_signal_capture_flows_into_resumption_brief` +- `approval_execution`: `tests/integration/test_mvp_acceptance_suite.py::test_acceptance_approval_lifecycle_resolution_execution_and_trace_availability` +- `magnesium_reorder`: `tests/integration/test_mvp_acceptance_suite.py::test_acceptance_canonical_magnesium_reorder_flow_with_memory_write_back_evidence` + +The `magnesium_reorder` scenario is the canonical MVP ship-gate flow: +`request -> approval -> execution -> memory write-back`. + +## PASS Rule + +- PASS only when the command exits `0` and all mapped scenario checks pass. +- FAIL when any mapped scenario is missing, skipped, or non-deterministic. diff --git a/docs/runbooks/phase4-closeout-packet.md b/docs/runbooks/phase4-closeout-packet.md new file mode 100644 index 0000000..5c7177b --- /dev/null +++ b/docs/runbooks/phase4-closeout-packet.md @@ -0,0 +1,80 @@ +# Phase 4 Closeout Packet + +This closeout packet defines Sprint 19 MVP qualification and sign-off evidence required for formal closeout approval. + +## Required Go/No-Go Commands + +Run from repo root: + +1. `python3 scripts/run_phase4_mvp_qualification.py` +2. `python3 scripts/verify_phase4_mvp_signoff_record.py` + +Qualification chain executed by command `#1` (ordered, deterministic): + +1. `python3 scripts/run_phase4_release_candidate.py` +2. `python3 scripts/verify_phase4_rc_archive.py` +3. `python3 scripts/generate_phase4_mvp_exit_manifest.py` +4. `python3 scripts/verify_phase4_mvp_exit_manifest.py` + +## Required Evidence Bundle + +- latest summary artifact (compatibility path): `artifacts/release/phase4_rc_summary.json` +- retained archive artifacts: `artifacts/release/archive/*_phase4_rc_summary.json` +- append-only archive ledger: `artifacts/release/archive/index.json` +- deterministic archive index lock path: `artifacts/release/archive/index.lock` +- MVP exit manifest artifact: `artifacts/release/phase4_mvp_exit_manifest.json` +- MVP qualification sign-off artifact: `artifacts/release/phase4_mvp_signoff_record.json` +- artifact schema fields: + - `artifact_version` + - `ordered_steps` + - `steps[]` entries with `status`, `command`, `exit_code`, `duration_seconds`, and `induced_failure` + - `final_decision` and `summary_exit_code` +- archive index entry fields: + - `created_at` + - `archive_artifact_path` + - `final_decision` + - `summary_exit_code` + - `failing_steps` + - `command_mode` +- MVP exit manifest fields: + - `artifact_version` (`phase4_mvp_exit_manifest.v1`) + - `artifact_path` + - `phase` (`phase4`) and `release_gate` (`mvp`) + - `decision` (`final_decision`, `summary_exit_code`, `failing_steps`) + - `source_references` (`archive_index_path`, `archive_entry_index`, `archive_entry_created_at`, `archive_artifact_path`, `archive_entry_command_mode`) + - `ordered_steps` + - `step_status_by_id` + - `compatibility_validation_commands` + - `integrity.archive_artifact_sha256` +- GO requires `final_decision` = `GO` and every step `status` = `PASS` +- NO_GO requires at least one failed step and preserves partial evidence (`NOT_RUN` for downstream steps) +- GO and NO_GO runs must be retained concurrently in the archive/index (no overwrite of prior archive entries) +- archive index updates are lock-guarded and atomic: + - writer acquires `artifacts/release/archive/index.lock` + - index persistence uses temp-file write + atomic replace for `artifacts/release/archive/index.json` + - lock contention timeout is explicit and deterministic (`exit 2` with lock-timeout message) +- MVP exit manifest generation must select the latest GO rehearsal entry from archive index evidence. +- MVP exit manifest verification must validate required schema fields plus referenced archive/index evidence integrity. +- MVP qualification sign-off record fields: + - `artifact_version` (`phase4_mvp_signoff_record.v1`) + - `artifact_path` + - `generated_at` + - `phase` (`phase4`) and `release_gate` (`mvp`) + - `ordered_steps`, `executed_steps`, `total_steps` + - `failing_steps`, `not_run_steps` + - `required_references` (`release_candidate_summary_path`, `release_candidate_archive_index_path`, `mvp_exit_manifest_path`) + - `final_decision` (`GO` or `NO_GO`) and `summary_exit_code` (`0` for GO, `1` for NO_GO) + - `blockers` (must be empty for GO; explicit blocker entries for NO_GO) + - `steps[]` entries with `step`, `status`, `command`, `exit_code`, `required_artifacts`, and `missing_artifacts` +- Sign-off verifier command `python3 scripts/verify_phase4_mvp_signoff_record.py` must enforce sign-off schema, required references, and GO/NO_GO consistency. +- links to current `BUILD_REPORT.md` and `REVIEW_REPORT.md` + +## Explicit Deferred Scope + +Remain out of closeout scope unless explicitly opened: + +- runtime/task-run schema redesign +- connector breadth expansion +- auth-model redesign +- platform/channel expansion +- workflow engine/orchestration redesign diff --git a/docs/runbooks/phase4-mvp-qualification.md b/docs/runbooks/phase4-mvp-qualification.md new file mode 100644 index 0000000..d7fd4b6 --- /dev/null +++ b/docs/runbooks/phase4-mvp-qualification.md @@ -0,0 +1,43 @@ +# Phase 4 MVP Qualification + +Phase 4 Sprint 19 formalizes MVP qualification as one deterministic command plus a sign-off verifier. + +## Canonical Commands + +Run from repo root: + +1. `python3 scripts/run_phase4_mvp_qualification.py` +2. `python3 scripts/verify_phase4_mvp_signoff_record.py` + +The qualification command executes this ordered chain: + +1. `python3 scripts/run_phase4_release_candidate.py` +2. `python3 scripts/verify_phase4_rc_archive.py` +3. `python3 scripts/generate_phase4_mvp_exit_manifest.py` +4. `python3 scripts/verify_phase4_mvp_exit_manifest.py` + +## Qualification Artifacts + +- Sign-off record: `artifacts/release/phase4_mvp_signoff_record.json` +- RC summary: `artifacts/release/phase4_rc_summary.json` +- RC archive index: `artifacts/release/archive/index.json` +- MVP exit manifest: `artifacts/release/phase4_mvp_exit_manifest.json` + +## Sign-Off Contract + +- `final_decision = GO` requires: + - all qualification chain steps `PASS` + - `summary_exit_code = 0` + - `blockers = []` +- `final_decision = NO_GO` requires: + - one or more non-PASS steps (`FAIL` and/or `NOT_RUN`) + - `summary_exit_code = 1` + - explicit `blockers[]` entries for each non-PASS step + +## Blocker Policy + +- Fixes in this sprint are blocker-only. +- Non-blocking improvements are deferred. +- If qualification returns `NO_GO`, capture blocker details in: + - `artifacts/release/phase4_mvp_signoff_record.json` + - `BUILD_REPORT.md` diff --git a/docs/runbooks/phase4-readiness-gates.md b/docs/runbooks/phase4-readiness-gates.md new file mode 100644 index 0000000..b11ec0b --- /dev/null +++ b/docs/runbooks/phase4-readiness-gates.md @@ -0,0 +1,23 @@ +# Phase 4 Readiness Gates + +This runbook defines deterministic Phase 4 readiness prerequisites for Sprint 14 canonical gate ownership. + +## Canonical Command + +Run from repo root: + +1. `python3 scripts/run_phase4_readiness_gates.py` + +## Ordered Gate Contract + +The readiness command executes these ordered gates: + +1. `phase4_acceptance` +2. `canonical_magnesium_ship_gate` +3. `phase3_readiness_compat` + +## PASS Rule + +- PASS only when every readiness gate reports `PASS`. +- NO_GO when any readiness gate fails. +- Failing gate IDs are reported explicitly as `Failing gates: ...`. diff --git a/docs/runbooks/phase4-validation-matrix.md b/docs/runbooks/phase4-validation-matrix.md new file mode 100644 index 0000000..22721c5 --- /dev/null +++ b/docs/runbooks/phase4-validation-matrix.md @@ -0,0 +1,50 @@ +# Phase 4 Validation Matrix + +This runbook defines the deterministic Phase 4 validation chain used by Sprint 17 release-candidate rehearsal and archive audit flow. + +## Canonical Command + +Run from repo root: + +1. `python3 scripts/run_phase4_validation_matrix.py` +2. RC rehearsal entrypoint: `python3 scripts/run_phase4_release_candidate.py` (includes this matrix step, writes latest + archive evidence) +3. Archive ledger verifier: `python3 scripts/verify_phase4_rc_archive.py` + +## Validation Steps + +The validation matrix executes these ordered steps: + +1. `control_doc_truth` +2. `phase4_acceptance` +3. `phase4_readiness_gates` +4. `phase4_magnesium_ship_gate` +5. `phase4_scenarios` +6. `phase4_web_diagnostics` +7. `phase3_compat_validation` +8. `phase2_compat_validation` +9. `mvp_compat_validation` + +## Canonical Magnesium Step + +The `phase4_magnesium_ship_gate` step runs: + +- `tests/integration/test_mvp_acceptance_suite.py::test_acceptance_canonical_magnesium_reorder_flow_with_memory_write_back_evidence` + +## Scenario Step Coverage + +The `phase4_scenarios` step runs: + +- `run_progression_with_pause` +- `restart_safe_resume` +- `budget_exhaustion_fail_closed` +- `draft_first_tool_execution` +- `approval_resume_execution` + +## PASS Rule + +- PASS only when every step reports `PASS`. +- NO_GO when any step fails. +- Failing step IDs are reported explicitly as `Failing steps: ...`. +- In RC rehearsal context, matrix failure marks overall `final_decision` as `NO_GO` in `artifacts/release/phase4_rc_summary.json`. +- RC rehearsal writes an archive copy and updates `artifacts/release/archive/index.json`; the index is the canonical repeated-run audit ledger. +- Archive index updates are serialized by deterministic lock path `artifacts/release/archive/index.lock` with bounded timeout behavior and atomic replace writes for `index.json`. diff --git a/docs/runbooks/phase9-public-release-runbook.md b/docs/runbooks/phase9-public-release-runbook.md new file mode 100644 index 0000000..4318203 --- /dev/null +++ b/docs/runbooks/phase9-public-release-runbook.md @@ -0,0 +1,74 @@ +# Phase 9 Public Release Runbook + +This runbook executes `P9-S38` release readiness for the first public tag. + +## Objective + +Cut a credible public release from shipped local functionality with reproducible verification evidence. + +## Scope Guard + +Do not add features during this runbook. Only launch docs/release readiness adjustments are allowed. + +## Step 1: Start Runtime + +```bash +docker compose up -d +./scripts/migrate.sh +./scripts/load_sample_data.sh +``` + +## Step 2: Start API and Verify Health + +```bash +APP_RELOAD=false ./scripts/api_dev.sh +``` + +In separate terminal: + +```bash +curl -sS http://127.0.0.1:8000/healthz +``` + +## Step 3: Verify Test and Eval Gates + +```bash +./.venv/bin/python -m pytest tests/unit tests/integration +pnpm --dir apps/web test +EVAL_USER_ID="$(./.venv/bin/python -c 'import uuid; print(uuid.uuid4())')" +EVAL_USER_EMAIL="phase9-eval-${EVAL_USER_ID}@example.com" +./scripts/run_phase9_eval.sh --user-id "${EVAL_USER_ID}" --user-email "${EVAL_USER_EMAIL}" --display-name "Phase9 Eval" --report-path eval/reports/phase9_eval_latest.json +``` + +## Step 4: Verify Documentation Surfaces + +- `README.md` +- `docs/quickstart/local-setup-and-first-result.md` +- `docs/integrations/cli.md` +- `docs/integrations/mcp.md` +- `docs/integrations/importers.md` +- `docs/release/v0.1.0-release-checklist.md` +- `docs/release/v0.1.0-tag-plan.md` + +Ensure all commands and claims are executable and evidence-backed. + +## Step 5: Record Sprint Reports + +- update `BUILD_REPORT.md` with command evidence and outcomes +- update `REVIEW_REPORT.md` with sprint review state + +## Step 6: Execute Release Checklist + +Complete all checks in `docs/release/v0.1.0-release-checklist.md`. + +## Step 7: Tag Gate + +After checklist pass + reviewer `PASS` + explicit maintainer approval, follow: + +- `docs/release/v0.1.0-tag-plan.md` + +## Failure Handling + +- If a required command fails, stop tag prep. +- Fix only launch/doc/release-readiness issues inside `P9-S38` scope. +- Re-run failed gates and update `BUILD_REPORT.md` before retry. diff --git a/eval/baselines/phase9_s37_baseline.json b/eval/baselines/phase9_s37_baseline.json new file mode 100644 index 0000000..7ff6cab --- /dev/null +++ b/eval/baselines/phase9_s37_baseline.json @@ -0,0 +1,207 @@ +{ + "correction_effectiveness": { + "after_top_id": "26ef8d40-cd74-4b6a-a40f-4e1140b50482", + "before_top_id": "7c19aeb1-74c0-432d-afef-ac116eefcc27", + "effective": true, + "replacement_id": "26ef8d40-cd74-4b6a-a40f-4e1140b50482", + "target_importer": "openclaw" + }, + "generated_at": "2026-04-08T08:22:21.423211+00:00", + "importer_runs": [ + { + "duplicate_posture_ok": true, + "first_run": { + "dedupe_posture": "workspace_and_payload_fingerprint", + "fixture_id": "openclaw-s36-workspace-v1", + "imported_capture_event_ids": [ + "1925f916-0926-45bd-ad3b-0408c368ea68", + "57037b66-34b2-47d3-8ee3-502a1241dd89", + "c3146467-ad33-4e2f-a75f-793a54ee2bd3", + "069a479b-a502-420e-bf19-7eeadb8bb6c1" + ], + "imported_count": 4, + "imported_object_ids": [ + "7c19aeb1-74c0-432d-afef-ac116eefcc27", + "563f57c4-2227-4cb8-ab6e-e9b8da8ce9aa", + "5813abf4-fe88-48cc-9fb2-56956aff7a3e", + "094013b0-697c-4ac4-a1ce-18efcc4f25d5" + ], + "provenance_source_kind": "openclaw_import", + "skipped_duplicates": 1, + "source_path": "fixtures/openclaw/workspace_v1.json", + "status": "ok", + "total_candidates": 5, + "workspace_id": "openclaw-workspace-demo-001", + "workspace_name": "OpenClaw Interop Demo" + }, + "import_success": true, + "importer": "openclaw", + "second_run": { + "dedupe_posture": "workspace_and_payload_fingerprint", + "fixture_id": "openclaw-s36-workspace-v1", + "imported_capture_event_ids": [], + "imported_count": 0, + "imported_object_ids": [], + "provenance_source_kind": "openclaw_import", + "skipped_duplicates": 5, + "source_path": "fixtures/openclaw/workspace_v1.json", + "status": "noop", + "total_candidates": 5, + "workspace_id": "openclaw-workspace-demo-001", + "workspace_name": "OpenClaw Interop Demo" + }, + "source_kind": "openclaw_import", + "source_path": "fixtures/openclaw/workspace_v1.json" + }, + { + "duplicate_posture_ok": true, + "first_run": { + "dedupe_posture": "workspace_and_line_fingerprint", + "fixture_id": "markdown-s37-workspace-v1", + "imported_capture_event_ids": [ + "7d643a3d-0ca1-476a-b505-f5e7ca2f7cda", + "716c19f5-6065-4380-9581-22a0e0e9b7f7", + "c8745899-7c27-44bf-bf5e-b0f0582cb2b1", + "e7a43dd0-dbb1-44ca-a1e5-9a07a7a2ba06" + ], + "imported_count": 4, + "imported_object_ids": [ + "dc742ed2-1ba8-4de5-84d1-ece333faab13", + "72123f2d-a870-4592-b585-04d2705722c0", + "c4a317b6-7400-4526-b554-e8a6af1c2c8f", + "fcfc9203-1d86-4e7d-85cd-5e89af03a5ad" + ], + "provenance_source_kind": "markdown_import", + "skipped_duplicates": 1, + "source_path": "fixtures/importers/markdown/workspace_v1.md", + "status": "ok", + "total_candidates": 5, + "workspace_id": "markdown-workspace-demo-001", + "workspace_name": "Markdown Import Demo" + }, + "import_success": true, + "importer": "markdown", + "second_run": { + "dedupe_posture": "workspace_and_line_fingerprint", + "fixture_id": "markdown-s37-workspace-v1", + "imported_capture_event_ids": [], + "imported_count": 0, + "imported_object_ids": [], + "provenance_source_kind": "markdown_import", + "skipped_duplicates": 5, + "source_path": "fixtures/importers/markdown/workspace_v1.md", + "status": "noop", + "total_candidates": 5, + "workspace_id": "markdown-workspace-demo-001", + "workspace_name": "Markdown Import Demo" + }, + "source_kind": "markdown_import", + "source_path": "fixtures/importers/markdown/workspace_v1.md" + }, + { + "duplicate_posture_ok": true, + "first_run": { + "dedupe_posture": "workspace_conversation_message_fingerprint", + "fixture_id": "chatgpt-s37-workspace-v1", + "imported_capture_event_ids": [ + "a6fc88a5-5fb0-4f21-a243-1ddb964ca07b", + "f1f9fd8e-ad40-4d5d-ac52-784200cab2e9", + "dac1accf-256f-4985-8edd-29803fa56744", + "45df5b84-765b-4d34-bc60-50210d1b2d9a" + ], + "imported_count": 4, + "imported_object_ids": [ + "114e5abb-3715-40fb-90b3-7acabd2c63f4", + "316172c4-fce1-4940-a087-7c5e54518a9d", + "98e4ed19-d664-4aad-84dc-91e7c93bb6b2", + "e9b21c9a-7f35-4138-87a1-0b3f9dc6ee73" + ], + "provenance_source_kind": "chatgpt_import", + "skipped_duplicates": 1, + "source_path": "fixtures/importers/chatgpt/workspace_v1.json", + "status": "ok", + "total_candidates": 5, + "workspace_id": "chatgpt-workspace-demo-001", + "workspace_name": "ChatGPT Import Demo" + }, + "import_success": true, + "importer": "chatgpt", + "second_run": { + "dedupe_posture": "workspace_conversation_message_fingerprint", + "fixture_id": "chatgpt-s37-workspace-v1", + "imported_capture_event_ids": [], + "imported_count": 0, + "imported_object_ids": [], + "provenance_source_kind": "chatgpt_import", + "skipped_duplicates": 5, + "source_path": "fixtures/importers/chatgpt/workspace_v1.json", + "status": "noop", + "total_candidates": 5, + "workspace_id": "chatgpt-workspace-demo-001", + "workspace_name": "ChatGPT Import Demo" + }, + "source_kind": "chatgpt_import", + "source_path": "fixtures/importers/chatgpt/workspace_v1.json" + } + ], + "recall_precision_checks": [ + { + "expected_source_kind": "openclaw_import", + "hit": true, + "importer": "openclaw", + "query": "MCP tool surface", + "returned_count": 1, + "top_source_kind": "openclaw_import" + }, + { + "expected_source_kind": "markdown_import", + "hit": true, + "importer": "markdown", + "query": "markdown importer deterministic", + "returned_count": 4, + "top_source_kind": "markdown_import" + }, + { + "expected_source_kind": "chatgpt_import", + "hit": true, + "importer": "chatgpt", + "query": "ChatGPT import provenance explicit", + "returned_count": 4, + "top_source_kind": "chatgpt_import" + } + ], + "resumption_usefulness_checks": [ + { + "expected_source_kind": "openclaw_import", + "importer": "openclaw", + "last_decision_source_kind": "openclaw_import", + "next_action_source_kind": "openclaw_import", + "useful": true + }, + { + "expected_source_kind": "markdown_import", + "importer": "markdown", + "last_decision_source_kind": "markdown_import", + "next_action_source_kind": "markdown_import", + "useful": true + }, + { + "expected_source_kind": "chatgpt_import", + "importer": "chatgpt", + "last_decision_source_kind": "chatgpt_import", + "next_action_source_kind": "chatgpt_import", + "useful": true + } + ], + "schema_version": "phase9_eval_v1", + "summary": { + "correction_effectiveness_rate": 1.0, + "duplicate_posture_rate": 1.0, + "importer_count": 3, + "importer_success_rate": 1.0, + "pass_threshold": 1.0, + "recall_precision_at_1": 1.0, + "resumption_usefulness_rate": 1.0, + "status": "pass" + } +} diff --git a/eval/reports/phase9_eval_latest.json b/eval/reports/phase9_eval_latest.json new file mode 100644 index 0000000..d93e90f --- /dev/null +++ b/eval/reports/phase9_eval_latest.json @@ -0,0 +1,207 @@ +{ + "correction_effectiveness": { + "after_top_id": "82fd3c0b-729b-4cc8-b98d-ac6b585d4070", + "before_top_id": "6ad3691a-42c8-4179-9d5a-9bcca2e71906", + "effective": true, + "replacement_id": "82fd3c0b-729b-4cc8-b98d-ac6b585d4070", + "target_importer": "openclaw" + }, + "generated_at": "2026-04-08T09:55:27.525902+00:00", + "importer_runs": [ + { + "duplicate_posture_ok": true, + "first_run": { + "dedupe_posture": "workspace_and_payload_fingerprint", + "fixture_id": "openclaw-s36-workspace-v1", + "imported_capture_event_ids": [ + "5baef767-3325-4e8a-b8b6-d39e31a9a07c", + "63418de1-cf3e-4996-81ec-e37e5415367d", + "a7d52f0c-6875-4504-89a0-9befa0e183c1", + "4f695f89-1d39-484d-b063-5963cbef0c6a" + ], + "imported_count": 4, + "imported_object_ids": [ + "6ad3691a-42c8-4179-9d5a-9bcca2e71906", + "af2891a9-fa82-401b-b264-8267029133bb", + "215a5a90-6e32-4870-a7cd-500148ca7d14", + "7b3905cb-6c53-4029-a886-ee8723123825" + ], + "provenance_source_kind": "openclaw_import", + "skipped_duplicates": 1, + "source_path": "fixtures/openclaw/workspace_v1.json", + "status": "ok", + "total_candidates": 5, + "workspace_id": "openclaw-workspace-demo-001", + "workspace_name": "OpenClaw Interop Demo" + }, + "import_success": true, + "importer": "openclaw", + "second_run": { + "dedupe_posture": "workspace_and_payload_fingerprint", + "fixture_id": "openclaw-s36-workspace-v1", + "imported_capture_event_ids": [], + "imported_count": 0, + "imported_object_ids": [], + "provenance_source_kind": "openclaw_import", + "skipped_duplicates": 5, + "source_path": "fixtures/openclaw/workspace_v1.json", + "status": "noop", + "total_candidates": 5, + "workspace_id": "openclaw-workspace-demo-001", + "workspace_name": "OpenClaw Interop Demo" + }, + "source_kind": "openclaw_import", + "source_path": "fixtures/openclaw/workspace_v1.json" + }, + { + "duplicate_posture_ok": true, + "first_run": { + "dedupe_posture": "workspace_and_line_fingerprint", + "fixture_id": "markdown-s37-workspace-v1", + "imported_capture_event_ids": [ + "962e4106-6e50-49c4-8852-c2c4a2422698", + "c631ab62-5bfb-4ceb-9905-1c1f7b6b5444", + "b9201b2f-d4ba-446b-990c-8c5a05f47291", + "47f083d0-3650-4dba-a7d2-4a2b40d6a67d" + ], + "imported_count": 4, + "imported_object_ids": [ + "3b8bf41b-4164-4adb-9f98-b6f34aabe340", + "50b5acec-b2b3-4543-88fd-985339411dd4", + "c8636bac-dab3-4240-a296-5393ec79e4e0", + "aad069d4-50fb-450b-a32d-5e8e9d3613bc" + ], + "provenance_source_kind": "markdown_import", + "skipped_duplicates": 1, + "source_path": "fixtures/importers/markdown/workspace_v1.md", + "status": "ok", + "total_candidates": 5, + "workspace_id": "markdown-workspace-demo-001", + "workspace_name": "Markdown Import Demo" + }, + "import_success": true, + "importer": "markdown", + "second_run": { + "dedupe_posture": "workspace_and_line_fingerprint", + "fixture_id": "markdown-s37-workspace-v1", + "imported_capture_event_ids": [], + "imported_count": 0, + "imported_object_ids": [], + "provenance_source_kind": "markdown_import", + "skipped_duplicates": 5, + "source_path": "fixtures/importers/markdown/workspace_v1.md", + "status": "noop", + "total_candidates": 5, + "workspace_id": "markdown-workspace-demo-001", + "workspace_name": "Markdown Import Demo" + }, + "source_kind": "markdown_import", + "source_path": "fixtures/importers/markdown/workspace_v1.md" + }, + { + "duplicate_posture_ok": true, + "first_run": { + "dedupe_posture": "workspace_conversation_message_fingerprint", + "fixture_id": "chatgpt-s37-workspace-v1", + "imported_capture_event_ids": [ + "510a3e8b-4e8a-4f67-bfb2-01f8b0958bd9", + "6f35a337-821e-479f-a21e-568c557d0d5a", + "6453c89f-7fc4-4261-8532-660a8c45661b", + "b3561524-c354-4658-b114-2550ab7d09ff" + ], + "imported_count": 4, + "imported_object_ids": [ + "d68db45d-cf43-4e2d-bf20-89f804e12db5", + "54892bea-c0d2-461a-a010-452994271fb9", + "d877be64-0025-417a-8119-f6284a4f0475", + "75395b95-91d9-444e-8490-a3b002aec95d" + ], + "provenance_source_kind": "chatgpt_import", + "skipped_duplicates": 1, + "source_path": "fixtures/importers/chatgpt/workspace_v1.json", + "status": "ok", + "total_candidates": 5, + "workspace_id": "chatgpt-workspace-demo-001", + "workspace_name": "ChatGPT Import Demo" + }, + "import_success": true, + "importer": "chatgpt", + "second_run": { + "dedupe_posture": "workspace_conversation_message_fingerprint", + "fixture_id": "chatgpt-s37-workspace-v1", + "imported_capture_event_ids": [], + "imported_count": 0, + "imported_object_ids": [], + "provenance_source_kind": "chatgpt_import", + "skipped_duplicates": 5, + "source_path": "fixtures/importers/chatgpt/workspace_v1.json", + "status": "noop", + "total_candidates": 5, + "workspace_id": "chatgpt-workspace-demo-001", + "workspace_name": "ChatGPT Import Demo" + }, + "source_kind": "chatgpt_import", + "source_path": "fixtures/importers/chatgpt/workspace_v1.json" + } + ], + "recall_precision_checks": [ + { + "expected_source_kind": "openclaw_import", + "hit": true, + "importer": "openclaw", + "query": "MCP tool surface", + "returned_count": 1, + "top_source_kind": "openclaw_import" + }, + { + "expected_source_kind": "markdown_import", + "hit": true, + "importer": "markdown", + "query": "markdown importer deterministic", + "returned_count": 4, + "top_source_kind": "markdown_import" + }, + { + "expected_source_kind": "chatgpt_import", + "hit": true, + "importer": "chatgpt", + "query": "ChatGPT import provenance explicit", + "returned_count": 4, + "top_source_kind": "chatgpt_import" + } + ], + "resumption_usefulness_checks": [ + { + "expected_source_kind": "openclaw_import", + "importer": "openclaw", + "last_decision_source_kind": "openclaw_import", + "next_action_source_kind": "openclaw_import", + "useful": true + }, + { + "expected_source_kind": "markdown_import", + "importer": "markdown", + "last_decision_source_kind": "markdown_import", + "next_action_source_kind": "markdown_import", + "useful": true + }, + { + "expected_source_kind": "chatgpt_import", + "importer": "chatgpt", + "last_decision_source_kind": "chatgpt_import", + "next_action_source_kind": "chatgpt_import", + "useful": true + } + ], + "schema_version": "phase9_eval_v1", + "summary": { + "correction_effectiveness_rate": 1.0, + "duplicate_posture_rate": 1.0, + "importer_count": 3, + "importer_success_rate": 1.0, + "pass_threshold": 1.0, + "recall_precision_at_1": 1.0, + "resumption_usefulness_rate": 1.0, + "status": "pass" + } +} diff --git a/fixtures/importers/chatgpt/workspace_v1.json b/fixtures/importers/chatgpt/workspace_v1.json new file mode 100644 index 0000000..149caf7 --- /dev/null +++ b/fixtures/importers/chatgpt/workspace_v1.json @@ -0,0 +1,52 @@ +{ + "fixture_id": "chatgpt-s37-workspace-v1", + "workspace": { + "id": "chatgpt-workspace-demo-001", + "name": "ChatGPT Import Demo" + }, + "conversations": [ + { + "id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + "title": "Phase 9 Import Plan", + "project": "ChatGPT Import Project", + "person": "Interop Owner", + "messages": [ + { + "id": "cgpt-msg-001", + "role": "assistant", + "text": "Decision: Keep ChatGPT import provenance explicit for every message.", + "status": "active", + "confidence": 0.95 + }, + { + "id": "cgpt-msg-002", + "role": "assistant", + "text": "Next Action: Run ChatGPT fixture import before phase9 evaluation.", + "status": "active", + "confidence": 0.92 + }, + { + "id": "cgpt-msg-003", + "role": "assistant", + "text": "Blocker: Need reviewer confirmation on dedupe posture.", + "status": "active", + "confidence": 0.90 + }, + { + "id": "cgpt-msg-004", + "role": "assistant", + "text": "Commitment: Capture correction outcome evidence in eval report.", + "status": "completed", + "confidence": 0.91 + }, + { + "id": "cgpt-msg-004", + "role": "assistant", + "text": "Commitment: Capture correction outcome evidence in eval report.", + "status": "completed", + "confidence": 0.91 + } + ] + } + ] +} diff --git a/fixtures/importers/markdown/workspace_v1.md b/fixtures/importers/markdown/workspace_v1.md new file mode 100644 index 0000000..d374301 --- /dev/null +++ b/fixtures/importers/markdown/workspace_v1.md @@ -0,0 +1,19 @@ +--- +fixture_id: markdown-s37-workspace-v1 +workspace_id: markdown-workspace-demo-001 +workspace_name: Markdown Import Demo +thread_id: eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee +task_id: ffffffff-ffff-4fff-8fff-ffffffffffff +project: Markdown Import Project +person: Markdown Interop Owner +default_status: active +default_confidence: 0.90 +--- + +# Markdown Continuity Snapshot + +- Decision: Keep markdown importer deterministic for baseline evidence. | source_event_id=markdown-event-0001 | confirmation_status=confirmed +- Next Action: Run markdown fixture import before evaluation harness. | source_event_id=markdown-event-0002 | person=Build Engineer | confirmation_status=confirmed +- Waiting For: reviewer PASS on markdown importer verification. | source_event_id=markdown-event-0003 +- Commitment: Publish markdown importer usage docs in README. | status=completed | source_event_id=markdown-event-0004 | confirmation_status=confirmed +- Commitment: Publish markdown importer usage docs in README. | status=completed | source_event_id=markdown-event-0004 | confirmation_status=confirmed diff --git a/fixtures/openclaw/workspace_dir_v1/openclaw_memories.json b/fixtures/openclaw/workspace_dir_v1/openclaw_memories.json new file mode 100644 index 0000000..af326f7 --- /dev/null +++ b/fixtures/openclaw/workspace_dir_v1/openclaw_memories.json @@ -0,0 +1,72 @@ +{ + "workspace": { + "id": "openclaw-workspace-dir-demo-001", + "name": "OpenClaw Directory Interop Demo" + }, + "durable_memory": [ + { + "id": "oc-dir-memory-001", + "type": "decision", + "status": "active", + "content": "Use one command to bootstrap OpenClaw import checks.", + "thread_id": "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + "task_id": "dddddddd-dddd-4ddd-8ddd-dddddddddddd", + "project": "Alice Public Core", + "person": "Interop Owner", + "source_event_ids": [ + "openclaw-dir-event-0001" + ], + "confirmation_status": "confirmed", + "confidence": 0.95 + }, + { + "id": "oc-dir-memory-002", + "type": "next_action", + "status": "active", + "content": "Run Alice recall after OpenClaw import to verify provenance labels.", + "thread_id": "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + "task_id": "dddddddd-dddd-4ddd-8ddd-dddddddddddd", + "project": "Alice Public Core", + "person": "Build Engineer", + "source_event_ids": [ + "openclaw-dir-event-0002" + ], + "confirmation_status": "confirmed", + "confidence": 0.92 + } + ], + "records": [ + { + "id": "oc-dir-memory-003", + "kind": "waiting_for", + "status": "active", + "content": "Wait for idempotent replay check to pass.", + "thread_id": "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + "task_id": "dddddddd-dddd-4ddd-8ddd-dddddddddddd", + "project": "Alice Public Core", + "person": "Maintainers", + "provenance": { + "source_event_ids": [ + "openclaw-dir-event-0003" + ] + }, + "confidence": 0.9 + }, + { + "id": "oc-dir-memory-003", + "kind": "waiting_for", + "status": "active", + "content": "Wait for idempotent replay check to pass.", + "thread_id": "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + "task_id": "dddddddd-dddd-4ddd-8ddd-dddddddddddd", + "project": "Alice Public Core", + "person": "Maintainers", + "provenance": { + "source_event_ids": [ + "openclaw-dir-event-0003" + ] + }, + "confidence": 0.9 + } + ] +} diff --git a/fixtures/openclaw/workspace_dir_v1/workspace.json b/fixtures/openclaw/workspace_dir_v1/workspace.json new file mode 100644 index 0000000..8a80f4f --- /dev/null +++ b/fixtures/openclaw/workspace_dir_v1/workspace.json @@ -0,0 +1,7 @@ +{ + "fixture_id": "openclaw-s39-workspace-dir-v1", + "workspace": { + "id": "openclaw-workspace-dir-demo-001", + "name": "OpenClaw Directory Interop Demo" + } +} diff --git a/fixtures/openclaw/workspace_v1.json b/fixtures/openclaw/workspace_v1.json new file mode 100644 index 0000000..fa8f144 --- /dev/null +++ b/fixtures/openclaw/workspace_v1.json @@ -0,0 +1,88 @@ +{ + "fixture_id": "openclaw-s36-workspace-v1", + "workspace": { + "id": "openclaw-workspace-demo-001", + "name": "OpenClaw Interop Demo" + }, + "durable_memory": [ + { + "id": "oc-memory-001", + "type": "decision", + "status": "active", + "content": "Keep MCP tool surface narrow during Phase 9 interop rollout.", + "thread_id": "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + "task_id": "dddddddd-dddd-4ddd-8ddd-dddddddddddd", + "project": "Alice Public Core", + "person": "Interop Owner", + "source_event_ids": [ + "openclaw-event-0001" + ], + "confirmation_status": "confirmed", + "confidence": 0.97, + "tags": [ + "release", + "interop" + ] + }, + { + "id": "oc-memory-002", + "type": "next_action", + "status": "active", + "content": "Run OpenClaw fixture import before CLI recall and resume checks.", + "thread_id": "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + "task_id": "dddddddd-dddd-4ddd-8ddd-dddddddddddd", + "project": "Alice Public Core", + "person": "Build Engineer", + "source_event_ids": [ + "openclaw-event-0002" + ], + "confirmation_status": "confirmed", + "confidence": 0.93 + }, + { + "id": "oc-memory-003", + "type": "waiting_for", + "status": "active", + "content": "Wait for reviewer PASS after OpenClaw adapter verification.", + "thread_id": "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + "task_id": "dddddddd-dddd-4ddd-8ddd-dddddddddddd", + "project": "Alice Public Core", + "person": "Maintainers", + "source_event_ids": [ + "openclaw-event-0003" + ], + "confirmation_status": "unconfirmed", + "confidence": 0.9 + }, + { + "id": "oc-memory-004", + "type": "commitment", + "status": "completed", + "content": "Document the import boundary in ADR-004.", + "thread_id": "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + "task_id": "dddddddd-dddd-4ddd-8ddd-dddddddddddd", + "project": "Alice Public Core", + "person": "Docs Owner", + "source_event_ids": [ + "openclaw-event-0004" + ], + "confirmation_status": "confirmed", + "confidence": 0.91 + }, + { + "id": "oc-memory-004", + "type": "commitment", + "status": "completed", + "content": "Document the import boundary in ADR-004.", + "thread_id": "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + "task_id": "dddddddd-dddd-4ddd-8ddd-dddddddddddd", + "project": "Alice Public Core", + "person": "Docs Owner", + "source_event_ids": [ + "openclaw-event-0004" + ], + "confirmation_status": "confirmed", + "confidence": 0.91 + } + ] +} diff --git a/fixtures/public_sample_data/continuity_v1.json b/fixtures/public_sample_data/continuity_v1.json new file mode 100644 index 0000000..44de184 --- /dev/null +++ b/fixtures/public_sample_data/continuity_v1.json @@ -0,0 +1,94 @@ +{ + "fixture_id": "public-core-s33-continuity-v1", + "description": "Deterministic continuity fixture for public-core quickstart recall and resumption checks.", + "user": { + "email": "public-sample@example.com", + "display_name": "Public Sample User" + }, + "objects": [ + { + "raw_content": "Decision: Keep Alice local-first for public v0.1 packaging.", + "explicit_signal": "decision", + "object_type": "Decision", + "status": "active", + "title": "Decision: Keep Alice local-first for public v0.1 packaging.", + "body": { + "decision_text": "Keep Alice local-first for public v0.1 packaging." + }, + "confidence": 0.98, + "provenance": { + "thread_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "task_id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + "project": "Alice Public Core", + "person": "Platform Team", + "source_event_ids": [ + "sample-event-0001" + ], + "confirmation_status": "confirmed" + } + }, + { + "raw_content": "Task: Publish public-core packaging docs and startup path.", + "explicit_signal": "task", + "object_type": "NextAction", + "status": "active", + "title": "Next Action: Publish public-core packaging docs and startup path.", + "body": { + "action_text": "Publish public-core packaging docs and startup path." + }, + "confidence": 0.94, + "provenance": { + "thread_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "task_id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + "project": "Alice Public Core", + "person": "Docs Owner", + "source_event_ids": [ + "sample-event-0002" + ], + "confirmation_status": "confirmed" + } + }, + { + "raw_content": "Waiting for: reviewer PASS and merge approval from maintainers.", + "explicit_signal": "waiting_for", + "object_type": "WaitingFor", + "status": "active", + "title": "Waiting For: reviewer PASS and merge approval from maintainers.", + "body": { + "waiting_for_text": "Reviewer PASS and merge approval from maintainers." + }, + "confidence": 0.91, + "provenance": { + "thread_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "task_id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + "project": "Alice Public Core", + "person": "Maintainers", + "source_event_ids": [ + "sample-event-0003" + ], + "confirmation_status": "unconfirmed" + } + }, + { + "raw_content": "Commitment: publish the public-core bootstrap packet.", + "explicit_signal": "commitment", + "object_type": "Commitment", + "status": "completed", + "title": "Commitment: Publish the public-core bootstrap packet.", + "body": { + "commitment_text": "Publish the public-core bootstrap packet." + }, + "confidence": 0.88, + "provenance": { + "thread_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "task_id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + "project": "Alice Public Core", + "person": "Program Lead", + "source_event_ids": [ + "sample-event-0004" + ], + "confirmation_status": "confirmed" + } + } + ] +} diff --git a/infra/postgres/init/001_roles.sql b/infra/postgres/init/001_roles.sql new file mode 100644 index 0000000..78f9d49 --- /dev/null +++ b/infra/postgres/init/001_roles.sql @@ -0,0 +1,16 @@ +DO +$$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'alicebot_app') THEN + CREATE ROLE alicebot_app + LOGIN + PASSWORD 'alicebot_app' + NOSUPERUSER + NOCREATEDB + NOCREATEROLE + NOINHERIT; + END IF; +END +$$; + +GRANT CONNECT ON DATABASE alicebot TO alicebot_app; diff --git a/packages/alice-cli/README.md b/packages/alice-cli/README.md new file mode 100644 index 0000000..c50733c --- /dev/null +++ b/packages/alice-cli/README.md @@ -0,0 +1,36 @@ +# @aliceos/alice-cli + +CLI package scaffold for AliceOS. + +## Install + +```bash +npm install -g @aliceos/alice-cli +``` + +## Usage + +```bash +alice hello +alice mcp --help +alice --help +alice --version +``` + +## MCP passthrough + +`alice mcp` launches the Python Alice MCP server: + +```bash +ALICEBOT_PYTHON=/ABS/PATH/TO/AliceBot/.venv/bin/python alice mcp --help +``` + +For `npx` usage: + +```bash +ALICEBOT_PYTHON=/ABS/PATH/TO/AliceBot/.venv/bin/python npx -y @aliceos/alice-cli mcp --help +``` + +Prerequisite: the selected Python runtime must be able to import +`alicebot_api.mcp_server` (for example, run from this repository after editable +install). diff --git a/packages/alice-cli/bin/alice.js b/packages/alice-cli/bin/alice.js new file mode 100755 index 0000000..6c5bceb --- /dev/null +++ b/packages/alice-cli/bin/alice.js @@ -0,0 +1,72 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; + +const args = process.argv.slice(2); +const version = "0.1.1"; +const defaultPythonCommand = process.platform === "win32" ? "python" : "python3"; + +if (args.includes("--version") || args.includes("-v")) { + console.log(version); + process.exit(0); +} + +if (args.length === 0 || args[0] === "--help" || args[0] === "-h") { + console.log(`alice ${version} + +Usage: + alice hello + alice mcp [alicebot-mcp-args...] + alice --help + alice --version`); + process.exit(0); +} + +if (args[0] === "hello") { + try { + const { helloAlice } = await import("@aliceos/alice-core"); + console.log(helloAlice()); + process.exit(0); + } catch (error) { + console.error( + `Failed to load @aliceos/alice-core: ${error.message} +Install dependencies with npm install.`, + ); + process.exit(1); + } +} + +if (args[0] === "mcp") { + const pythonCommand = process.env.ALICEBOT_PYTHON || defaultPythonCommand; + const child = spawn( + pythonCommand, + ["-m", "alicebot_api.mcp_server", ...args.slice(1)], + { + stdio: "inherit", + env: process.env, + }, + ); + + child.on("error", (error) => { + console.error( + `Failed to start Alice MCP server using "${pythonCommand}": ${error.message} +Set ALICEBOT_PYTHON to your Alice Python runtime (for example: /abs/path/.venv/bin/python).`, + ); + process.exit(1); + }); + + child.on("exit", (code, signal) => { + if (typeof code === "number") { + process.exit(code); + } + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(1); + }); +} else { + console.error(`Unknown command: ${args[0]} +Run "alice --help" for usage.`); + process.exit(1); +} diff --git a/packages/alice-cli/package.json b/packages/alice-cli/package.json new file mode 100644 index 0000000..be7c033 --- /dev/null +++ b/packages/alice-cli/package.json @@ -0,0 +1,41 @@ +{ + "name": "@aliceos/alice-cli", + "version": "0.1.1", + "description": "CLI package for AliceOS.", + "type": "module", + "bin": { + "alice": "./bin/alice.js" + }, + "files": [ + "bin/alice.js", + "README.md" + ], + "scripts": { + "test": "node ./bin/alice.js --help" + }, + "keywords": [ + "alice", + "aliceos", + "cli" + ], + "author": "AliceOS", + "repository": { + "type": "git", + "url": "git+https://github.com/samrusani/AliceBot.git", + "directory": "packages/alice-cli" + }, + "homepage": "https://github.com/samrusani/AliceBot/tree/main/packages/alice-cli", + "bugs": { + "url": "https://github.com/samrusani/AliceBot/issues" + }, + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "engines": { + "node": ">=18" + }, + "dependencies": { + "@aliceos/alice-core": "^0.1.0" + } +} diff --git a/packages/alice-core/README.md b/packages/alice-core/README.md new file mode 100644 index 0000000..5f1928d --- /dev/null +++ b/packages/alice-core/README.md @@ -0,0 +1,17 @@ +# @aliceos/alice-core + +Core package scaffold for AliceOS. + +## Install + +```bash +npm install @aliceos/alice-core +``` + +## Usage + +```js +import { helloAlice } from "@aliceos/alice-core"; + +console.log(helloAlice()); +``` diff --git a/packages/alice-core/index.js b/packages/alice-core/index.js new file mode 100644 index 0000000..49ebc1d --- /dev/null +++ b/packages/alice-core/index.js @@ -0,0 +1,5 @@ +export const ALICE_CORE_VERSION = "0.1.0"; + +export function helloAlice() { + return "hello from @aliceos/alice-core"; +} diff --git a/packages/alice-core/package.json b/packages/alice-core/package.json new file mode 100644 index 0000000..5d581ab --- /dev/null +++ b/packages/alice-core/package.json @@ -0,0 +1,39 @@ +{ + "name": "@aliceos/alice-core", + "version": "0.1.0", + "description": "Core utilities for AliceOS packages.", + "type": "module", + "main": "./index.js", + "exports": { + ".": "./index.js" + }, + "files": [ + "index.js", + "README.md" + ], + "scripts": { + "test": "node -e \"import('./index.js').then((m)=>console.log(m.helloAlice()))\"" + }, + "keywords": [ + "alice", + "aliceos", + "core" + ], + "author": "AliceOS", + "repository": { + "type": "git", + "url": "git+https://github.com/samrusani/AliceBot.git", + "directory": "packages/alice-core" + }, + "homepage": "https://github.com/samrusani/AliceBot/tree/main/packages/alice-core", + "bugs": { + "url": "https://github.com/samrusani/AliceBot/issues" + }, + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "engines": { + "node": ">=18" + } +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..aa83986 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,36 @@ +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "alice-core" +version = "0.1.0" +description = "Public-safe Alice core package for local memory and continuity workflows." +requires-python = ">=3.12" +dependencies = [ + "alembic>=1.14,<2.0", + "fastapi>=0.115,<1.0", + "psycopg[binary]>=3.2,<4.0", + "redis>=5.0,<6.0", + "sqlalchemy>=2.0,<3.0", + "uvicorn>=0.34,<1.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.3,<9.0", +] + +[project.scripts] +alicebot = "alicebot_api.cli:main" +alicebot-mcp = "alicebot_api.mcp_server:main" + +[tool.setuptools.package-dir] +"" = "." + +[tool.setuptools.packages.find] +where = ["apps/api/src", "workers"] + +[tool.pytest.ini_options] +pythonpath = ["apps/api/src", "workers"] +testpaths = ["tests"] diff --git a/scripts/.gitkeep b/scripts/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/scripts/.gitkeep @@ -0,0 +1 @@ + diff --git a/scripts/api_dev.sh b/scripts/api_dev.sh new file mode 100755 index 0000000..1afa5cc --- /dev/null +++ b/scripts/api_dev.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" + +if [ -f "${REPO_ROOT}/.env" ]; then + PRESERVE_ENV_KEYS=( + APP_ENV + APP_HOST + APP_PORT + APP_RELOAD + DATABASE_URL + DATABASE_ADMIN_URL + REDIS_URL + S3_ENDPOINT_URL + S3_ACCESS_KEY + S3_SECRET_KEY + S3_BUCKET + HEALTHCHECK_TIMEOUT_SECONDS + TASK_WORKSPACE_ROOT + ALICEBOT_AUTH_USER_ID + PUBLIC_SAMPLE_DATA_PATH + RESPONSE_RATE_LIMIT_WINDOW_SECONDS + RESPONSE_RATE_LIMIT_MAX_REQUESTS + ) + + for key in "${PRESERVE_ENV_KEYS[@]}"; do + if [ "${!key+x}" = "x" ]; then + export "__PRESERVE_${key}=${!key}" + fi + done + + set -a + . "${REPO_ROOT}/.env" + set +a + + for key in "${PRESERVE_ENV_KEYS[@]}"; do + preserve_key="__PRESERVE_${key}" + if [ "${!preserve_key+x}" = "x" ]; then + export "${key}=${!preserve_key}" + unset "${preserve_key}" + fi + done +fi + +PYTHON_BIN="python3" +if [ -x "${REPO_ROOT}/.venv/bin/python" ]; then + PYTHON_BIN="${REPO_ROOT}/.venv/bin/python" +fi + +cd "${REPO_ROOT}" + +UVICORN_ARGS=( + --app-dir "${REPO_ROOT}/apps/api/src" + --host "${APP_HOST:-127.0.0.1}" + --port "${APP_PORT:-8000}" +) + +if [ "${APP_RELOAD:-true}" = "true" ]; then + UVICORN_ARGS+=(--reload) +fi + +exec "${PYTHON_BIN}" -m uvicorn alicebot_api.main:app "${UVICORN_ARGS[@]}" diff --git a/scripts/check_control_doc_truth.py b/scripts/check_control_doc_truth.py new file mode 100644 index 0000000..931b516 --- /dev/null +++ b/scripts/check_control_doc_truth.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + + +ROOT_DIR = Path(__file__).resolve().parents[1] + + +@dataclass(frozen=True, slots=True) +class ControlDocTruthRule: + relative_path: str + required_markers: tuple[str, ...] + + +CONTROL_DOC_TRUTH_RULES: tuple[ControlDocTruthRule, ...] = ( + ControlDocTruthRule( + relative_path="README.md", + required_markers=( + "Phase 9 is complete.", + "Alice Connect is the planned Phase 10 product layer", + "Historical planning and control docs: [docs/archive/planning/2026-04-08-context-compaction/README.md]", + ), + ), + ControlDocTruthRule( + relative_path="ROADMAP.md", + required_markers=( + "Phase 10 is the next delivery phase: Alice Connect.", + "P10-S1: Identity + Workspace Bootstrap", + ), + ), + ControlDocTruthRule( + relative_path=".ai/active/SPRINT_PACKET.md", + required_markers=( + "Phase 10 Sprint 1 (P10-S1): Identity + Workspace Bootstrap", + "Phase 9 shipped scope is baseline truth, not sprint work", + ), + ), + ControlDocTruthRule( + relative_path="RULES.md", + required_markers=( + "Phase 10 must not fork semantics between local, CLI, MCP, and Telegram.", + "Do not rewrite shipped Phase 9 capabilities as future roadmap items.", + ), + ), + ControlDocTruthRule( + relative_path=".ai/handoff/CURRENT_STATE.md", + required_markers=( + "Phase 9 is complete and shipped.", + "Phase 10 planning is defined as Alice Connect.", + "P10-S1 (Identity + Workspace Bootstrap) is the first execution sprint packet.", + ), + ), + ControlDocTruthRule( + relative_path="docs/archive/planning/2026-04-08-context-compaction/README.md", + required_markers=("This folder preserves superseded planning and control material removed from the live docs during Context Compaction 01.",), + ), +) + +DISALLOWED_MARKERS: tuple[str, ...] = ( + "through Phase 3 Sprint 9", + "Active Sprint focus is Phase 4 Sprint 14", + "Gate ownership is canonicalized to Phase 4 runner scripts", + "Gate ownership is canonicalized to Phase 4 runner script names", + "Legacy Compatibility Marker", + "Legacy Compatibility Markers", + "Phase 9 Sprint Sequence", + "No active build sprint is open.", + "Phase 10 planning docs are not defined yet.", + "Keep this file as an idle-state pointer, not as a fake active sprint.", +) + + +def run_control_doc_truth_check( + *, + root_dir: Path = ROOT_DIR, + rules: tuple[ControlDocTruthRule, ...] = CONTROL_DOC_TRUTH_RULES, + disallowed_markers: tuple[str, ...] = DISALLOWED_MARKERS, +) -> list[str]: + issues: list[str] = [] + for rule in rules: + doc_path = root_dir / rule.relative_path + if not doc_path.exists(): + issues.append(f"{rule.relative_path}: missing file") + continue + + text = doc_path.read_text(encoding="utf-8") + for marker in rule.required_markers: + if marker not in text: + issues.append(f"{rule.relative_path}: missing required marker '{marker}'") + + lowered_text = text.casefold() + for marker in disallowed_markers: + if marker.casefold() in lowered_text: + issues.append(f"{rule.relative_path}: contains disallowed marker '{marker}'") + + return issues + + +def main() -> int: + issues = run_control_doc_truth_check() + if issues: + print("Control-doc truth check: FAIL") + for issue in issues: + print(f" - {issue}") + return 1 + + print("Control-doc truth check: PASS") + for rule in CONTROL_DOC_TRUTH_RULES: + print(f" - verified: {rule.relative_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/dev_up.sh b/scripts/dev_up.sh new file mode 100755 index 0000000..983ce1b --- /dev/null +++ b/scripts/dev_up.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" + +if [ -f "${REPO_ROOT}/.env" ]; then + set -a + . "${REPO_ROOT}/.env" + set +a +fi + +PYTHON_BIN="python3" +if [ -x "${REPO_ROOT}/.venv/bin/python" ]; then + PYTHON_BIN="${REPO_ROOT}/.venv/bin/python" +fi + +cd "${REPO_ROOT}" + +docker compose up -d + +"${PYTHON_BIN}" -c ' +import os +import sys +import time + +import psycopg + +database_url = os.getenv( + "DATABASE_ADMIN_URL", + "postgresql://alicebot_admin:alicebot_admin@localhost:5432/alicebot", +) +deadline = time.time() + 60 + +while time.time() < deadline: + try: + with psycopg.connect(database_url, connect_timeout=1) as conn: + with conn.cursor() as cur: + cur.execute("SELECT 1 FROM pg_roles WHERE rolname = %s", ("alicebot_app",)) + if cur.fetchone() == (1,): + sys.exit(0) + except psycopg.Error: + pass + time.sleep(1) + +raise SystemExit("Timed out waiting for Postgres readiness and alicebot_app bootstrap") +' + +"${PYTHON_BIN}" -m alembic -c "${REPO_ROOT}/apps/api/alembic.ini" upgrade head diff --git a/scripts/generate_phase4_mvp_exit_manifest.py b/scripts/generate_phase4_mvp_exit_manifest.py new file mode 100644 index 0000000..b9dfc71 --- /dev/null +++ b/scripts/generate_phase4_mvp_exit_manifest.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +from datetime import datetime +import hashlib +import json +import os +from pathlib import Path +import time + + +ROOT_DIR = Path(__file__).resolve().parents[1] +DEFAULT_ARCHIVE_INDEX_PATH = ROOT_DIR / "artifacts" / "release" / "archive" / "index.json" +DEFAULT_MANIFEST_PATH = ROOT_DIR / "artifacts" / "release" / "phase4_mvp_exit_manifest.json" + +MANIFEST_ARTIFACT_VERSION = "phase4_mvp_exit_manifest.v1" +ARCHIVE_INDEX_VERSION = "phase4_rc_archive_index.v1" +RC_SUMMARY_ARTIFACT_VERSION = "phase4_rc_summary.v1" +REQUIRED_COMPATIBILITY_COMMANDS: tuple[str, ...] = ( + "python3 scripts/run_phase4_validation_matrix.py", + "python3 scripts/run_phase3_validation_matrix.py", + "python3 scripts/run_phase2_validation_matrix.py", + "python3 scripts/run_mvp_validation_matrix.py", +) +UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%SZ" + + +def _resolve_path(path_value: str) -> Path: + candidate = Path(path_value) + if candidate.is_absolute(): + return candidate + return ROOT_DIR / candidate + + +def _render_path(path: Path) -> str: + try: + return str(path.relative_to(ROOT_DIR)) + except ValueError: + return str(path) + + +def _render_json(payload: dict[str, object]) -> str: + return json.dumps(payload, indent=2, sort_keys=True) + "\n" + + +def _atomic_write_json(*, path: Path, payload: dict[str, object]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temp_path = path.parent / f".{path.name}.tmp.{os.getpid()}.{time.monotonic_ns()}" + temp_path.write_text(_render_json(payload), encoding="utf-8") + try: + os.replace(temp_path, path) + finally: + if temp_path.exists(): + temp_path.unlink() + + +def _load_json_object(path: Path, *, label: str) -> dict[str, object]: + payload = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise ValueError(f"{label} must be a JSON object: {path}") + return payload + + +def _validate_utc_timestamp(value: object, *, field_name: str) -> str: + if not isinstance(value, str): + raise ValueError(f"{field_name} must be a string in {UTC_TIMESTAMP_FORMAT}.") + try: + datetime.strptime(value, UTC_TIMESTAMP_FORMAT) + except ValueError as exc: + raise ValueError(f"{field_name} must use {UTC_TIMESTAMP_FORMAT}.") from exc + return value + + +def _sha256_for_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _load_archive_index(index_path: Path) -> dict[str, object]: + if not index_path.exists(): + raise ValueError(f"Phase 4 RC archive index not found: {index_path}") + payload = _load_json_object(index_path, label="archive index") + if payload.get("artifact_version") != ARCHIVE_INDEX_VERSION: + raise ValueError( + "archive index artifact_version must be " + f"{ARCHIVE_INDEX_VERSION!r}, got {payload.get('artifact_version')!r}." + ) + entries = payload.get("entries") + if not isinstance(entries, list): + raise ValueError("archive index entries must be a list.") + return payload + + +def _find_latest_go_entry(index_payload: dict[str, object]) -> tuple[int, dict[str, object]]: + entries = index_payload["entries"] + assert isinstance(entries, list) + + for entry_index in range(len(entries) - 1, -1, -1): + entry = entries[entry_index] + if isinstance(entry, dict) and entry.get("final_decision") == "GO": + return entry_index, entry + + raise ValueError("archive index does not contain a GO rehearsal entry.") + + +def _extract_go_summary_contract(summary_payload: dict[str, object]) -> tuple[list[str], dict[str, str]]: + if summary_payload.get("artifact_version") != RC_SUMMARY_ARTIFACT_VERSION: + raise ValueError( + "archive summary artifact_version must be " + f"{RC_SUMMARY_ARTIFACT_VERSION!r}, got {summary_payload.get('artifact_version')!r}." + ) + if summary_payload.get("final_decision") != "GO": + raise ValueError("archive summary final_decision must be GO.") + if summary_payload.get("summary_exit_code") != 0: + raise ValueError("archive summary summary_exit_code must be 0 for GO evidence.") + if summary_payload.get("failing_steps") != []: + raise ValueError("archive summary failing_steps must be [] for GO evidence.") + + ordered_steps = summary_payload.get("ordered_steps") + if not isinstance(ordered_steps, list) or not all(isinstance(step, str) for step in ordered_steps): + raise ValueError("archive summary ordered_steps must be list[str].") + + steps = summary_payload.get("steps") + if not isinstance(steps, list): + raise ValueError("archive summary steps must be a list.") + + step_status_by_id: dict[str, str] = {} + for step_payload in steps: + if not isinstance(step_payload, dict): + raise ValueError("archive summary steps[] entries must be JSON objects.") + step_id = step_payload.get("step") + status = step_payload.get("status") + if not isinstance(step_id, str) or not isinstance(status, str): + raise ValueError("archive summary steps[] must include string step and status fields.") + step_status_by_id[step_id] = status + + missing_steps = [step_id for step_id in ordered_steps if step_id not in step_status_by_id] + if missing_steps: + raise ValueError( + "archive summary ordered_steps references missing step payloads: " + + ", ".join(missing_steps) + ) + + non_pass_steps = [step_id for step_id in ordered_steps if step_status_by_id.get(step_id) != "PASS"] + if non_pass_steps: + raise ValueError( + "archive summary GO evidence must have PASS for all ordered steps. " + f"Non-PASS steps: {', '.join(non_pass_steps)}" + ) + + ordered_step_statuses = {step_id: step_status_by_id[step_id] for step_id in ordered_steps} + return ordered_steps, ordered_step_statuses + + +def generate_manifest( + *, + index_path: Path = DEFAULT_ARCHIVE_INDEX_PATH, + manifest_path: Path = DEFAULT_MANIFEST_PATH, +) -> dict[str, object]: + index_payload = _load_archive_index(index_path) + go_entry_index, go_entry = _find_latest_go_entry(index_payload) + + archive_artifact_path_value = go_entry.get("archive_artifact_path") + if not isinstance(archive_artifact_path_value, str): + raise ValueError("latest GO archive entry archive_artifact_path must be a string.") + + archive_artifact_path = _resolve_path(archive_artifact_path_value) + if not archive_artifact_path.exists(): + raise ValueError(f"latest GO archive artifact is missing: {archive_artifact_path_value}") + + go_entry_created_at = _validate_utc_timestamp( + go_entry.get("created_at"), + field_name="latest GO archive entry created_at", + ) + + go_entry_command_mode = go_entry.get("command_mode") + if not isinstance(go_entry_command_mode, str): + raise ValueError("latest GO archive entry command_mode must be a string.") + + archive_summary_payload = _load_json_object(archive_artifact_path, label="archive summary") + ordered_steps, step_status_by_id = _extract_go_summary_contract(archive_summary_payload) + + if go_entry.get("summary_exit_code") != 0: + raise ValueError("latest GO archive entry summary_exit_code must be 0.") + if go_entry.get("failing_steps") != []: + raise ValueError("latest GO archive entry failing_steps must be [].") + + manifest = { + "artifact_version": MANIFEST_ARTIFACT_VERSION, + "artifact_path": _render_path(manifest_path), + "phase": "phase4", + "release_gate": "mvp", + "decision": { + "final_decision": "GO", + "summary_exit_code": 0, + "failing_steps": [], + }, + "source_references": { + "archive_index_path": _render_path(index_path), + "archive_entry_index": go_entry_index, + "archive_entry_created_at": go_entry_created_at, + "archive_artifact_path": archive_artifact_path_value, + "archive_entry_command_mode": go_entry_command_mode, + }, + "ordered_steps": ordered_steps, + "step_status_by_id": step_status_by_id, + "compatibility_validation_commands": list(REQUIRED_COMPATIBILITY_COMMANDS), + "integrity": { + "archive_artifact_sha256": _sha256_for_file(archive_artifact_path), + }, + } + + _atomic_write_json(path=manifest_path, payload=manifest) + return manifest + + +def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Generate deterministic Phase 4 MVP exit manifest from the latest GO release-candidate " + "archive evidence entry." + ), + ) + parser.add_argument( + "--index-path", + default=str(DEFAULT_ARCHIVE_INDEX_PATH), + help="Path to Phase 4 RC archive index JSON artifact.", + ) + parser.add_argument( + "--manifest-path", + default=str(DEFAULT_MANIFEST_PATH), + help="Output path for generated MVP exit manifest.", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = _parse_args(argv) + index_path = Path(args.index_path) + manifest_path = Path(args.manifest_path) + + try: + manifest = generate_manifest(index_path=index_path, manifest_path=manifest_path) + except Exception as exc: + print("Phase 4 MVP exit manifest generation: FAIL") + print(f" - {exc}") + return 1 + + print("Phase 4 MVP exit manifest generation: PASS") + print(f"Manifest artifact: {manifest['artifact_path']}") + source_refs = manifest["source_references"] + assert isinstance(source_refs, dict) + print(f"Source archive artifact: {source_refs['archive_artifact_path']}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/load_chatgpt_sample_data.py b/scripts/load_chatgpt_sample_data.py new file mode 100755 index 0000000..ce5ef31 --- /dev/null +++ b/scripts/load_chatgpt_sample_data.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import sys +from uuid import UUID + + +REPO_ROOT = Path(__file__).resolve().parents[1] +API_SRC = REPO_ROOT / "apps" / "api" / "src" +if str(API_SRC) not in sys.path: + sys.path.insert(0, str(API_SRC)) + +from alicebot_api.chatgpt_import import import_chatgpt_source +from alicebot_api.db import user_connection +from alicebot_api.store import ContinuityStore + + +DEFAULT_DATABASE_URL = "postgresql://alicebot_app:alicebot_app@localhost:5432/alicebot" +DEFAULT_AUTH_USER_ID = "00000000-0000-0000-0000-000000000001" +DEFAULT_SOURCE_PATH = REPO_ROOT / "fixtures" / "importers" / "chatgpt" / "workspace_v1.json" + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Import ChatGPT sample export data into Alice continuity objects." + ) + parser.add_argument( + "--source", + default=os.getenv("CHATGPT_SAMPLE_DATA_PATH", str(DEFAULT_SOURCE_PATH)), + help="Path to a ChatGPT export JSON file or directory.", + ) + parser.add_argument( + "--database-url", + default=os.getenv("DATABASE_URL", DEFAULT_DATABASE_URL), + help="Database URL used for writes.", + ) + parser.add_argument( + "--user-id", + default=os.getenv("ALICEBOT_AUTH_USER_ID", DEFAULT_AUTH_USER_ID), + help="User ID to own imported ChatGPT data.", + ) + parser.add_argument( + "--user-email", + default=os.getenv("ALICEBOT_IMPORT_USER_EMAIL", "chatgpt-sample@example.com"), + help="Email for auto-created user when --user-id is not found.", + ) + parser.add_argument( + "--display-name", + default=os.getenv("ALICEBOT_IMPORT_USER_DISPLAY_NAME", "ChatGPT Sample User"), + help="Display name for auto-created user when --user-id is not found.", + ) + return parser.parse_args() + + +def _ensure_user(store: ContinuityStore, *, user_id: UUID, email: str, display_name: str) -> None: + with store.conn.cursor() as cur: + cur.execute("SELECT 1 FROM users WHERE id = %s", (user_id,)) + exists = cur.fetchone() is not None + if exists: + return + store.create_user(user_id, email, display_name) + + +def main() -> int: + args = _parse_args() + source_path = Path(args.source).expanduser().resolve() + user_id = UUID(str(args.user_id)) + + with user_connection(args.database_url, user_id) as conn: + store = ContinuityStore(conn) + _ensure_user( + store, + user_id=user_id, + email=str(args.user_email), + display_name=str(args.display_name), + ) + summary = import_chatgpt_source( + store, + user_id=user_id, + source=source_path, + ) + + print( + json.dumps( + { + **summary, + "user_id": str(user_id), + "source_path": str(source_path), + }, + indent=2, + sort_keys=True, + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/load_chatgpt_sample_data.sh b/scripts/load_chatgpt_sample_data.sh new file mode 100755 index 0000000..8022dfa --- /dev/null +++ b/scripts/load_chatgpt_sample_data.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" + +if [ -f "${REPO_ROOT}/.env" ]; then + set -a + . "${REPO_ROOT}/.env" + set +a +fi + +PYTHON_BIN="python3" +if [ -x "${REPO_ROOT}/.venv/bin/python" ]; then + PYTHON_BIN="${REPO_ROOT}/.venv/bin/python" +fi + +cd "${REPO_ROOT}" + +exec "${PYTHON_BIN}" "${REPO_ROOT}/scripts/load_chatgpt_sample_data.py" "$@" diff --git a/scripts/load_markdown_sample_data.py b/scripts/load_markdown_sample_data.py new file mode 100755 index 0000000..3a969df --- /dev/null +++ b/scripts/load_markdown_sample_data.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import sys +from uuid import UUID + + +REPO_ROOT = Path(__file__).resolve().parents[1] +API_SRC = REPO_ROOT / "apps" / "api" / "src" +if str(API_SRC) not in sys.path: + sys.path.insert(0, str(API_SRC)) + +from alicebot_api.db import user_connection +from alicebot_api.markdown_import import import_markdown_source +from alicebot_api.store import ContinuityStore + + +DEFAULT_DATABASE_URL = "postgresql://alicebot_app:alicebot_app@localhost:5432/alicebot" +DEFAULT_AUTH_USER_ID = "00000000-0000-0000-0000-000000000001" +DEFAULT_SOURCE_PATH = REPO_ROOT / "fixtures" / "importers" / "markdown" / "workspace_v1.md" + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Import markdown sample workspace data into Alice continuity objects." + ) + parser.add_argument( + "--source", + default=os.getenv("MARKDOWN_SAMPLE_DATA_PATH", str(DEFAULT_SOURCE_PATH)), + help="Path to a markdown file or directory.", + ) + parser.add_argument( + "--database-url", + default=os.getenv("DATABASE_URL", DEFAULT_DATABASE_URL), + help="Database URL used for writes.", + ) + parser.add_argument( + "--user-id", + default=os.getenv("ALICEBOT_AUTH_USER_ID", DEFAULT_AUTH_USER_ID), + help="User ID to own imported markdown data.", + ) + parser.add_argument( + "--user-email", + default=os.getenv("ALICEBOT_IMPORT_USER_EMAIL", "markdown-sample@example.com"), + help="Email for auto-created user when --user-id is not found.", + ) + parser.add_argument( + "--display-name", + default=os.getenv("ALICEBOT_IMPORT_USER_DISPLAY_NAME", "Markdown Sample User"), + help="Display name for auto-created user when --user-id is not found.", + ) + return parser.parse_args() + + +def _ensure_user(store: ContinuityStore, *, user_id: UUID, email: str, display_name: str) -> None: + with store.conn.cursor() as cur: + cur.execute("SELECT 1 FROM users WHERE id = %s", (user_id,)) + exists = cur.fetchone() is not None + if exists: + return + store.create_user(user_id, email, display_name) + + +def main() -> int: + args = _parse_args() + source_path = Path(args.source).expanduser().resolve() + user_id = UUID(str(args.user_id)) + + with user_connection(args.database_url, user_id) as conn: + store = ContinuityStore(conn) + _ensure_user( + store, + user_id=user_id, + email=str(args.user_email), + display_name=str(args.display_name), + ) + summary = import_markdown_source( + store, + user_id=user_id, + source=source_path, + ) + + print( + json.dumps( + { + **summary, + "user_id": str(user_id), + "source_path": str(source_path), + }, + indent=2, + sort_keys=True, + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/load_markdown_sample_data.sh b/scripts/load_markdown_sample_data.sh new file mode 100755 index 0000000..1b5ff79 --- /dev/null +++ b/scripts/load_markdown_sample_data.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" + +if [ -f "${REPO_ROOT}/.env" ]; then + set -a + . "${REPO_ROOT}/.env" + set +a +fi + +PYTHON_BIN="python3" +if [ -x "${REPO_ROOT}/.venv/bin/python" ]; then + PYTHON_BIN="${REPO_ROOT}/.venv/bin/python" +fi + +cd "${REPO_ROOT}" + +exec "${PYTHON_BIN}" "${REPO_ROOT}/scripts/load_markdown_sample_data.py" "$@" diff --git a/scripts/load_openclaw_sample_data.py b/scripts/load_openclaw_sample_data.py new file mode 100755 index 0000000..c977836 --- /dev/null +++ b/scripts/load_openclaw_sample_data.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import sys +from uuid import UUID + + +REPO_ROOT = Path(__file__).resolve().parents[1] +API_SRC = REPO_ROOT / "apps" / "api" / "src" +if str(API_SRC) not in sys.path: + sys.path.insert(0, str(API_SRC)) + +from alicebot_api.db import user_connection +from alicebot_api.openclaw_import import import_openclaw_source +from alicebot_api.store import ContinuityStore + + +DEFAULT_DATABASE_URL = "postgresql://alicebot_app:alicebot_app@localhost:5432/alicebot" +DEFAULT_AUTH_USER_ID = "00000000-0000-0000-0000-000000000001" +DEFAULT_SOURCE_PATH = REPO_ROOT / "fixtures" / "openclaw" / "workspace_v1.json" + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Import OpenClaw sample workspace data into Alice continuity objects." + ) + parser.add_argument( + "--source", + default=os.getenv("OPENCLAW_SAMPLE_DATA_PATH", str(DEFAULT_SOURCE_PATH)), + help="Path to an OpenClaw workspace/export file or directory.", + ) + parser.add_argument( + "--database-url", + default=os.getenv("DATABASE_URL", DEFAULT_DATABASE_URL), + help="Database URL used for writes.", + ) + parser.add_argument( + "--user-id", + default=os.getenv("ALICEBOT_AUTH_USER_ID", DEFAULT_AUTH_USER_ID), + help="User ID to own imported OpenClaw data.", + ) + parser.add_argument( + "--user-email", + default=os.getenv("ALICEBOT_IMPORT_USER_EMAIL", "openclaw-sample@example.com"), + help="Email for auto-created user when --user-id is not found.", + ) + parser.add_argument( + "--display-name", + default=os.getenv("ALICEBOT_IMPORT_USER_DISPLAY_NAME", "OpenClaw Sample User"), + help="Display name for auto-created user when --user-id is not found.", + ) + return parser.parse_args() + + +def _ensure_user(store: ContinuityStore, *, user_id: UUID, email: str, display_name: str) -> None: + with store.conn.cursor() as cur: + cur.execute("SELECT 1 FROM users WHERE id = %s", (user_id,)) + exists = cur.fetchone() is not None + if exists: + return + store.create_user(user_id, email, display_name) + + +def main() -> int: + args = _parse_args() + source_path = Path(args.source).expanduser().resolve() + user_id = UUID(str(args.user_id)) + + with user_connection(args.database_url, user_id) as conn: + store = ContinuityStore(conn) + _ensure_user( + store, + user_id=user_id, + email=str(args.user_email), + display_name=str(args.display_name), + ) + summary = import_openclaw_source( + store, + user_id=user_id, + source=source_path, + ) + + print( + json.dumps( + { + **summary, + "user_id": str(user_id), + "source_path": str(source_path), + }, + indent=2, + sort_keys=True, + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/load_openclaw_sample_data.sh b/scripts/load_openclaw_sample_data.sh new file mode 100755 index 0000000..02a9cf9 --- /dev/null +++ b/scripts/load_openclaw_sample_data.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" + +if [ -f "${REPO_ROOT}/.env" ]; then + set -a + . "${REPO_ROOT}/.env" + set +a +fi + +PYTHON_BIN="python3" +if [ -x "${REPO_ROOT}/.venv/bin/python" ]; then + PYTHON_BIN="${REPO_ROOT}/.venv/bin/python" +fi + +cd "${REPO_ROOT}" + +exec "${PYTHON_BIN}" "${REPO_ROOT}/scripts/load_openclaw_sample_data.py" "$@" diff --git a/scripts/load_public_sample_data.py b/scripts/load_public_sample_data.py new file mode 100755 index 0000000..b01e7fe --- /dev/null +++ b/scripts/load_public_sample_data.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import sys +from typing import Any +from uuid import UUID + + +REPO_ROOT = Path(__file__).resolve().parents[1] +API_SRC = REPO_ROOT / "apps" / "api" / "src" +if str(API_SRC) not in sys.path: + sys.path.insert(0, str(API_SRC)) + +from alicebot_api.db import user_connection +from alicebot_api.store import ContinuityStore + + +DEFAULT_DATABASE_URL = "postgresql://alicebot_app:alicebot_app@localhost:5432/alicebot" +DEFAULT_AUTH_USER_ID = "00000000-0000-0000-0000-000000000001" +DEFAULT_FIXTURE_PATH = REPO_ROOT / "fixtures" / "public_sample_data" / "continuity_v1.json" + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Load deterministic public-core sample continuity data." + ) + parser.add_argument( + "--fixture", + default=os.getenv("PUBLIC_SAMPLE_DATA_PATH", str(DEFAULT_FIXTURE_PATH)), + help="Path to a sample-data fixture JSON file.", + ) + parser.add_argument( + "--database-url", + default=os.getenv("DATABASE_URL", DEFAULT_DATABASE_URL), + help="Database URL used for writes.", + ) + parser.add_argument( + "--user-id", + default=os.getenv("ALICEBOT_AUTH_USER_ID", DEFAULT_AUTH_USER_ID), + help="User ID to own the sample data.", + ) + return parser.parse_args() + + +def _load_fixture(path: Path) -> dict[str, Any]: + if not path.exists(): + raise FileNotFoundError(f"fixture file not found: {path}") + + payload = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise ValueError("fixture root must be a JSON object") + if not isinstance(payload.get("fixture_id"), str) or payload["fixture_id"].strip() == "": + raise ValueError("fixture_id must be a non-empty string") + if not isinstance(payload.get("objects"), list) or len(payload["objects"]) == 0: + raise ValueError("objects must be a non-empty list") + return payload + + +def _ensure_user(store: ContinuityStore, *, user_id: UUID, email: str, display_name: str) -> None: + with store.conn.cursor() as cur: + cur.execute("SELECT 1 FROM users WHERE id = %s", (user_id,)) + exists = cur.fetchone() is not None + if exists: + return + store.create_user(user_id, email, display_name) + + +def _already_seeded(store: ContinuityStore, *, fixture_id: str) -> bool: + for row in store.list_continuity_recall_candidates(): + provenance = row["provenance"] + if isinstance(provenance, dict) and provenance.get("sample_fixture") == fixture_id: + return True + return False + + +def _seed_fixture( + store: ContinuityStore, + *, + fixture: dict[str, Any], +) -> int: + fixture_id = str(fixture["fixture_id"]) + created = 0 + + for item in fixture["objects"]: + if not isinstance(item, dict): + raise ValueError("each fixture object entry must be a JSON object") + + raw_content = str(item["raw_content"]) + explicit_signal = item.get("explicit_signal") + object_type = str(item["object_type"]) + status = str(item["status"]) + title = str(item["title"]) + body = item["body"] + confidence = float(item.get("confidence", 0.9)) + provenance = dict(item.get("provenance", {})) + + provenance["sample_fixture"] = fixture_id + provenance["source_kind"] = "public_sample_fixture" + + capture = store.create_continuity_capture_event( + raw_content=raw_content, + explicit_signal=explicit_signal, + admission_posture="DERIVED", + admission_reason=f"sample_fixture_{fixture_id}", + ) + + store.create_continuity_object( + capture_event_id=capture["id"], + object_type=object_type, + status=status, + title=title, + body=body, + provenance=provenance, + confidence=confidence, + ) + created += 1 + + return created + + +def main() -> int: + args = _parse_args() + fixture_path = Path(args.fixture) + fixture = _load_fixture(fixture_path) + fixture_id = str(fixture["fixture_id"]) + user_id = UUID(str(args.user_id)) + + fixture_user = fixture.get("user") if isinstance(fixture.get("user"), dict) else {} + email = str(fixture_user.get("email", "public-sample@example.com")) + display_name = str(fixture_user.get("display_name", "Public Sample User")) + + with user_connection(args.database_url, user_id) as conn: + store = ContinuityStore(conn) + _ensure_user(store, user_id=user_id, email=email, display_name=display_name) + + if _already_seeded(store, fixture_id=fixture_id): + print( + json.dumps( + { + "status": "noop", + "reason": "fixture_already_loaded", + "fixture_id": fixture_id, + "user_id": str(user_id), + }, + indent=2, + sort_keys=True, + ) + ) + return 0 + + created_count = _seed_fixture(store, fixture=fixture) + + print( + json.dumps( + { + "status": "ok", + "fixture_id": fixture_id, + "user_id": str(user_id), + "created_object_count": created_count, + "fixture_path": str(fixture_path), + }, + indent=2, + sort_keys=True, + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/load_sample_data.sh b/scripts/load_sample_data.sh new file mode 100755 index 0000000..bdec4b3 --- /dev/null +++ b/scripts/load_sample_data.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" + +if [ -f "${REPO_ROOT}/.env" ]; then + set -a + . "${REPO_ROOT}/.env" + set +a +fi + +PYTHON_BIN="python3" +if [ -x "${REPO_ROOT}/.venv/bin/python" ]; then + PYTHON_BIN="${REPO_ROOT}/.venv/bin/python" +fi + +cd "${REPO_ROOT}" + +exec "${PYTHON_BIN}" "${REPO_ROOT}/scripts/load_public_sample_data.py" "$@" diff --git a/scripts/migrate.sh b/scripts/migrate.sh new file mode 100755 index 0000000..ef2401b --- /dev/null +++ b/scripts/migrate.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" + +if [ -f "${REPO_ROOT}/.env" ]; then + set -a + . "${REPO_ROOT}/.env" + set +a +fi + +PYTHON_BIN="python3" +if [ -x "${REPO_ROOT}/.venv/bin/python" ]; then + PYTHON_BIN="${REPO_ROOT}/.venv/bin/python" +fi + +cd "${REPO_ROOT}" + +"${PYTHON_BIN}" -m alembic -c "${REPO_ROOT}/apps/api/alembic.ini" upgrade "${1:-head}" diff --git a/scripts/run_hermes_mcp_smoke.py b/scripts/run_hermes_mcp_smoke.py new file mode 100755 index 0000000..6715b36 --- /dev/null +++ b/scripts/run_hermes_mcp_smoke.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import sys +from uuid import UUID, uuid4 + +from alicebot_api.config import DEFAULT_DATABASE_URL +from alicebot_api.db import user_connection +from alicebot_api.store import ContinuityStore + + +THREAD_ID = UUID("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") +REQUIRED_HERMES_TOOL_NAMES = ( + "mcp_alice_core_alice_recall", + "mcp_alice_core_alice_resume", + "mcp_alice_core_alice_open_loops", +) + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="run_hermes_mcp_smoke.py", + description=( + "Verify Hermes MCP runtime can discover and call Alice MCP tools " + "(alice_recall, alice_resume, alice_open_loops)." + ), + ) + parser.add_argument( + "--database-url", + default=os.getenv("DATABASE_URL", DEFAULT_DATABASE_URL), + help="Database URL for seeding and runtime calls.", + ) + parser.add_argument( + "--python-command", + default=sys.executable, + help="Python executable Hermes should use for the Alice MCP server.", + ) + parser.add_argument( + "--repo-root", + default=str(Path(__file__).resolve().parents[1]), + help="Alice repository root used to compose PYTHONPATH for the MCP server.", + ) + return parser + + +def _dispatch_mcp_tool(registry, *, tool_name: str, arguments: dict[str, object]) -> dict[str, object]: + payload = json.loads(registry.dispatch(tool_name, arguments)) + if "error" in payload: + raise RuntimeError(f"{tool_name} returned error: {payload['error']}") + result = payload.get("result") + if not isinstance(result, dict): + raise RuntimeError(f"{tool_name} returned unexpected payload: {payload}") + return result + + +def main(argv: list[str] | None = None) -> int: + args = _build_parser().parse_args(argv) + + # Import Hermes MCP runtime lazily so the script can print a clear error + # when Hermes dependencies are not installed in this Python environment. + try: + from tools.mcp_tool import register_mcp_servers, shutdown_mcp_servers + from tools.registry import registry + except ModuleNotFoundError as exc: + print( + "error: Hermes runtime modules are unavailable. " + "Install hermes-agent and mcp in this Python environment.", + file=sys.stderr, + ) + print(f"detail: {exc}", file=sys.stderr) + return 1 + + user_id = uuid4() + email = f"hermes-smoke-{user_id}@example.com" + pythonpath = f"{args.repo_root}/apps/api/src:{args.repo_root}/workers" + + with user_connection(args.database_url, user_id) as conn: + store = ContinuityStore(conn) + store.create_user(user_id, email, "Hermes Smoke") + + decision_capture = store.create_continuity_capture_event( + raw_content="Decision: Keep Alice MCP local-first for Hermes verification.", + explicit_signal="decision", + admission_posture="DERIVED", + admission_reason="explicit_signal_decision", + ) + decision = store.create_continuity_object( + capture_event_id=decision_capture["id"], + object_type="Decision", + status="active", + title="Decision: Keep Alice MCP local-first for Hermes verification.", + body={"decision_text": "Keep Alice MCP local-first for Hermes verification."}, + provenance={"thread_id": str(THREAD_ID), "source_event_ids": ["hermes-smoke-1"]}, + confidence=0.95, + ) + + waiting_capture = store.create_continuity_capture_event( + raw_content="Waiting For: Hermes docs sign-off", + explicit_signal="waiting_for", + admission_posture="DERIVED", + admission_reason="explicit_signal_waiting_for", + ) + waiting_for = store.create_continuity_object( + capture_event_id=waiting_capture["id"], + object_type="WaitingFor", + status="active", + title="Waiting For: Hermes docs sign-off", + body={"waiting_for_text": "Hermes docs sign-off"}, + provenance={"thread_id": str(THREAD_ID), "source_event_ids": ["hermes-smoke-2"]}, + confidence=0.93, + ) + + server_config = { + "alice_core": { + "command": args.python_command, + "args": ["-m", "alicebot_api.mcp_server"], + "env": { + "DATABASE_URL": args.database_url, + "ALICEBOT_AUTH_USER_ID": str(user_id), + "PYTHONPATH": pythonpath, + }, + "tools": { + "include": ["alice_recall", "alice_resume", "alice_open_loops"], + "resources": False, + "prompts": False, + }, + } + } + + try: + registered_tools = set(register_mcp_servers(server_config)) + required_tools = set(REQUIRED_HERMES_TOOL_NAMES) + if not required_tools.issubset(registered_tools): + missing = sorted(required_tools - registered_tools) + raise RuntimeError(f"Hermes did not register expected tools: {missing}") + + recall = _dispatch_mcp_tool( + registry, + tool_name="mcp_alice_core_alice_recall", + arguments={"thread_id": str(THREAD_ID), "query": "Hermes", "limit": 5}, + ) + resume = _dispatch_mcp_tool( + registry, + tool_name="mcp_alice_core_alice_resume", + arguments={"thread_id": str(THREAD_ID), "max_recent_changes": 5, "max_open_loops": 5}, + ) + open_loops = _dispatch_mcp_tool( + registry, + tool_name="mcp_alice_core_alice_open_loops", + arguments={"thread_id": str(THREAD_ID), "limit": 5}, + ) + + if recall["summary"]["returned_count"] < 1: + raise RuntimeError("Recall returned no continuity items.") + if resume["brief"]["last_decision"]["item"]["id"] != str(decision["id"]): + raise RuntimeError("Resume did not surface the seeded decision.") + if open_loops["dashboard"]["waiting_for"]["items"][0]["id"] != str(waiting_for["id"]): + raise RuntimeError("Open loops did not surface the seeded waiting-for item.") + + summary = { + "registered_tools": sorted(required_tools), + "recall_items": recall["summary"]["returned_count"], + "resume_last_decision_title": resume["brief"]["last_decision"]["item"]["title"], + "open_loop_count": open_loops["dashboard"]["summary"]["total_count"], + } + print(json.dumps(summary, separators=(",", ":"), sort_keys=True)) + finally: + shutdown_mcp_servers() + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_mvp_acceptance.py b/scripts/run_mvp_acceptance.py new file mode 100644 index 0000000..fc9c0fe --- /dev/null +++ b/scripts/run_mvp_acceptance.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +from pathlib import Path +import shlex +import subprocess +import sys + + +ROOT_DIR = Path(__file__).resolve().parents[1] +TARGET_SCRIPT = ROOT_DIR / "scripts" / "run_phase2_acceptance.py" + + +def _resolve_python_executable() -> str: + venv_python = ROOT_DIR / ".venv" / "bin" / "python" + if venv_python.exists(): + return str(venv_python) + return sys.executable + + +def main() -> int: + command = [_resolve_python_executable(), str(TARGET_SCRIPT), *sys.argv[1:]] + print("MVP acceptance compatibility alias -> scripts/run_phase2_acceptance.py", flush=True) + print(shlex.join(command), flush=True) + completed = subprocess.run( + command, + cwd=ROOT_DIR, + check=False, + ) + return completed.returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_mvp_readiness_gates.py b/scripts/run_mvp_readiness_gates.py new file mode 100644 index 0000000..db18e91 --- /dev/null +++ b/scripts/run_mvp_readiness_gates.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +from pathlib import Path +import shlex +import subprocess +import sys + + +ROOT_DIR = Path(__file__).resolve().parents[1] +TARGET_SCRIPT = ROOT_DIR / "scripts" / "run_phase2_readiness_gates.py" + + +def _resolve_python_executable() -> str: + venv_python = ROOT_DIR / ".venv" / "bin" / "python" + if venv_python.exists(): + return str(venv_python) + return sys.executable + + +def main() -> int: + command = [_resolve_python_executable(), str(TARGET_SCRIPT), *sys.argv[1:]] + print("MVP readiness compatibility alias -> scripts/run_phase2_readiness_gates.py", flush=True) + print(shlex.join(command), flush=True) + completed = subprocess.run( + command, + cwd=ROOT_DIR, + check=False, + ) + return completed.returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_mvp_validation_matrix.py b/scripts/run_mvp_validation_matrix.py new file mode 100644 index 0000000..6309032 --- /dev/null +++ b/scripts/run_mvp_validation_matrix.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +from pathlib import Path +import shlex +import subprocess +import sys + + +ROOT_DIR = Path(__file__).resolve().parents[1] +TARGET_SCRIPT = ROOT_DIR / "scripts" / "run_phase2_validation_matrix.py" + + +def _resolve_python_executable() -> str: + venv_python = ROOT_DIR / ".venv" / "bin" / "python" + if venv_python.exists(): + return str(venv_python) + return sys.executable + + +def main() -> int: + command = [_resolve_python_executable(), str(TARGET_SCRIPT), *sys.argv[1:]] + print("MVP validation matrix compatibility alias -> scripts/run_phase2_validation_matrix.py", flush=True) + print(shlex.join(command), flush=True) + completed = subprocess.run( + command, + cwd=ROOT_DIR, + check=False, + ) + return completed.returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_phase2_acceptance.py b/scripts/run_phase2_acceptance.py new file mode 100644 index 0000000..21693c5 --- /dev/null +++ b/scripts/run_phase2_acceptance.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import os +from pathlib import Path +import shlex +import subprocess +import sys + + +ROOT_DIR = Path(__file__).resolve().parents[1] +INDUCED_FAILURE_ENV = "MVP_ACCEPTANCE_INDUCED_FAILURE_SCENARIO" +ACCEPTANCE_SCENARIOS = ( + "response_memory", + "capture_resumption", + "approval_execution", + "magnesium_reorder", +) +ACCEPTANCE_TEST_NODE_IDS = ( + "tests/integration/test_mvp_acceptance_suite.py::test_acceptance_response_path_uses_admitted_memory_and_preference_correction", + "tests/integration/test_mvp_acceptance_suite.py::test_acceptance_explicit_signal_capture_flows_into_resumption_brief", + "tests/integration/test_mvp_acceptance_suite.py::test_acceptance_approval_lifecycle_resolution_execution_and_trace_availability", + "tests/integration/test_mvp_acceptance_suite.py::test_acceptance_canonical_magnesium_reorder_flow_with_memory_write_back_evidence", +) + + +def _resolve_python_executable() -> str: + venv_python = ROOT_DIR / ".venv" / "bin" / "python" + if venv_python.exists(): + return str(venv_python) + return sys.executable + + +def _build_pytest_command(python_executable: str) -> list[str]: + return [python_executable, "-m", "pytest", "-q", *ACCEPTANCE_TEST_NODE_IDS] + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Run the bounded Phase 2 acceptance evidence suite.", + ) + parser.add_argument( + "--induce-failure", + choices=ACCEPTANCE_SCENARIOS, + default=None, + help="Intentionally fail one acceptance scenario to validate deterministic failure signaling.", + ) + return parser.parse_args() + + +def main() -> int: + args = _parse_args() + python_executable = _resolve_python_executable() + command = _build_pytest_command(python_executable) + + env = os.environ.copy() + if args.induce_failure is None: + env.pop(INDUCED_FAILURE_ENV, None) + else: + env[INDUCED_FAILURE_ENV] = args.induce_failure + + print("Phase 2 acceptance test subset:") + for node_id in ACCEPTANCE_TEST_NODE_IDS: + print(f" - {node_id}") + if args.induce_failure is not None: + print( + "Induced failure enabled: " + f"{INDUCED_FAILURE_ENV}={args.induce_failure}" + ) + print("Running command:") + print(shlex.join(command)) + + completed = subprocess.run( + command, + cwd=ROOT_DIR, + env=env, + check=False, + ) + + if completed.returncode == 0: + print("Phase 2 acceptance suite result: PASS") + else: + print(f"Phase 2 acceptance suite result: FAIL (exit code {completed.returncode})") + return completed.returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_phase2_readiness_gates.py b/scripts/run_phase2_readiness_gates.py new file mode 100644 index 0000000..b730808 --- /dev/null +++ b/scripts/run_phase2_readiness_gates.py @@ -0,0 +1,794 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +from collections.abc import Iterator +import contextlib +from dataclasses import dataclass +import json +import math +import os +from pathlib import Path +import shlex +import subprocess +import sys +import time +from typing import Any, Callable, Literal +from urllib.parse import urlencode, urlsplit, urlunsplit +from uuid import UUID, uuid4 + +ROOT_DIR = Path(__file__).resolve().parents[1] +VENV_PYTHON = ROOT_DIR / ".venv" / "bin" / "python" + + +def _maybe_reexec_into_venv() -> None: + if not VENV_PYTHON.exists(): + return + venv_root = ROOT_DIR / ".venv" + try: + active_prefix = Path(sys.prefix).resolve() + expected_prefix = venv_root.resolve() + except OSError: + active_prefix = Path(sys.prefix) + expected_prefix = venv_root + if active_prefix == expected_prefix: + return + os.execv(str(VENV_PYTHON), [str(VENV_PYTHON), str(Path(__file__).resolve()), *sys.argv[1:]]) + + +_maybe_reexec_into_venv() + +from alembic import command +import anyio +import psycopg +from psycopg import sql + +API_SRC_DIR = ROOT_DIR / "apps" / "api" / "src" +if str(ROOT_DIR) not in sys.path: + sys.path.insert(0, str(ROOT_DIR)) +if str(API_SRC_DIR) not in sys.path: + sys.path.insert(0, str(API_SRC_DIR)) + +import apps.api.src.alicebot_api.main as main_module +import alicebot_api.response_generation as response_generation_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.contracts import ModelInvocationResponse, ModelUsagePayload +from alicebot_api.db import user_connection +from alicebot_api.migrations import make_alembic_config +from alicebot_api.store import ContinuityStore + +DEFAULT_ADMIN_URL = "postgresql://alicebot_admin:alicebot_admin@localhost:5432/alicebot" +DEFAULT_APP_URL = "postgresql://alicebot_app:alicebot_app@localhost:5432/alicebot" + +ACCEPTANCE_GATE_NAME = "acceptance_suite" +LATENCY_GATE_NAME = "latency_p95" +CACHE_GATE_NAME = "cache_reuse" +MEMORY_GATE_NAME = "memory_quality" + +LATENCY_P95_THRESHOLD_SECONDS = 5.0 +CACHE_REUSE_THRESHOLD = 0.70 +MEMORY_PRECISION_THRESHOLD = 0.80 +MEMORY_MIN_ADJUDICATED_SAMPLE = 20 +PROBE_CALL_COUNT = 8 + +GateStatus = Literal["PASS", "FAIL", "BLOCKED"] +InducedScenario = Literal[ + "acceptance_fail", + "latency_fail", + "cache_fail", + "cache_blocked", + "memory_needs_review", + "memory_insufficient", +] +CacheTelemetryMode = Literal["present", "low_reuse", "missing"] +MemoryProfile = Literal["on_track", "needs_review", "insufficient_evidence"] +MemoryReviewAdjudicationLabel = Literal["correct", "incorrect"] +INDUCED_SCENARIOS: tuple[InducedScenario, ...] = ( + "acceptance_fail", + "latency_fail", + "cache_fail", + "cache_blocked", + "memory_needs_review", + "memory_insufficient", +) + + +@dataclass(frozen=True, slots=True) +class GateResult: + gate: str + status: GateStatus + measured: str + threshold: str + detail: str + + +@dataclass(frozen=True, slots=True) +class ProbeRun: + durations_seconds: list[float] + usages: list[ModelUsagePayload] + + +@dataclass(frozen=True, slots=True) +class MemoryCaptureAdjudication: + memory_id: UUID + label: MemoryReviewAdjudicationLabel + note: str + + +@contextlib.contextmanager +def _temporary_database_urls() -> Iterator[dict[str, str]]: + admin_root_url = os.getenv("DATABASE_ADMIN_URL", DEFAULT_ADMIN_URL) + app_root_url = os.getenv("DATABASE_URL", DEFAULT_APP_URL) + database_name = f"alicebot_readiness_{uuid4().hex[:12]}" + admin_database_url = _swap_database_name(admin_root_url, database_name) + app_database_url = _swap_database_name(app_root_url, database_name) + + with psycopg.connect(admin_root_url, autocommit=True) as conn: + with conn.cursor() as cur: + cur.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(database_name))) + cur.execute( + sql.SQL("GRANT CONNECT, TEMPORARY ON DATABASE {} TO alicebot_app").format( + sql.Identifier(database_name) + ) + ) + + try: + yield {"admin": admin_database_url, "app": app_database_url} + finally: + with psycopg.connect(admin_root_url, autocommit=True) as conn: + with conn.cursor() as cur: + cur.execute( + sql.SQL("DROP DATABASE IF EXISTS {} WITH (FORCE)").format( + sql.Identifier(database_name) + ) + ) + + +@contextlib.contextmanager +def _patched_api_runtime( + *, + settings: Settings, + invoke_model: Callable[..., ModelInvocationResponse], +) -> Iterator[None]: + original_get_settings = main_module.get_settings + original_invoke_model = response_generation_module.invoke_model + + main_module.get_settings = lambda: settings # type: ignore[assignment] + response_generation_module.invoke_model = invoke_model # type: ignore[assignment] + try: + yield + finally: + main_module.get_settings = original_get_settings # type: ignore[assignment] + response_generation_module.invoke_model = original_invoke_model # type: ignore[assignment] + + +def _swap_database_name(database_url: str, database_name: str) -> str: + parsed = urlsplit(database_url) + return urlunsplit((parsed.scheme, parsed.netloc, f"/{database_name}", parsed.query, parsed.fragment)) + + +def _resolve_python_executable() -> str: + venv_python = ROOT_DIR / ".venv" / "bin" / "python" + if venv_python.exists(): + return str(venv_python) + return sys.executable + + +def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Run deterministic Phase 2 readiness gates for quantitative evidence.", + ) + parser.add_argument( + "--induce-gate", + choices=INDUCED_SCENARIOS, + default=None, + help="Intentionally induce one gate outcome to validate deterministic failure/blocked behavior.", + ) + return parser.parse_args(argv) + + +def _seed_probe_state(database_url: str) -> dict[str, UUID]: + user_id = uuid4() + + with user_connection(database_url, user_id) as conn: + store = ContinuityStore(conn) + store.create_user(user_id, "readiness@example.com", "Readiness Runner") + thread = store.create_thread("Phase 2 readiness probe") + session = store.create_session(thread["id"], status="active") + source_event = store.append_event( + thread["id"], + session["id"], + "message.user", + {"text": "Probe baseline context."}, + ) + + return { + "user_id": user_id, + "thread_id": thread["id"], + "session_id": session["id"], + "source_event_id": source_event["id"], + } + + +def _build_memory_capture_messages(*, profile: MemoryProfile) -> list[str]: + if profile == "on_track": + unique_capture_count = 20 + duplicate_capture_count = 0 + elif profile == "needs_review": + unique_capture_count = 16 + duplicate_capture_count = 4 + else: + unique_capture_count = 9 + duplicate_capture_count = 1 + + unique_messages = [f"I like readiness-topic-{index:02d}" for index in range(1, unique_capture_count + 1)] + return [*unique_messages, *unique_messages[:duplicate_capture_count]] + + +def _append_user_message_event( + *, + database_url: str, + user_id: UUID, + thread_id: UUID, + session_id: UUID, + message_text: str, +) -> UUID: + with user_connection(database_url, user_id) as conn: + store = ContinuityStore(conn) + event = store.append_event( + thread_id, + session_id, + "message.user", + {"text": message_text}, + ) + return event["id"] + + +def _capture_explicit_signals( + *, + settings: Settings, + user_id: UUID, + source_event_id: UUID, +) -> dict[str, Any]: + def unused_invoke_model(*, settings: Settings, request: Any) -> ModelInvocationResponse: + del settings + del request + raise AssertionError("invoke_model should not be called for explicit signal capture") + + with _patched_api_runtime(settings=settings, invoke_model=unused_invoke_model): + status_code, payload = _invoke_request( + "POST", + "/v0/memories/capture-explicit-signals", + payload={ + "user_id": str(user_id), + "source_event_id": str(source_event_id), + }, + ) + if status_code != 200: + raise RuntimeError( + "explicit signal capture request failed: " + f"status={status_code} payload={json.dumps(payload, sort_keys=True)}" + ) + if not isinstance(payload, dict): + raise RuntimeError("explicit signal capture response was not an object") + return payload + + +def _extract_capture_admissions(capture_payload: dict[str, Any]) -> list[dict[str, Any]]: + admissions: list[dict[str, Any]] = [] + for section_name in ("preferences", "commitments"): + section = capture_payload.get(section_name) + if not isinstance(section, dict): + raise RuntimeError(f"explicit signal capture payload was missing '{section_name}' section") + section_admissions = section.get("admissions") + if not isinstance(section_admissions, list): + raise RuntimeError( + f"explicit signal capture payload had invalid '{section_name}.admissions' value" + ) + for admission in section_admissions: + if not isinstance(admission, dict): + raise RuntimeError( + f"explicit signal capture payload had non-object admission in '{section_name}'" + ) + admissions.append(admission) + return admissions + + +def _adjudicate_capture_admissions( + admissions: list[dict[str, Any]], +) -> list[MemoryCaptureAdjudication]: + adjudications: list[MemoryCaptureAdjudication] = [] + for admission in admissions: + decision = admission.get("decision") + memory = admission.get("memory") + if not isinstance(decision, str): + raise RuntimeError("explicit signal capture admission was missing a string decision") + if not isinstance(memory, dict): + raise RuntimeError("explicit signal capture admission was missing a memory payload") + memory_id = memory.get("id") + if not isinstance(memory_id, str): + raise RuntimeError("explicit signal capture admission memory payload was missing id") + + label: MemoryReviewAdjudicationLabel = ( + "correct" if decision in ("ADD", "UPDATE") else "incorrect" + ) + adjudications.append( + MemoryCaptureAdjudication( + memory_id=UUID(memory_id), + label=label, + note=f"Readiness capture adjudication: decision={decision}", + ) + ) + return adjudications + + +def _persist_memory_adjudications( + *, + database_url: str, + user_id: UUID, + adjudications: list[MemoryCaptureAdjudication], +) -> None: + with user_connection(database_url, user_id) as conn: + store = ContinuityStore(conn) + for adjudication in adjudications: + store.create_memory_review_label( + memory_id=adjudication.memory_id, + label=adjudication.label, + note=adjudication.note, + ) + + +def _capture_and_adjudicate_memory_quality_sample( + *, + database_url: str, + settings: Settings, + user_id: UUID, + thread_id: UUID, + session_id: UUID, + profile: MemoryProfile, +) -> None: + capture_messages = _build_memory_capture_messages(profile=profile) + all_adjudications: list[MemoryCaptureAdjudication] = [] + for message_text in capture_messages: + source_event_id = _append_user_message_event( + database_url=database_url, + user_id=user_id, + thread_id=thread_id, + session_id=session_id, + message_text=message_text, + ) + capture_payload = _capture_explicit_signals( + settings=settings, + user_id=user_id, + source_event_id=source_event_id, + ) + admissions = _extract_capture_admissions(capture_payload) + if not admissions: + raise RuntimeError("explicit signal capture produced no admissions for adjudication") + all_adjudications.extend(_adjudicate_capture_admissions(admissions)) + + if not all_adjudications: + raise RuntimeError("no capture-derived adjudications were produced") + _persist_memory_adjudications( + database_url=database_url, + user_id=user_id, + adjudications=all_adjudications, + ) + + +def _invoke_request( + method: str, + path: str, + *, + query_params: dict[str, str] | None = None, + payload: dict[str, Any] | None = None, +) -> tuple[int, dict[str, Any]]: + messages: list[dict[str, object]] = [] + encoded_body = b"" if payload is None else json.dumps(payload).encode() + request_received = False + + async def receive() -> dict[str, object]: + nonlocal request_received + if request_received: + return {"type": "http.disconnect"} + + request_received = True + return {"type": "http.request", "body": encoded_body, "more_body": False} + + async def send(message: dict[str, object]) -> None: + messages.append(message) + + query_string = urlencode(query_params or {}).encode() + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": query_string, + "headers": [(b"content-type", b"application/json")], + "client": ("testclient", 50000), + "server": ("testserver", 80), + "root_path": "", + } + + anyio.run(main_module.app, scope, receive, send) + + start_message = next(message for message in messages if message["type"] == "http.response.start") + body = b"".join( + message.get("body", b"") for message in messages if message["type"] == "http.response.body" + ) + parsed_body = {} if not body else json.loads(body) + return int(start_message["status"]), parsed_body + + +def calculate_p95_seconds(durations_seconds: list[float]) -> float: + if not durations_seconds: + raise ValueError("at least one probe duration is required") + + sorted_durations = sorted(durations_seconds) + rank = math.ceil(0.95 * len(sorted_durations)) + return sorted_durations[max(rank - 1, 0)] + + +def _evaluate_latency_gate(durations_seconds: list[float]) -> GateResult: + p95_seconds = calculate_p95_seconds(durations_seconds) + status: GateStatus = "PASS" if p95_seconds < LATENCY_P95_THRESHOLD_SECONDS else "FAIL" + return GateResult( + gate=LATENCY_GATE_NAME, + status=status, + measured=( + f"p95_seconds={p95_seconds:.6f}; " + f"samples={','.join(f'{value:.6f}' for value in durations_seconds)}" + ), + threshold=f"p95_seconds < {LATENCY_P95_THRESHOLD_SECONDS:.1f}", + detail=f"probe_count={len(durations_seconds)}", + ) + + +def calculate_cache_reuse_ratio(usages: list[ModelUsagePayload]) -> float | None: + if not usages: + return None + + total_input_tokens = 0 + total_cached_tokens = 0 + for usage in usages: + input_tokens = usage.get("input_tokens") + cached_input_tokens = usage.get("cached_input_tokens") + if not isinstance(input_tokens, int) or input_tokens <= 0: + return None + if not isinstance(cached_input_tokens, int) or cached_input_tokens < 0: + return None + total_input_tokens += input_tokens + total_cached_tokens += min(cached_input_tokens, input_tokens) + + if total_input_tokens <= 0: + return None + return total_cached_tokens / total_input_tokens + + +def _evaluate_cache_reuse_gate(usages: list[ModelUsagePayload]) -> GateResult: + ratio = calculate_cache_reuse_ratio(usages) + if ratio is None: + return GateResult( + gate=CACHE_GATE_NAME, + status="BLOCKED", + measured="cache_reuse_ratio=unavailable", + threshold=f"cache_reuse_ratio >= {CACHE_REUSE_THRESHOLD:.2f}", + detail="cached token telemetry was not available for all probe responses", + ) + + status: GateStatus = "PASS" if ratio >= CACHE_REUSE_THRESHOLD else "FAIL" + return GateResult( + gate=CACHE_GATE_NAME, + status=status, + measured=f"cache_reuse_ratio={ratio:.6f}", + threshold=f"cache_reuse_ratio >= {CACHE_REUSE_THRESHOLD:.2f}", + detail=f"probe_count={len(usages)}", + ) + + +def _evaluate_memory_quality_gate(summary: dict[str, Any]) -> GateResult: + counts = summary.get("label_row_counts_by_value") + if not isinstance(counts, dict): + return GateResult( + gate=MEMORY_GATE_NAME, + status="BLOCKED", + measured="precision=unavailable; adjudicated_sample=unavailable", + threshold=( + f"precision > {MEMORY_PRECISION_THRESHOLD:.2f} and " + f"adjudicated_sample >= {MEMORY_MIN_ADJUDICATED_SAMPLE}" + ), + detail="evaluation summary payload was missing label counts", + ) + + correct = counts.get("correct") + incorrect = counts.get("incorrect") + unlabeled = summary.get("unlabeled_memory_count") + if not isinstance(correct, int) or not isinstance(incorrect, int) or not isinstance(unlabeled, int): + return GateResult( + gate=MEMORY_GATE_NAME, + status="BLOCKED", + measured="precision=unavailable; adjudicated_sample=unavailable", + threshold=( + f"precision > {MEMORY_PRECISION_THRESHOLD:.2f} and " + f"adjudicated_sample >= {MEMORY_MIN_ADJUDICATED_SAMPLE}" + ), + detail="evaluation summary payload had invalid value types", + ) + + adjudicated_sample = correct + incorrect + precision = None if adjudicated_sample == 0 else correct / adjudicated_sample + + if adjudicated_sample < MEMORY_MIN_ADJUDICATED_SAMPLE: + status: GateStatus = "BLOCKED" + posture = "insufficient_evidence" + elif precision is not None and precision > MEMORY_PRECISION_THRESHOLD: + status = "PASS" + posture = "on_track" + else: + status = "FAIL" + posture = "needs_review" + + precision_text = "undefined" if precision is None else f"{precision:.6f}" + return GateResult( + gate=MEMORY_GATE_NAME, + status=status, + measured=( + f"precision={precision_text}; adjudicated_sample={adjudicated_sample}; " + f"unlabeled_memory_count={unlabeled}; posture={posture}" + ), + threshold=( + f"precision > {MEMORY_PRECISION_THRESHOLD:.2f} and " + f"adjudicated_sample >= {MEMORY_MIN_ADJUDICATED_SAMPLE}" + ), + detail=f"correct={correct}; incorrect={incorrect}", + ) + + +def _build_probe_invoke_model( + *, + cache_mode: CacheTelemetryMode, + captured_usages: list[ModelUsagePayload], +) -> Callable[..., ModelInvocationResponse]: + def fake_invoke_model(*, settings: Settings, request: Any) -> ModelInvocationResponse: + del settings + del request + usage: ModelUsagePayload + if cache_mode == "present": + usage = { + "input_tokens": 100, + "output_tokens": 20, + "total_tokens": 120, + "cached_input_tokens": 80, + } + elif cache_mode == "low_reuse": + usage = { + "input_tokens": 100, + "output_tokens": 20, + "total_tokens": 120, + "cached_input_tokens": 50, + } + else: + usage = { + "input_tokens": 100, + "output_tokens": 20, + "total_tokens": 120, + } + + captured_usages.append(usage) + return ModelInvocationResponse( + provider="openai_responses", + model="gpt-5-mini", + response_id="resp_readiness_probe", + finish_reason="completed", + output_text="Readiness probe reply.", + usage=usage, + ) + + return fake_invoke_model + + +def _run_response_probes( + *, + settings: Settings, + user_id: UUID, + thread_id: UUID, + cache_mode: CacheTelemetryMode, + forced_duration_seconds: float | None, +) -> ProbeRun: + durations_seconds: list[float] = [] + captured_usages: list[ModelUsagePayload] = [] + invoke_model = _build_probe_invoke_model(cache_mode=cache_mode, captured_usages=captured_usages) + + with _patched_api_runtime(settings=settings, invoke_model=invoke_model): + for index in range(PROBE_CALL_COUNT): + started = time.perf_counter() + status_code, payload = _invoke_request( + "POST", + "/v0/responses", + payload={ + "user_id": str(user_id), + "thread_id": str(thread_id), + "message": f"Readiness probe call {index + 1}", + }, + ) + elapsed = time.perf_counter() - started + duration = elapsed if forced_duration_seconds is None else forced_duration_seconds + durations_seconds.append(duration) + if status_code != 200: + raise RuntimeError( + "response probe call failed: " + f"status={status_code} payload={json.dumps(payload, sort_keys=True)}" + ) + + return ProbeRun(durations_seconds=durations_seconds, usages=captured_usages) + + +def _fetch_memory_summary(*, settings: Settings, user_id: UUID) -> dict[str, Any]: + def unused_invoke_model(*, settings: Settings, request: Any) -> ModelInvocationResponse: + del settings + del request + raise AssertionError("invoke_model should not be called for memory summary gate") + + with _patched_api_runtime(settings=settings, invoke_model=unused_invoke_model): + status_code, payload = _invoke_request( + "GET", + "/v0/memories/evaluation-summary", + query_params={"user_id": str(user_id)}, + ) + if status_code != 200: + raise RuntimeError( + "memory summary request failed: " + f"status={status_code} payload={json.dumps(payload, sort_keys=True)}" + ) + + summary = payload.get("summary") + if not isinstance(summary, dict): + raise RuntimeError("memory summary response did not include a summary object") + return summary + + +def _run_acceptance_suite_gate(*, induce_failure: bool) -> GateResult: + python_executable = _resolve_python_executable() + command_parts = [python_executable, "scripts/run_phase2_acceptance.py"] + if induce_failure: + command_parts.extend(["--induce-failure", "response_memory"]) + + completed = subprocess.run( + command_parts, + cwd=ROOT_DIR, + check=False, + ) + status: GateStatus = "PASS" if completed.returncode == 0 else "FAIL" + return GateResult( + gate=ACCEPTANCE_GATE_NAME, + status=status, + measured=f"exit_code={completed.returncode}", + threshold="exit_code == 0", + detail=f"command={shlex.join(command_parts)}", + ) + + +def run_readiness_gates(*, induce_gate: InducedScenario | None = None) -> list[GateResult]: + gate_results: list[GateResult] = [] + + acceptance_gate = _run_acceptance_suite_gate(induce_failure=induce_gate == "acceptance_fail") + gate_results.append(acceptance_gate) + + cache_mode: CacheTelemetryMode = "present" + if induce_gate == "cache_blocked": + cache_mode = "missing" + elif induce_gate == "cache_fail": + cache_mode = "low_reuse" + + memory_profile: MemoryProfile = "on_track" + if induce_gate == "memory_needs_review": + memory_profile = "needs_review" + elif induce_gate == "memory_insufficient": + memory_profile = "insufficient_evidence" + + forced_duration_seconds = 5.2 if induce_gate == "latency_fail" else None + + try: + with _temporary_database_urls() as database_urls: + config = make_alembic_config(database_urls["admin"]) + command.upgrade(config, "head") + + seeded = _seed_probe_state(database_urls["app"]) + settings = Settings( + database_url=database_urls["app"], + model_provider="openai_responses", + model_name="gpt-5-mini", + model_api_key="test-key", + ) + _capture_and_adjudicate_memory_quality_sample( + database_url=database_urls["app"], + settings=settings, + user_id=seeded["user_id"], + thread_id=seeded["thread_id"], + session_id=seeded["session_id"], + profile=memory_profile, + ) + probe_run = _run_response_probes( + settings=settings, + user_id=seeded["user_id"], + thread_id=seeded["thread_id"], + cache_mode=cache_mode, + forced_duration_seconds=forced_duration_seconds, + ) + gate_results.append(_evaluate_latency_gate(probe_run.durations_seconds)) + gate_results.append(_evaluate_cache_reuse_gate(probe_run.usages)) + + summary = _fetch_memory_summary(settings=settings, user_id=seeded["user_id"]) + gate_results.append(_evaluate_memory_quality_gate(summary)) + except Exception as exc: + blocked_detail = str(exc) + existing_gates = {result.gate for result in gate_results} + if LATENCY_GATE_NAME not in existing_gates: + gate_results.append( + GateResult( + gate=LATENCY_GATE_NAME, + status="BLOCKED", + measured="p95_seconds=unavailable", + threshold=f"p95_seconds < {LATENCY_P95_THRESHOLD_SECONDS:.1f}", + detail=blocked_detail, + ) + ) + if CACHE_GATE_NAME not in existing_gates: + gate_results.append( + GateResult( + gate=CACHE_GATE_NAME, + status="BLOCKED", + measured="cache_reuse_ratio=unavailable", + threshold=f"cache_reuse_ratio >= {CACHE_REUSE_THRESHOLD:.2f}", + detail=blocked_detail, + ) + ) + if MEMORY_GATE_NAME not in existing_gates: + gate_results.append( + GateResult( + gate=MEMORY_GATE_NAME, + status="BLOCKED", + measured="precision=unavailable; adjudicated_sample=unavailable", + threshold=( + f"precision > {MEMORY_PRECISION_THRESHOLD:.2f} and " + f"adjudicated_sample >= {MEMORY_MIN_ADJUDICATED_SAMPLE}" + ), + detail=blocked_detail, + ) + ) + + return gate_results + + +def exit_code_for_gate_results(gate_results: list[GateResult]) -> int: + return 0 if all(result.status == "PASS" for result in gate_results) else 1 + + +def _print_gate_results(gate_results: list[GateResult]) -> None: + print("Phase 2 readiness gate results:") + for result in gate_results: + print(f" - {result.gate}: {result.status}") + print(f" measured: {result.measured}") + print(f" threshold: {result.threshold}") + print(f" detail: {result.detail}") + + +def main(argv: list[str] | None = None) -> int: + args = _parse_args(argv) + gate_results = run_readiness_gates(induce_gate=args.induce_gate) + _print_gate_results(gate_results) + + exit_code = exit_code_for_gate_results(gate_results) + if exit_code == 0: + print("Phase 2 readiness gate result: PASS") + else: + print("Phase 2 readiness gate result: NO_GO") + return exit_code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_phase2_validation_matrix.py b/scripts/run_phase2_validation_matrix.py new file mode 100644 index 0000000..463bf2a --- /dev/null +++ b/scripts/run_phase2_validation_matrix.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +from dataclasses import dataclass +from pathlib import Path +import shlex +import subprocess +import sys +import time +from typing import Callable, Literal + +ROOT_DIR = Path(__file__).resolve().parents[1] +WEB_DIR = ROOT_DIR / "apps" / "web" + +INDUCED_FAILURE_EXIT_CODE = 97 + +StepStatus = Literal["PASS", "FAIL"] + +BACKEND_INTEGRATION_TEST_FILES: tuple[str, ...] = ( + "tests/integration/test_continuity_api.py", + "tests/integration/test_responses_api.py", + "tests/integration/test_approval_api.py", + "tests/integration/test_proxy_execution_api.py", + "tests/integration/test_tasks_api.py", + "tests/integration/test_traces_api.py", + "tests/integration/test_memory_review_api.py", + "tests/integration/test_entities_api.py", + "tests/integration/test_task_artifacts_api.py", + "tests/integration/test_gmail_accounts_api.py", + "tests/integration/test_calendar_accounts_api.py", +) + +GATE_CONTRACT_TEST_FILES: tuple[str, ...] = ( + "tests/integration/test_mvp_readiness_gates.py", + "tests/integration/test_mvp_validation_matrix.py", +) + +WEB_OPERATOR_SURFACES: tuple[str, ...] = ( + "/chat", + "/approvals", + "/tasks", + "/artifacts", + "/gmail", + "/calendar", + "/memories", + "/entities", + "/traces", +) + +STEP_READINESS_GATES = "readiness_gates" +STEP_GATE_CONTRACT_TESTS = "gate_contract_tests" +STEP_BACKEND_MATRIX = "backend_integration_matrix" +STEP_WEB_MATRIX = "web_validation_matrix" +STEP_CONTROL_DOC_TRUTH = "control_doc_truth" +STEP_IDS: tuple[str, ...] = ( + STEP_CONTROL_DOC_TRUTH, + STEP_GATE_CONTRACT_TESTS, + STEP_READINESS_GATES, + STEP_BACKEND_MATRIX, + STEP_WEB_MATRIX, +) + + +@dataclass(frozen=True, slots=True) +class MatrixStep: + step: str + description: str + command: tuple[str, ...] + coverage: str + + +@dataclass(frozen=True, slots=True) +class MatrixStepResult: + step: str + status: StepStatus + exit_code: int + duration_seconds: float + command: tuple[str, ...] + coverage: str + induced_failure: bool + + +CommandExecutor = Callable[[tuple[str, ...], Path], int] + + +def _resolve_python_executable() -> str: + venv_python = ROOT_DIR / ".venv" / "bin" / "python" + if venv_python.exists(): + return str(venv_python) + return sys.executable + + +def _build_backend_matrix_command(python_executable: str) -> tuple[str, ...]: + return (python_executable, "-m", "pytest", "-q", *BACKEND_INTEGRATION_TEST_FILES) + + +def _build_gate_contract_tests_command(python_executable: str) -> tuple[str, ...]: + return (python_executable, "-m", "pytest", "-q", *GATE_CONTRACT_TEST_FILES) + + +def _build_control_doc_truth_command(python_executable: str) -> tuple[str, ...]: + return (python_executable, "scripts/check_control_doc_truth.py") + + +def _build_web_matrix_command() -> tuple[str, ...]: + return ("npm", "--prefix", str(WEB_DIR), "run", "test:mvp:validation-matrix") + + +def build_validation_matrix_steps(*, python_executable: str | None = None) -> list[MatrixStep]: + resolved_python = python_executable or _resolve_python_executable() + return [ + MatrixStep( + step=STEP_CONTROL_DOC_TRUTH, + description="Validate canonical control-doc truth markers and stale-marker exclusions.", + command=_build_control_doc_truth_command(resolved_python), + coverage=( + "ARCHITECTURE.md, ROADMAP.md, README.md, PRODUCT_BRIEF.md, RULES.md, " + ".ai/handoff/CURRENT_STATE.md baseline/ownership truth markers" + ), + ), + MatrixStep( + step=STEP_GATE_CONTRACT_TESTS, + description=( + "Run canonical gate-runner contract tests for readiness/validation matrix ownership " + "and MVP alias compatibility behavior." + ), + command=_build_gate_contract_tests_command(resolved_python), + coverage=( + "tests/integration/test_mvp_readiness_gates.py, " + "tests/integration/test_mvp_validation_matrix.py" + ), + ), + MatrixStep( + step=STEP_READINESS_GATES, + description="Run deterministic readiness gates prerequisite chain.", + command=(resolved_python, "scripts/run_phase2_readiness_gates.py"), + coverage="acceptance_suite, latency_p95, cache_reuse, memory_quality", + ), + MatrixStep( + step=STEP_BACKEND_MATRIX, + description="Run bounded backend integration seams matrix.", + command=_build_backend_matrix_command(resolved_python), + coverage=( + "continuity, responses, approvals/execution, tasks/steps, traces, " + "memory/entities/artifacts, gmail/calendar account seams" + ), + ), + MatrixStep( + step=STEP_WEB_MATRIX, + description="Run bounded web operator shell matrix via explicit Vitest suites.", + command=_build_web_matrix_command(), + coverage=", ".join(WEB_OPERATOR_SURFACES), + ), + ] + + +def _execute_command(command: tuple[str, ...], cwd: Path) -> int: + completed = subprocess.run( + list(command), + cwd=cwd, + check=False, + ) + return completed.returncode + + +def _build_induced_failure_command(*, step: str, python_executable: str) -> tuple[str, ...]: + return ( + python_executable, + "-c", + ( + "import sys; " + f"print('Induced validation-matrix failure for step: {step}'); " + f"sys.exit({INDUCED_FAILURE_EXIT_CODE})" + ), + ) + + +def run_validation_matrix( + *, + induce_step: str | None = None, + execute_command: CommandExecutor = _execute_command, +) -> list[MatrixStepResult]: + results: list[MatrixStepResult] = [] + matrix_steps = build_validation_matrix_steps() + python_executable = _resolve_python_executable() + + for matrix_step in matrix_steps: + induced_failure = induce_step == matrix_step.step + step_command = ( + _build_induced_failure_command(step=matrix_step.step, python_executable=python_executable) + if induced_failure + else matrix_step.command + ) + + started = time.perf_counter() + exit_code = execute_command(step_command, ROOT_DIR) + duration_seconds = time.perf_counter() - started + status: StepStatus = "PASS" if exit_code == 0 else "FAIL" + results.append( + MatrixStepResult( + step=matrix_step.step, + status=status, + exit_code=exit_code, + duration_seconds=duration_seconds, + command=step_command, + coverage=matrix_step.coverage, + induced_failure=induced_failure, + ) + ) + + return results + + +def exit_code_for_step_results(step_results: list[MatrixStepResult]) -> int: + return 0 if all(result.status == "PASS" for result in step_results) else 1 + + +def _print_step_results(step_results: list[MatrixStepResult]) -> None: + print("Phase 2 validation matrix results:") + for result in step_results: + print(f" - {result.step}: {result.status}") + print(f" command: {shlex.join(result.command)}") + print(f" duration_seconds: {result.duration_seconds:.3f}") + print(f" exit_code: {result.exit_code}") + print(f" coverage: {result.coverage}") + if result.induced_failure: + print(" induced_failure: true") + + failing_steps = [result.step for result in step_results if result.status != "PASS"] + if failing_steps: + print(f"Failing steps: {', '.join(failing_steps)}") + + +def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Run deterministic Phase 2 validation matrix over control-doc truth, readiness " + "prerequisite, backend seams, and web operator shell suites." + ), + ) + parser.add_argument( + "--induce-step", + choices=STEP_IDS, + default=None, + help=( + "Force one matrix step to fail deterministically to validate " + "no-go signaling and failing-step reporting." + ), + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = _parse_args(argv) + step_results = run_validation_matrix(induce_step=args.induce_step) + _print_step_results(step_results) + + exit_code = exit_code_for_step_results(step_results) + if exit_code == 0: + print("Phase 2 validation matrix result: PASS") + else: + print("Phase 2 validation matrix result: NO_GO") + return exit_code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_phase3_acceptance.py b/scripts/run_phase3_acceptance.py new file mode 100755 index 0000000..43fb32b --- /dev/null +++ b/scripts/run_phase3_acceptance.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +from pathlib import Path +import shlex +import subprocess +import sys + + +ROOT_DIR = Path(__file__).resolve().parents[1] +TARGET_SCRIPT = ROOT_DIR / "scripts" / "run_phase2_acceptance.py" + + +def _resolve_python_executable() -> str: + venv_python = ROOT_DIR / ".venv" / "bin" / "python" + if venv_python.exists(): + return str(venv_python) + return sys.executable + + +def main() -> int: + command = [_resolve_python_executable(), str(TARGET_SCRIPT), *sys.argv[1:]] + print("Phase 3 acceptance entrypoint -> scripts/run_phase2_acceptance.py", flush=True) + print(shlex.join(command), flush=True) + completed = subprocess.run( + command, + cwd=ROOT_DIR, + check=False, + ) + return completed.returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_phase3_readiness_gates.py b/scripts/run_phase3_readiness_gates.py new file mode 100755 index 0000000..f84da90 --- /dev/null +++ b/scripts/run_phase3_readiness_gates.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +from pathlib import Path +import shlex +import subprocess +import sys + + +ROOT_DIR = Path(__file__).resolve().parents[1] +TARGET_SCRIPT = ROOT_DIR / "scripts" / "run_phase2_readiness_gates.py" + + +def _resolve_python_executable() -> str: + venv_python = ROOT_DIR / ".venv" / "bin" / "python" + if venv_python.exists(): + return str(venv_python) + return sys.executable + + +def main() -> int: + command = [_resolve_python_executable(), str(TARGET_SCRIPT), *sys.argv[1:]] + print("Phase 3 readiness entrypoint -> scripts/run_phase2_readiness_gates.py", flush=True) + print(shlex.join(command), flush=True) + completed = subprocess.run( + command, + cwd=ROOT_DIR, + check=False, + ) + return completed.returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_phase3_validation_matrix.py b/scripts/run_phase3_validation_matrix.py new file mode 100755 index 0000000..6f61339 --- /dev/null +++ b/scripts/run_phase3_validation_matrix.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +from pathlib import Path +import shlex +import subprocess +import sys + + +ROOT_DIR = Path(__file__).resolve().parents[1] +TARGET_SCRIPT = ROOT_DIR / "scripts" / "run_phase2_validation_matrix.py" + + +def _resolve_python_executable() -> str: + venv_python = ROOT_DIR / ".venv" / "bin" / "python" + if venv_python.exists(): + return str(venv_python) + return sys.executable + + +def main() -> int: + command = [_resolve_python_executable(), str(TARGET_SCRIPT), *sys.argv[1:]] + print("Phase 3 validation matrix entrypoint -> scripts/run_phase2_validation_matrix.py", flush=True) + print(shlex.join(command), flush=True) + completed = subprocess.run( + command, + cwd=ROOT_DIR, + check=False, + ) + return completed.returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_phase4_acceptance.py b/scripts/run_phase4_acceptance.py new file mode 100755 index 0000000..725dbee --- /dev/null +++ b/scripts/run_phase4_acceptance.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +from dataclasses import dataclass +import os +from pathlib import Path +import shlex +import subprocess +import sys +from typing import Literal + + +ROOT_DIR = Path(__file__).resolve().parents[1] + +INDUCED_FAILURE_ENV = "MVP_ACCEPTANCE_INDUCED_FAILURE_SCENARIO" +ScenarioId = Literal["response_memory", "capture_resumption", "approval_execution", "magnesium_reorder"] + + +@dataclass(frozen=True, slots=True) +class AcceptanceScenario: + scenario: ScenarioId + node_id: str + evidence: str + + +ACCEPTANCE_SCENARIOS: tuple[AcceptanceScenario, ...] = ( + AcceptanceScenario( + scenario="response_memory", + node_id=( + "tests/integration/test_mvp_acceptance_suite.py::" + "test_acceptance_response_path_uses_admitted_memory_and_preference_correction" + ), + evidence="admitted memory and preference correction path remains deterministic", + ), + AcceptanceScenario( + scenario="capture_resumption", + node_id=( + "tests/integration/test_mvp_acceptance_suite.py::" + "test_acceptance_explicit_signal_capture_flows_into_resumption_brief" + ), + evidence="explicit signal capture persists and flows into bounded resumption brief", + ), + AcceptanceScenario( + scenario="approval_execution", + node_id=( + "tests/integration/test_mvp_acceptance_suite.py::" + "test_acceptance_approval_lifecycle_resolution_execution_and_trace_availability" + ), + evidence="approval resolution drives execution and trace evidence deterministically", + ), + AcceptanceScenario( + scenario="magnesium_reorder", + node_id=( + "tests/integration/test_mvp_acceptance_suite.py::" + "test_acceptance_canonical_magnesium_reorder_flow_with_memory_write_back_evidence" + ), + evidence=( + "canonical MVP ship gate (request -> approval -> execution -> memory write-back) " + "for magnesium reorder" + ), + ), +) + +ACCEPTANCE_SCENARIO_IDS: tuple[ScenarioId, ...] = tuple( + scenario.scenario for scenario in ACCEPTANCE_SCENARIOS +) +ACCEPTANCE_TEST_NODE_IDS: tuple[str, ...] = tuple( + scenario.node_id for scenario in ACCEPTANCE_SCENARIOS +) + + +def _resolve_python_executable() -> str: + venv_python = ROOT_DIR / ".venv" / "bin" / "python" + if venv_python.exists(): + return str(venv_python) + return sys.executable + + +def _build_pytest_command(python_executable: str) -> list[str]: + return [python_executable, "-m", "pytest", "-q", *ACCEPTANCE_TEST_NODE_IDS] + + +def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Run deterministic Phase 4 acceptance evidence checks, including canonical " + "magnesium reorder ship-gate coverage." + ), + ) + parser.add_argument( + "--induce-failure", + choices=ACCEPTANCE_SCENARIO_IDS, + default=None, + help="Intentionally fail one acceptance scenario to validate deterministic no-go signaling.", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = _parse_args(argv) + command = _build_pytest_command(_resolve_python_executable()) + + env = os.environ.copy() + if args.induce_failure is None: + env.pop(INDUCED_FAILURE_ENV, None) + else: + env[INDUCED_FAILURE_ENV] = args.induce_failure + + print("Phase 4 acceptance scenario evidence mapping:") + for scenario in ACCEPTANCE_SCENARIOS: + print(f" - {scenario.scenario}: {scenario.node_id}") + print(f" evidence: {scenario.evidence}") + if args.induce_failure is not None: + print(f"Induced failure enabled: {INDUCED_FAILURE_ENV}={args.induce_failure}") + print("Running command:") + print(shlex.join(command), flush=True) + + completed = subprocess.run( + command, + cwd=ROOT_DIR, + env=env, + check=False, + ) + + if completed.returncode == 0: + print("Phase 4 acceptance suite result: PASS") + else: + print(f"Phase 4 acceptance suite result: FAIL (exit code {completed.returncode})") + return completed.returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_phase4_mvp_qualification.py b/scripts/run_phase4_mvp_qualification.py new file mode 100644 index 0000000..ef19c72 --- /dev/null +++ b/scripts/run_phase4_mvp_qualification.py @@ -0,0 +1,393 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +from dataclasses import dataclass +from datetime import UTC, datetime +import json +import os +from pathlib import Path +import shlex +import subprocess +import sys +import time +from typing import Callable, Literal + + +ROOT_DIR = Path(__file__).resolve().parents[1] + +DEFAULT_SIGNOFF_PATH = ROOT_DIR / "artifacts" / "release" / "phase4_mvp_signoff_record.json" +DEFAULT_RC_SUMMARY_PATH = ROOT_DIR / "artifacts" / "release" / "phase4_rc_summary.json" +DEFAULT_RC_ARCHIVE_INDEX_PATH = ROOT_DIR / "artifacts" / "release" / "archive" / "index.json" +DEFAULT_MVP_EXIT_MANIFEST_PATH = ROOT_DIR / "artifacts" / "release" / "phase4_mvp_exit_manifest.json" + +SIGNOFF_ARTIFACT_VERSION = "phase4_mvp_signoff_record.v1" + +StepStatus = Literal["PASS", "FAIL", "NOT_RUN"] +FinalDecision = Literal["GO", "NO_GO"] + +STEP_RELEASE_CANDIDATE_REHEARSAL = "release_candidate_rehearsal" +STEP_RELEASE_CANDIDATE_ARCHIVE_VERIFY = "release_candidate_archive_verify" +STEP_MVP_EXIT_MANIFEST_GENERATE = "mvp_exit_manifest_generate" +STEP_MVP_EXIT_MANIFEST_VERIFY = "mvp_exit_manifest_verify" +STEP_IDS: tuple[str, ...] = ( + STEP_RELEASE_CANDIDATE_REHEARSAL, + STEP_RELEASE_CANDIDATE_ARCHIVE_VERIFY, + STEP_MVP_EXIT_MANIFEST_GENERATE, + STEP_MVP_EXIT_MANIFEST_VERIFY, +) + + +@dataclass(frozen=True, slots=True) +class QualificationStep: + step: str + description: str + command: tuple[str, ...] + required_artifacts: tuple[str, ...] + + +@dataclass(frozen=True, slots=True) +class QualificationStepResult: + step: str + description: str + status: StepStatus + exit_code: int | None + duration_seconds: float + command: tuple[str, ...] + required_artifacts: tuple[str, ...] + missing_artifacts: tuple[str, ...] + + +CommandExecutor = Callable[[tuple[str, ...], Path], int] + + +def _resolve_python_executable() -> str: + venv_python = ROOT_DIR / ".venv" / "bin" / "python" + if venv_python.exists(): + return str(venv_python) + return sys.executable + + +def _resolve_path(path_value: str) -> Path: + candidate = Path(path_value) + if candidate.is_absolute(): + return candidate + return ROOT_DIR / candidate + + +def _render_path(path: Path) -> str: + try: + return str(path.relative_to(ROOT_DIR)) + except ValueError: + return str(path) + + +def _render_json(payload: dict[str, object]) -> str: + return json.dumps(payload, indent=2, sort_keys=True) + "\n" + + +def _atomic_write_json(*, path: Path, payload: dict[str, object]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temp_path = path.parent / f".{path.name}.tmp.{os.getpid()}.{time.monotonic_ns()}" + temp_path.write_text(_render_json(payload), encoding="utf-8") + try: + os.replace(temp_path, path) + finally: + if temp_path.exists(): + temp_path.unlink() + + +def default_required_references( + *, + rc_summary_path: Path = DEFAULT_RC_SUMMARY_PATH, + rc_archive_index_path: Path = DEFAULT_RC_ARCHIVE_INDEX_PATH, + mvp_exit_manifest_path: Path = DEFAULT_MVP_EXIT_MANIFEST_PATH, +) -> dict[str, str]: + return { + "release_candidate_summary_path": _render_path(rc_summary_path), + "release_candidate_archive_index_path": _render_path(rc_archive_index_path), + "mvp_exit_manifest_path": _render_path(mvp_exit_manifest_path), + } + + +def build_qualification_steps( + *, + python_executable: str | None = None, + rc_summary_path: Path = DEFAULT_RC_SUMMARY_PATH, + rc_archive_index_path: Path = DEFAULT_RC_ARCHIVE_INDEX_PATH, + mvp_exit_manifest_path: Path = DEFAULT_MVP_EXIT_MANIFEST_PATH, +) -> list[QualificationStep]: + resolved_python = python_executable or _resolve_python_executable() + rc_summary_ref = _render_path(rc_summary_path) + rc_archive_index_ref = _render_path(rc_archive_index_path) + mvp_manifest_ref = _render_path(mvp_exit_manifest_path) + return [ + QualificationStep( + step=STEP_RELEASE_CANDIDATE_REHEARSAL, + description="Run canonical Phase 4 release-candidate rehearsal chain.", + command=(resolved_python, "scripts/run_phase4_release_candidate.py"), + required_artifacts=(rc_summary_ref, rc_archive_index_ref), + ), + QualificationStep( + step=STEP_RELEASE_CANDIDATE_ARCHIVE_VERIFY, + description="Verify append-only Phase 4 RC archive/index evidence.", + command=(resolved_python, "scripts/verify_phase4_rc_archive.py"), + required_artifacts=(rc_archive_index_ref,), + ), + QualificationStep( + step=STEP_MVP_EXIT_MANIFEST_GENERATE, + description="Generate deterministic Phase 4 MVP exit manifest from latest GO archive evidence.", + command=(resolved_python, "scripts/generate_phase4_mvp_exit_manifest.py"), + required_artifacts=(mvp_manifest_ref,), + ), + QualificationStep( + step=STEP_MVP_EXIT_MANIFEST_VERIFY, + description="Verify deterministic Phase 4 MVP exit manifest schema and source coherence.", + command=(resolved_python, "scripts/verify_phase4_mvp_exit_manifest.py"), + required_artifacts=(mvp_manifest_ref,), + ), + ] + + +def _execute_command(command: tuple[str, ...], cwd: Path) -> int: + completed = subprocess.run( + list(command), + cwd=cwd, + check=False, + ) + return completed.returncode + + +def run_qualification( + *, + qualification_steps: list[QualificationStep] | None = None, + execute_command: CommandExecutor = _execute_command, + cwd: Path = ROOT_DIR, +) -> list[QualificationStepResult]: + results: list[QualificationStepResult] = [] + steps = qualification_steps or build_qualification_steps() + failed = False + + for step in steps: + if failed: + results.append( + QualificationStepResult( + step=step.step, + description=step.description, + status="NOT_RUN", + exit_code=None, + duration_seconds=0.0, + command=step.command, + required_artifacts=step.required_artifacts, + missing_artifacts=(), + ) + ) + continue + + started = time.perf_counter() + exit_code = execute_command(step.command, cwd) + duration_seconds = time.perf_counter() - started + status: StepStatus = "PASS" if exit_code == 0 else "FAIL" + + missing_artifacts = tuple( + path_value + for path_value in step.required_artifacts + if not _resolve_path(path_value).exists() + ) + + if status == "PASS" and missing_artifacts: + status = "FAIL" + + results.append( + QualificationStepResult( + step=step.step, + description=step.description, + status=status, + exit_code=exit_code, + duration_seconds=duration_seconds, + command=step.command, + required_artifacts=step.required_artifacts, + missing_artifacts=missing_artifacts, + ) + ) + + if status == "FAIL": + failed = True + + return results + + +def final_decision_for_step_results(step_results: list[QualificationStepResult]) -> FinalDecision: + return "GO" if all(result.status == "PASS" for result in step_results) else "NO_GO" + + +def exit_code_for_final_decision(final_decision: FinalDecision) -> int: + return 0 if final_decision == "GO" else 1 + + +def _build_blockers(step_results: list[QualificationStepResult]) -> list[dict[str, object]]: + blockers: list[dict[str, object]] = [] + for result in step_results: + if result.status == "PASS": + continue + if result.status == "NOT_RUN": + blockers.append( + { + "step": result.step, + "reason": "upstream_failure", + "detail": "Step was not run because an earlier qualification gate failed.", + } + ) + continue + if result.missing_artifacts: + blockers.append( + { + "step": result.step, + "reason": "missing_required_artifacts", + "detail": ( + "Step command returned success but required artifacts were not present: " + + ", ".join(result.missing_artifacts) + ), + } + ) + continue + blockers.append( + { + "step": result.step, + "reason": "command_failed", + "detail": f"Step command exited non-zero ({result.exit_code}).", + } + ) + return blockers + + +def _normalize_utc_datetime(created_at: datetime | None) -> datetime: + if created_at is None: + return datetime.now(UTC).replace(microsecond=0) + if created_at.tzinfo is None: + return created_at.replace(tzinfo=UTC, microsecond=0) + return created_at.astimezone(UTC).replace(microsecond=0) + + +def _format_created_at(created_at: datetime) -> str: + return created_at.strftime("%Y-%m-%dT%H:%M:%SZ") + + +def build_signoff_record( + *, + step_results: list[QualificationStepResult], + artifact_path: Path, + required_references: dict[str, str], + created_at: datetime | None, +) -> dict[str, object]: + final_decision = final_decision_for_step_results(step_results) + blockers = _build_blockers(step_results) + failing_steps = [result.step for result in step_results if result.status == "FAIL"] + not_run_steps = [result.step for result in step_results if result.status == "NOT_RUN"] + normalized_created_at = _normalize_utc_datetime(created_at) + return { + "artifact_version": SIGNOFF_ARTIFACT_VERSION, + "artifact_path": _render_path(artifact_path), + "generated_at": _format_created_at(normalized_created_at), + "phase": "phase4", + "release_gate": "mvp", + "ordered_steps": list(STEP_IDS), + "executed_steps": sum(1 for result in step_results if result.status != "NOT_RUN"), + "total_steps": len(step_results), + "failing_steps": failing_steps, + "not_run_steps": not_run_steps, + "required_references": required_references, + "final_decision": final_decision, + "summary_exit_code": exit_code_for_final_decision(final_decision), + "blockers": blockers, + "steps": [ + { + "step": result.step, + "description": result.description, + "status": result.status, + "command": list(result.command), + "exit_code": result.exit_code, + "duration_seconds": round(result.duration_seconds, 6), + "required_artifacts": list(result.required_artifacts), + "missing_artifacts": list(result.missing_artifacts), + } + for result in step_results + ], + } + + +def write_signoff_record( + *, + step_results: list[QualificationStepResult], + artifact_path: Path = DEFAULT_SIGNOFF_PATH, + required_references: dict[str, str] | None = None, + created_at: datetime | None = None, +) -> dict[str, object]: + references = required_references or default_required_references() + record = build_signoff_record( + step_results=step_results, + artifact_path=artifact_path, + required_references=references, + created_at=created_at, + ) + _atomic_write_json(path=artifact_path, payload=record) + return record + + +def _print_step_results(step_results: list[QualificationStepResult]) -> None: + print("Phase 4 MVP qualification results:") + for result in step_results: + print(f" - {result.step}: {result.status}") + print(f" command: {shlex.join(result.command)}") + print(f" duration_seconds: {result.duration_seconds:.3f}") + print(f" exit_code: {result.exit_code}") + if result.required_artifacts: + print(" required_artifacts: " + ", ".join(result.required_artifacts)) + if result.missing_artifacts: + print(" missing_artifacts: " + ", ".join(result.missing_artifacts)) + + +def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Run the deterministic Phase 4 MVP qualification chain and emit a machine-readable " + "GO/NO_GO sign-off record." + ), + ) + parser.add_argument( + "--signoff-path", + default=str(DEFAULT_SIGNOFF_PATH), + help="Output path for the qualification sign-off JSON artifact.", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = _parse_args(argv) + signoff_path = Path(args.signoff_path) + + step_results = run_qualification() + signoff_record = write_signoff_record( + step_results=step_results, + artifact_path=signoff_path, + ) + _print_step_results(step_results) + + print(f"MVP qualification sign-off artifact: {signoff_record['artifact_path']}") + if signoff_record["final_decision"] == "GO": + print("Phase 4 MVP qualification result: GO") + else: + print("Phase 4 MVP qualification result: NO_GO") + blockers = signoff_record["blockers"] + assert isinstance(blockers, list) + if blockers: + print("Blockers:") + for blocker in blockers: + assert isinstance(blocker, dict) + print(f" - {blocker['step']}: {blocker['reason']} ({blocker['detail']})") + + return int(signoff_record["summary_exit_code"]) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_phase4_readiness_gates.py b/scripts/run_phase4_readiness_gates.py new file mode 100755 index 0000000..551bbd9 --- /dev/null +++ b/scripts/run_phase4_readiness_gates.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +from dataclasses import dataclass +import json +from pathlib import Path +import shlex +import subprocess +import sys +import time +from typing import Callable, Literal + + +ROOT_DIR = Path(__file__).resolve().parents[1] +QUALITY_EVIDENCE_ARTIFACT_PATH = ROOT_DIR / "artifacts" / "release" / "phase6_quality_evidence.json" + +INDUCED_FAILURE_EXIT_CODE = 97 + +GateStatus = Literal["PASS", "FAIL"] + +GATE_PHASE4_ACCEPTANCE = "phase4_acceptance" +GATE_MAGNESIUM_SHIP_GATE = "canonical_magnesium_ship_gate" +GATE_PHASE3_COMPAT = "phase3_readiness_compat" +GATE_IDS: tuple[str, ...] = ( + GATE_PHASE4_ACCEPTANCE, + GATE_MAGNESIUM_SHIP_GATE, + GATE_PHASE3_COMPAT, +) + +MAGNESIUM_NODE_ID = ( + "tests/integration/test_mvp_acceptance_suite.py::" + "test_acceptance_canonical_magnesium_reorder_flow_with_memory_write_back_evidence" +) + + +@dataclass(frozen=True, slots=True) +class ReadinessGate: + gate: str + description: str + command: tuple[str, ...] + coverage: str + + +@dataclass(frozen=True, slots=True) +class ReadinessGateResult: + gate: str + status: GateStatus + exit_code: int + duration_seconds: float + command: tuple[str, ...] + coverage: str + induced_failure: bool + + +CommandExecutor = Callable[[tuple[str, ...], Path], int] + + +def _resolve_python_executable() -> str: + venv_python = ROOT_DIR / ".venv" / "bin" / "python" + if venv_python.exists(): + return str(venv_python) + return sys.executable + + +def build_readiness_gate_steps(*, python_executable: str | None = None) -> list[ReadinessGate]: + resolved_python = python_executable or _resolve_python_executable() + return [ + ReadinessGate( + gate=GATE_PHASE4_ACCEPTANCE, + description="Run canonical Phase 4 acceptance command contract.", + command=(resolved_python, "scripts/run_phase4_acceptance.py"), + coverage="Phase 4 acceptance evidence chain ownership", + ), + ReadinessGate( + gate=GATE_MAGNESIUM_SHIP_GATE, + description="Run canonical magnesium reorder ship-gate evidence check directly.", + command=(resolved_python, "-m", "pytest", "-q", MAGNESIUM_NODE_ID), + coverage="request -> approval -> execution -> memory write-back canonical MVP scenario", + ), + ReadinessGate( + gate=GATE_PHASE3_COMPAT, + description="Run Phase 3 readiness command for compatibility posture.", + command=(resolved_python, "scripts/run_phase3_readiness_gates.py"), + coverage="Phase 3 readiness compatibility remains PASS", + ), + ] + + +def _execute_command(command: tuple[str, ...], cwd: Path) -> int: + completed = subprocess.run( + list(command), + cwd=cwd, + check=False, + ) + return completed.returncode + + +def _build_induced_failure_command(*, gate: str, python_executable: str) -> tuple[str, ...]: + return ( + python_executable, + "-c", + ( + "import sys; " + f"print('Induced phase4 readiness failure for gate: {gate}'); " + f"sys.exit({INDUCED_FAILURE_EXIT_CODE})" + ), + ) + + +def run_readiness_gates( + *, + induce_gate: str | None = None, + execute_command: CommandExecutor = _execute_command, +) -> list[ReadinessGateResult]: + results: list[ReadinessGateResult] = [] + gate_steps = build_readiness_gate_steps() + python_executable = _resolve_python_executable() + + for gate_step in gate_steps: + induced_failure = induce_gate == gate_step.gate + gate_command = ( + _build_induced_failure_command(gate=gate_step.gate, python_executable=python_executable) + if induced_failure + else gate_step.command + ) + + started = time.perf_counter() + exit_code = execute_command(gate_command, ROOT_DIR) + duration_seconds = time.perf_counter() - started + status: GateStatus = "PASS" if exit_code == 0 else "FAIL" + results.append( + ReadinessGateResult( + gate=gate_step.gate, + status=status, + exit_code=exit_code, + duration_seconds=duration_seconds, + command=gate_command, + coverage=gate_step.coverage, + induced_failure=induced_failure, + ) + ) + + return results + + +def exit_code_for_gate_results(gate_results: list[ReadinessGateResult]) -> int: + return 0 if all(result.status == "PASS" for result in gate_results) else 1 + + +def _print_gate_results(gate_results: list[ReadinessGateResult]) -> None: + print("Phase 4 readiness gate results:") + for result in gate_results: + print(f" - {result.gate}: {result.status}") + print(f" command: {shlex.join(result.command)}") + print(f" measured: exit_code={result.exit_code}") + print(" threshold: exit_code == 0") + print(f" duration_seconds: {result.duration_seconds:.3f}") + print(f" coverage: {result.coverage}") + if result.induced_failure: + print(" induced_failure: true") + + failing_gates = [result.gate for result in gate_results if result.status != "PASS"] + if failing_gates: + print(f"Failing gates: {', '.join(failing_gates)}") + + +def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Run deterministic Phase 4 readiness gates for canonical acceptance ownership, " + "magnesium ship-gate evidence, and Phase 3 compatibility." + ), + ) + parser.add_argument( + "--induce-gate", + choices=GATE_IDS, + default=None, + help="Force one readiness gate to fail deterministically for no-go contract validation.", + ) + return parser.parse_args(argv) + + +def _collect_quality_evidence_summary(*, python_executable: str) -> dict[str, object]: + command = (python_executable, "scripts/run_phase6_quality_evidence.py") + completed = subprocess.run( + list(command), + cwd=ROOT_DIR, + check=False, + capture_output=True, + text=True, + ) + + summary: dict[str, object] = { + "status": "PASS" if completed.returncode == 0 else "WARN", + "command": list(command), + "exit_code": completed.returncode, + "artifact_path": None, + "quality_gate_status": None, + "recommended_review_mode": None, + "recommended_review_action": None, + "detail": None, + } + if completed.returncode != 0: + summary["detail"] = (completed.stderr or completed.stdout).strip() or "quality evidence command failed" + return summary + + if not QUALITY_EVIDENCE_ARTIFACT_PATH.exists(): + summary["status"] = "WARN" + summary["detail"] = ( + "quality evidence command exited successfully but artifact path was not found: " + f"{QUALITY_EVIDENCE_ARTIFACT_PATH}" + ) + return summary + + payload = json.loads(QUALITY_EVIDENCE_ARTIFACT_PATH.read_text(encoding="utf-8")) + dashboard = payload.get("dashboard", {}) + quality_gate = dashboard.get("quality_gate", {}) if isinstance(dashboard, dict) else {} + recommended_review = dashboard.get("recommended_review", {}) if isinstance(dashboard, dict) else {} + summary["artifact_path"] = payload.get("artifact_path") + summary["quality_gate_status"] = quality_gate.get("status") + summary["recommended_review_mode"] = recommended_review.get("priority_mode") + summary["recommended_review_action"] = recommended_review.get("action") + return summary + + +def _print_quality_evidence_summary(summary: dict[str, object]) -> None: + print("Phase 6 quality evidence summary:") + print(f" - status: {summary['status']}") + print(f" command: {shlex.join(summary['command'])}") + print(f" exit_code: {summary['exit_code']}") + if summary.get("artifact_path") is not None: + print(f" artifact_path: {summary['artifact_path']}") + if summary.get("quality_gate_status") is not None: + print(f" quality_gate_status: {summary['quality_gate_status']}") + if summary.get("recommended_review_mode") is not None: + print(f" recommended_review_mode: {summary['recommended_review_mode']}") + if summary.get("recommended_review_action") is not None: + print(f" recommended_review_action: {summary['recommended_review_action']}") + if summary.get("detail") is not None: + print(f" detail: {summary['detail']}") + + +def main(argv: list[str] | None = None) -> int: + args = _parse_args(argv) + gate_results = run_readiness_gates(induce_gate=args.induce_gate) + _print_gate_results(gate_results) + quality_evidence_summary = _collect_quality_evidence_summary( + python_executable=_resolve_python_executable() + ) + _print_quality_evidence_summary(quality_evidence_summary) + + exit_code = exit_code_for_gate_results(gate_results) + if exit_code == 0: + print("Phase 4 readiness gate result: PASS") + else: + print("Phase 4 readiness gate result: NO_GO") + return exit_code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_phase4_release_candidate.py b/scripts/run_phase4_release_candidate.py new file mode 100644 index 0000000..52c69ac --- /dev/null +++ b/scripts/run_phase4_release_candidate.py @@ -0,0 +1,622 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +from contextlib import contextmanager +from dataclasses import dataclass +from datetime import UTC, datetime +import json +import os +from pathlib import Path +import shlex +import subprocess +import sys +import time +from typing import Callable, Literal + + +ROOT_DIR = Path(__file__).resolve().parents[1] +ARTIFACT_PATH = ROOT_DIR / "artifacts" / "release" / "phase4_rc_summary.json" +QUALITY_EVIDENCE_ARTIFACT_PATH = ROOT_DIR / "artifacts" / "release" / "phase6_quality_evidence.json" +ARCHIVE_DIR_NAME = "archive" +ARCHIVE_INDEX_NAME = "index.json" +ARCHIVE_INDEX_LOCK_NAME = "index.lock" +ARCHIVE_INDEX_VERSION = "phase4_rc_archive_index.v1" +ARCHIVE_FILENAME_SUFFIX = "_phase4_rc_summary.json" +ARCHIVE_INDEX_LOCK_TIMEOUT_SECONDS = 5.0 +ARCHIVE_INDEX_LOCK_RETRY_INTERVAL_SECONDS = 0.05 +ARCHIVE_INDEX_LOCK_TIMEOUT_EXIT_CODE = 2 + +INDUCED_FAILURE_EXIT_CODE = 97 + +StepStatus = Literal["PASS", "FAIL", "NOT_RUN"] +FinalDecision = Literal["GO", "NO_GO"] + +STEP_CONTROL_DOC_TRUTH = "control_doc_truth" +STEP_PHASE4_ACCEPTANCE = "phase4_acceptance" +STEP_PHASE4_READINESS = "phase4_readiness" +STEP_PHASE4_VALIDATION_MATRIX = "phase4_validation_matrix" +STEP_PHASE3_COMPAT_VALIDATION = "phase3_compat_validation" +STEP_PHASE2_COMPAT_VALIDATION = "phase2_compat_validation" +STEP_MVP_COMPAT_VALIDATION = "mvp_compat_validation" +STEP_IDS: tuple[str, ...] = ( + STEP_CONTROL_DOC_TRUTH, + STEP_PHASE4_ACCEPTANCE, + STEP_PHASE4_READINESS, + STEP_PHASE4_VALIDATION_MATRIX, + STEP_PHASE3_COMPAT_VALIDATION, + STEP_PHASE2_COMPAT_VALIDATION, + STEP_MVP_COMPAT_VALIDATION, +) + + +@dataclass(frozen=True, slots=True) +class ReleaseCandidateStep: + step: str + description: str + command: tuple[str, ...] + + +@dataclass(frozen=True, slots=True) +class ReleaseCandidateStepResult: + step: str + description: str + status: StepStatus + exit_code: int | None + duration_seconds: float + command: tuple[str, ...] + induced_failure: bool + + +CommandExecutor = Callable[[tuple[str, ...], Path], int] + + +class ArchiveIndexLockTimeoutError(RuntimeError): + """Raised when archive index lock acquisition times out.""" + + +def _resolve_python_executable() -> str: + venv_python = ROOT_DIR / ".venv" / "bin" / "python" + if venv_python.exists(): + return str(venv_python) + return sys.executable + + +def build_release_candidate_steps(*, python_executable: str | None = None) -> list[ReleaseCandidateStep]: + resolved_python = python_executable or _resolve_python_executable() + return [ + ReleaseCandidateStep( + step=STEP_CONTROL_DOC_TRUTH, + description="Validate control-doc truth markers.", + command=(resolved_python, "scripts/check_control_doc_truth.py"), + ), + ReleaseCandidateStep( + step=STEP_PHASE4_ACCEPTANCE, + description="Run Phase 4 acceptance gate.", + command=(resolved_python, "scripts/run_phase4_acceptance.py"), + ), + ReleaseCandidateStep( + step=STEP_PHASE4_READINESS, + description="Run Phase 4 readiness gates.", + command=(resolved_python, "scripts/run_phase4_readiness_gates.py"), + ), + ReleaseCandidateStep( + step=STEP_PHASE4_VALIDATION_MATRIX, + description="Run Phase 4 validation matrix.", + command=(resolved_python, "scripts/run_phase4_validation_matrix.py"), + ), + ReleaseCandidateStep( + step=STEP_PHASE3_COMPAT_VALIDATION, + description="Run Phase 3 compatibility validation matrix.", + command=(resolved_python, "scripts/run_phase3_validation_matrix.py"), + ), + ReleaseCandidateStep( + step=STEP_PHASE2_COMPAT_VALIDATION, + description="Run Phase 2 compatibility validation matrix.", + command=(resolved_python, "scripts/run_phase2_validation_matrix.py"), + ), + ReleaseCandidateStep( + step=STEP_MVP_COMPAT_VALIDATION, + description="Run MVP compatibility validation matrix.", + command=(resolved_python, "scripts/run_mvp_validation_matrix.py"), + ), + ] + + +def _execute_command(command: tuple[str, ...], cwd: Path) -> int: + completed = subprocess.run( + list(command), + cwd=cwd, + check=False, + ) + return completed.returncode + + +def _build_induced_failure_command(*, step: str, python_executable: str) -> tuple[str, ...]: + return ( + python_executable, + "-c", + ( + "import sys; " + f"print('Induced phase4 release-candidate failure for step: {step}'); " + f"sys.exit({INDUCED_FAILURE_EXIT_CODE})" + ), + ) + + +def run_release_candidate( + *, + induce_step: str | None = None, + execute_command: CommandExecutor = _execute_command, +) -> list[ReleaseCandidateStepResult]: + results: list[ReleaseCandidateStepResult] = [] + steps = build_release_candidate_steps() + python_executable = _resolve_python_executable() + failed = False + + for step in steps: + if failed: + results.append( + ReleaseCandidateStepResult( + step=step.step, + description=step.description, + status="NOT_RUN", + exit_code=None, + duration_seconds=0.0, + command=step.command, + induced_failure=False, + ) + ) + continue + + induced_failure = induce_step == step.step + step_command = ( + _build_induced_failure_command(step=step.step, python_executable=python_executable) + if induced_failure + else step.command + ) + + started = time.perf_counter() + exit_code = execute_command(step_command, ROOT_DIR) + duration_seconds = time.perf_counter() - started + status: StepStatus = "PASS" if exit_code == 0 else "FAIL" + + results.append( + ReleaseCandidateStepResult( + step=step.step, + description=step.description, + status=status, + exit_code=exit_code, + duration_seconds=duration_seconds, + command=step_command, + induced_failure=induced_failure, + ) + ) + + if status == "FAIL": + failed = True + + return results + + +def final_decision_for_step_results(step_results: list[ReleaseCandidateStepResult]) -> FinalDecision: + return "GO" if all(result.status == "PASS" for result in step_results) else "NO_GO" + + +def exit_code_for_final_decision(final_decision: FinalDecision) -> int: + return 0 if final_decision == "GO" else 1 + + +def _render_artifact_path(path: Path) -> str: + try: + return str(path.relative_to(ROOT_DIR)) + except ValueError: + return str(path) + + +def build_release_candidate_summary( + *, + step_results: list[ReleaseCandidateStepResult], + artifact_path: Path, + quality_evidence_summary: dict[str, object] | None = None, +) -> dict[str, object]: + final_decision = final_decision_for_step_results(step_results) + normalized_quality_evidence = quality_evidence_summary or { + "status": "NOT_RUN", + "command": None, + "exit_code": None, + "artifact_path": None, + "quality_gate_status": None, + "recommended_review_mode": None, + "recommended_review_action": None, + "detail": "quality evidence was not collected for this run", + } + return { + "artifact_version": "phase4_rc_summary.v1", + "artifact_path": _render_artifact_path(artifact_path), + "final_decision": final_decision, + "summary_exit_code": exit_code_for_final_decision(final_decision), + "ordered_steps": list(STEP_IDS), + "executed_steps": sum(1 for result in step_results if result.status != "NOT_RUN"), + "total_steps": len(step_results), + "failing_steps": [result.step for result in step_results if result.status == "FAIL"], + "steps": [ + { + "step": result.step, + "description": result.description, + "status": result.status, + "command": list(result.command), + "exit_code": result.exit_code, + "duration_seconds": round(result.duration_seconds, 6), + "induced_failure": result.induced_failure, + } + for result in step_results + ], + "quality_evidence": normalized_quality_evidence, + } + + +def _normalize_utc_datetime(created_at: datetime | None) -> datetime: + if created_at is None: + return datetime.now(UTC).replace(microsecond=0) + if created_at.tzinfo is None: + return created_at.replace(tzinfo=UTC, microsecond=0) + return created_at.astimezone(UTC).replace(microsecond=0) + + +def _format_created_at(created_at: datetime) -> str: + return created_at.strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _format_archive_timestamp(created_at: datetime) -> str: + return created_at.strftime("%Y%m%dT%H%M%SZ") + + +def _archive_dir_for_artifact_path(artifact_path: Path) -> Path: + return artifact_path.parent / ARCHIVE_DIR_NAME + + +def _archive_index_path_for_artifact_path(artifact_path: Path) -> Path: + return _archive_dir_for_artifact_path(artifact_path) / ARCHIVE_INDEX_NAME + + +def _archive_index_lock_path_for_artifact_path(artifact_path: Path) -> Path: + return _archive_dir_for_artifact_path(artifact_path) / ARCHIVE_INDEX_LOCK_NAME + + +def _render_json(payload: dict[str, object]) -> str: + return json.dumps(payload, indent=2, sort_keys=True) + "\n" + + +def _atomic_write_json(*, path: Path, payload: dict[str, object]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temp_path = path.parent / f".{path.name}.tmp.{os.getpid()}.{time.monotonic_ns()}" + temp_path.write_text(_render_json(payload), encoding="utf-8") + try: + os.replace(temp_path, path) + finally: + if temp_path.exists(): + temp_path.unlink() + + +@contextmanager +def _acquire_archive_index_lock( + *, + artifact_path: Path, + timeout_seconds: float, + retry_interval_seconds: float, +): + lock_path = _archive_index_lock_path_for_artifact_path(artifact_path) + lock_path.parent.mkdir(parents=True, exist_ok=True) + deadline = time.monotonic() + timeout_seconds + while True: + try: + fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY) + except FileExistsError: + if time.monotonic() >= deadline: + raise ArchiveIndexLockTimeoutError( + "Timed out acquiring archive index lock at " + f"{_render_artifact_path(lock_path)} after {timeout_seconds:.2f}s." + ) + time.sleep(retry_interval_seconds) + continue + + with os.fdopen(fd, "w", encoding="utf-8") as lock_file: + lock_file.write(f"pid={os.getpid()}\n") + break + + try: + yield + finally: + try: + lock_path.unlink() + except FileNotFoundError: + pass + + +def _next_archive_artifact_path(*, archive_dir: Path, timestamp: str) -> Path: + candidate = archive_dir / f"{timestamp}{ARCHIVE_FILENAME_SUFFIX}" + if not candidate.exists(): + return candidate + + suffix = 1 + while True: + candidate = archive_dir / f"{timestamp}_{suffix:03d}{ARCHIVE_FILENAME_SUFFIX}" + if not candidate.exists(): + return candidate + suffix += 1 + + +def _new_archive_index_payload(*, artifact_path: Path, archive_dir: Path) -> dict[str, object]: + return { + "artifact_version": ARCHIVE_INDEX_VERSION, + "latest_summary_path": _render_artifact_path(artifact_path), + "archive_dir": _render_artifact_path(archive_dir), + "entries": [], + } + + +def _load_archive_index_payload(*, artifact_path: Path, archive_dir: Path) -> dict[str, object]: + index_path = _archive_index_path_for_artifact_path(artifact_path) + if not index_path.exists(): + return _new_archive_index_payload(artifact_path=artifact_path, archive_dir=archive_dir) + + payload = json.loads(index_path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise ValueError("Archive index payload must be a JSON object.") + + if payload.get("artifact_version") != ARCHIVE_INDEX_VERSION: + raise ValueError( + f"Archive index artifact_version must be {ARCHIVE_INDEX_VERSION!r}." + ) + + entries = payload.get("entries") + if not isinstance(entries, list): + raise ValueError("Archive index entries must be a list.") + + latest_summary_path = payload.get("latest_summary_path") + if not isinstance(latest_summary_path, str): + raise ValueError("Archive index latest_summary_path must be a string.") + + archive_dir_value = payload.get("archive_dir") + if not isinstance(archive_dir_value, str): + raise ValueError("Archive index archive_dir must be a string.") + + return payload + + +def _append_archive_index_entry( + *, + artifact_path: Path, + archive_dir: Path, + entry: dict[str, object], +) -> Path: + payload = _load_archive_index_payload(artifact_path=artifact_path, archive_dir=archive_dir) + entries = payload["entries"] + assert isinstance(entries, list) + entries.append(entry) + + index_path = _archive_index_path_for_artifact_path(artifact_path) + _atomic_write_json(path=index_path, payload=payload) + return index_path + + +def _write_archive_copy_and_index( + *, + step_results: list[ReleaseCandidateStepResult], + artifact_path: Path, + command_mode: str, + created_at: datetime | None, + quality_evidence_summary: dict[str, object] | None = None, + lock_timeout_seconds: float = ARCHIVE_INDEX_LOCK_TIMEOUT_SECONDS, + lock_retry_interval_seconds: float = ARCHIVE_INDEX_LOCK_RETRY_INTERVAL_SECONDS, +) -> tuple[Path, Path]: + normalized_created_at = _normalize_utc_datetime(created_at) + created_at_iso = _format_created_at(normalized_created_at) + timestamp = _format_archive_timestamp(normalized_created_at) + + archive_dir = _archive_dir_for_artifact_path(artifact_path) + with _acquire_archive_index_lock( + artifact_path=artifact_path, + timeout_seconds=lock_timeout_seconds, + retry_interval_seconds=lock_retry_interval_seconds, + ): + archive_artifact_path = _next_archive_artifact_path(archive_dir=archive_dir, timestamp=timestamp) + archive_summary = build_release_candidate_summary( + step_results=step_results, + artifact_path=archive_artifact_path, + quality_evidence_summary=quality_evidence_summary, + ) + try: + _atomic_write_json(path=archive_artifact_path, payload=archive_summary) + + entry = { + "created_at": created_at_iso, + "archive_artifact_path": _render_artifact_path(archive_artifact_path), + "final_decision": archive_summary["final_decision"], + "summary_exit_code": archive_summary["summary_exit_code"], + "failing_steps": archive_summary["failing_steps"], + "command_mode": command_mode, + } + index_path = _append_archive_index_entry( + artifact_path=artifact_path, + archive_dir=archive_dir, + entry=entry, + ) + except Exception: + if archive_artifact_path.exists(): + archive_artifact_path.unlink() + raise + return archive_artifact_path, index_path + + +def write_release_candidate_summary( + *, + step_results: list[ReleaseCandidateStepResult], + artifact_path: Path = ARTIFACT_PATH, + quality_evidence_summary: dict[str, object] | None = None, + write_archive: bool = True, + command_mode: str = "default", + created_at: datetime | None = None, + lock_timeout_seconds: float = ARCHIVE_INDEX_LOCK_TIMEOUT_SECONDS, + lock_retry_interval_seconds: float = ARCHIVE_INDEX_LOCK_RETRY_INTERVAL_SECONDS, +) -> dict[str, object]: + summary = build_release_candidate_summary( + step_results=step_results, + artifact_path=artifact_path, + quality_evidence_summary=quality_evidence_summary, + ) + _atomic_write_json(path=artifact_path, payload=summary) + + if write_archive: + archive_artifact_path, archive_index_path = _write_archive_copy_and_index( + step_results=step_results, + artifact_path=artifact_path, + command_mode=command_mode, + created_at=created_at, + quality_evidence_summary=quality_evidence_summary, + lock_timeout_seconds=lock_timeout_seconds, + lock_retry_interval_seconds=lock_retry_interval_seconds, + ) + summary["archive_artifact_path"] = _render_artifact_path(archive_artifact_path) + summary["archive_index_path"] = _render_artifact_path(archive_index_path) + + return summary + + +def _print_step_results(step_results: list[ReleaseCandidateStepResult]) -> None: + print("Phase 4 release-candidate rehearsal results:") + for result in step_results: + print(f" - {result.step}: {result.status}") + print(f" command: {shlex.join(result.command)}") + print(f" duration_seconds: {result.duration_seconds:.3f}") + print(f" exit_code: {result.exit_code}") + if result.induced_failure: + print(" induced_failure: true") + + failing_steps = [result.step for result in step_results if result.status == "FAIL"] + if failing_steps: + print(f"Failing steps: {', '.join(failing_steps)}") + + +def _collect_quality_evidence_summary(*, python_executable: str) -> dict[str, object]: + command = (python_executable, "scripts/run_phase6_quality_evidence.py") + completed = subprocess.run( + list(command), + cwd=ROOT_DIR, + check=False, + capture_output=True, + text=True, + ) + summary: dict[str, object] = { + "status": "PASS" if completed.returncode == 0 else "WARN", + "command": list(command), + "exit_code": completed.returncode, + "artifact_path": None, + "quality_gate_status": None, + "recommended_review_mode": None, + "recommended_review_action": None, + "detail": None, + } + if completed.returncode != 0: + summary["detail"] = (completed.stderr or completed.stdout).strip() or "quality evidence command failed" + return summary + if not QUALITY_EVIDENCE_ARTIFACT_PATH.exists(): + summary["status"] = "WARN" + summary["detail"] = ( + "quality evidence command exited successfully but artifact path was not found: " + f"{QUALITY_EVIDENCE_ARTIFACT_PATH}" + ) + return summary + + payload = json.loads(QUALITY_EVIDENCE_ARTIFACT_PATH.read_text(encoding="utf-8")) + dashboard = payload.get("dashboard", {}) + quality_gate = dashboard.get("quality_gate", {}) if isinstance(dashboard, dict) else {} + recommended_review = dashboard.get("recommended_review", {}) if isinstance(dashboard, dict) else {} + summary["artifact_path"] = payload.get("artifact_path") + summary["quality_gate_status"] = quality_gate.get("status") + summary["recommended_review_mode"] = recommended_review.get("priority_mode") + summary["recommended_review_action"] = recommended_review.get("action") + return summary + + +def _print_quality_evidence_summary(summary: dict[str, object]) -> None: + print("Phase 6 quality evidence summary:") + print(f" - status: {summary['status']}") + command = summary.get("command") + if isinstance(command, list): + print(f" command: {shlex.join(command)}") + print(f" exit_code: {summary['exit_code']}") + if summary.get("artifact_path") is not None: + print(f" artifact_path: {summary['artifact_path']}") + if summary.get("quality_gate_status") is not None: + print(f" quality_gate_status: {summary['quality_gate_status']}") + if summary.get("recommended_review_mode") is not None: + print(f" recommended_review_mode: {summary['recommended_review_mode']}") + if summary.get("recommended_review_action") is not None: + print(f" recommended_review_action: {summary['recommended_review_action']}") + if summary.get("detail") is not None: + print(f" detail: {summary['detail']}") + + +def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Run deterministic Phase 4 release-candidate rehearsal chain and write " + "structured GO/NO_GO evidence bundle." + ), + ) + parser.add_argument( + "--induce-step", + choices=STEP_IDS, + default=None, + help="Force one rehearsal step to fail deterministically for NO_GO contract validation.", + ) + parser.add_argument( + "--no-archive", + action="store_true", + help="Write only the latest summary artifact and skip archive/index updates.", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = _parse_args(argv) + + step_results = run_release_candidate(induce_step=args.induce_step) + command_mode = "default" if args.induce_step is None else f"induced_failure:{args.induce_step}" + quality_evidence_summary = _collect_quality_evidence_summary( + python_executable=_resolve_python_executable() + ) + try: + summary = write_release_candidate_summary( + step_results=step_results, + quality_evidence_summary=quality_evidence_summary, + write_archive=not args.no_archive, + command_mode=command_mode, + ) + except ArchiveIndexLockTimeoutError as exc: + _print_step_results(step_results) + print(f"Phase 4 release-candidate archive update failed: {exc}") + return ARCHIVE_INDEX_LOCK_TIMEOUT_EXIT_CODE + _print_step_results(step_results) + _print_quality_evidence_summary(quality_evidence_summary) + + print(f"Release-candidate summary artifact: {summary['artifact_path']}") + archive_artifact_path = summary.get("archive_artifact_path") + archive_index_path = summary.get("archive_index_path") + if isinstance(archive_artifact_path, str): + print(f"Release-candidate archive artifact: {archive_artifact_path}") + if isinstance(archive_index_path, str): + print(f"Release-candidate archive index: {archive_index_path}") + final_decision = summary["final_decision"] + if final_decision == "GO": + print("Phase 4 release-candidate rehearsal result: GO") + else: + print("Phase 4 release-candidate rehearsal result: NO_GO") + + return int(summary["summary_exit_code"]) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_phase4_validation_matrix.py b/scripts/run_phase4_validation_matrix.py new file mode 100755 index 0000000..5855af5 --- /dev/null +++ b/scripts/run_phase4_validation_matrix.py @@ -0,0 +1,345 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +from dataclasses import dataclass +import json +from pathlib import Path +import shlex +import subprocess +import sys +import time +from typing import Callable, Literal + + +ROOT_DIR = Path(__file__).resolve().parents[1] +QUALITY_EVIDENCE_ARTIFACT_PATH = ROOT_DIR / "artifacts" / "release" / "phase6_quality_evidence.json" + +INDUCED_FAILURE_EXIT_CODE = 97 + +StepStatus = Literal["PASS", "FAIL"] + +STEP_CONTROL_DOC_TRUTH = "control_doc_truth" +STEP_PHASE4_ACCEPTANCE = "phase4_acceptance" +STEP_PHASE4_READINESS = "phase4_readiness_gates" +STEP_PHASE4_MAGNESIUM = "phase4_magnesium_ship_gate" +STEP_PHASE4_SCENARIOS = "phase4_scenarios" +STEP_PHASE4_WEB = "phase4_web_diagnostics" +STEP_PHASE3_COMPAT = "phase3_compat_validation" +STEP_PHASE2_COMPAT = "phase2_compat_validation" +STEP_MVP_COMPAT = "mvp_compat_validation" +STEP_IDS: tuple[str, ...] = ( + STEP_CONTROL_DOC_TRUTH, + STEP_PHASE4_ACCEPTANCE, + STEP_PHASE4_READINESS, + STEP_PHASE4_MAGNESIUM, + STEP_PHASE4_SCENARIOS, + STEP_PHASE4_WEB, + STEP_PHASE3_COMPAT, + STEP_PHASE2_COMPAT, + STEP_MVP_COMPAT, +) + +PHASE4_MAGNESIUM_NODE_ID = ( + "tests/integration/test_mvp_acceptance_suite.py::" + "test_acceptance_canonical_magnesium_reorder_flow_with_memory_write_back_evidence" +) + +PHASE4_SCENARIO_NODE_IDS: tuple[str, ...] = ( + "tests/integration/test_task_runs_api.py::test_task_run_endpoints_cover_budget_wait_resume_pause_cancel_and_conflicts", + "tests/unit/test_approvals.py::test_approval_resolution_resumes_waiting_approval_run_only", + "tests/unit/test_task_runs.py::test_tick_sets_budget_exhaustion_as_failed_with_explicit_failure_class", + "tests/unit/test_proxy_execution.py::test_registered_proxy_handler_keys_are_sorted_and_explicit", + "tests/integration/test_proxy_execution_api.py::test_execute_approved_proxy_endpoint_marks_linked_run_failed_when_blocked", +) + +PHASE4_WEB_COMMAND: tuple[str, ...] = ( + "pnpm", + "--dir", + "apps/web", + "exec", + "vitest", + "run", + "app/tasks/page.test.tsx", + "app/traces/page.test.tsx", + "components/task-run-list.test.tsx", + "components/execution-summary.test.tsx", + "lib/api.test.ts", +) + + +@dataclass(frozen=True, slots=True) +class MatrixStep: + step: str + description: str + command: tuple[str, ...] + coverage: str + + +@dataclass(frozen=True, slots=True) +class MatrixStepResult: + step: str + status: StepStatus + exit_code: int + duration_seconds: float + command: tuple[str, ...] + coverage: str + induced_failure: bool + + +CommandExecutor = Callable[[tuple[str, ...], Path], int] + + +def _resolve_python_executable() -> str: + venv_python = ROOT_DIR / ".venv" / "bin" / "python" + if venv_python.exists(): + return str(venv_python) + return sys.executable + + +def _build_phase4_scenario_command(python_executable: str) -> tuple[str, ...]: + return (python_executable, "-m", "pytest", "-q", *PHASE4_SCENARIO_NODE_IDS) + + +def _build_phase4_magnesium_command(python_executable: str) -> tuple[str, ...]: + return (python_executable, "-m", "pytest", "-q", PHASE4_MAGNESIUM_NODE_ID) + + +def build_validation_matrix_steps(*, python_executable: str | None = None) -> list[MatrixStep]: + resolved_python = python_executable or _resolve_python_executable() + return [ + MatrixStep( + step=STEP_CONTROL_DOC_TRUTH, + description="Validate canonical control-doc truth markers.", + command=(resolved_python, "scripts/check_control_doc_truth.py"), + coverage="README.md, ROADMAP.md, .ai/handoff/CURRENT_STATE.md and linked control docs", + ), + MatrixStep( + step=STEP_PHASE4_ACCEPTANCE, + description="Run Phase 4 acceptance gate entrypoint.", + command=(resolved_python, "scripts/run_phase4_acceptance.py"), + coverage="Phase 4 canonical acceptance chain with magnesium evidence mapping", + ), + MatrixStep( + step=STEP_PHASE4_READINESS, + description="Run Phase 4 readiness gate entrypoint.", + command=(resolved_python, "scripts/run_phase4_readiness_gates.py"), + coverage="Phase 4 deterministic readiness gates and explicit failing-gate signaling", + ), + MatrixStep( + step=STEP_PHASE4_MAGNESIUM, + description=( + "Run canonical MVP ship-gate magnesium reorder scenario evidence directly: " + "request -> approval -> execution -> memory write-back." + ), + command=_build_phase4_magnesium_command(resolved_python), + coverage=PHASE4_MAGNESIUM_NODE_ID, + ), + MatrixStep( + step=STEP_PHASE4_SCENARIOS, + description=( + "Run deterministic Phase 4 scenario evidence checks: " + "run_progression_with_pause, restart_safe_resume, budget_exhaustion_fail_closed, " + "draft_first_tool_execution, approval_resume_execution." + ), + command=_build_phase4_scenario_command(resolved_python), + coverage=", ".join(PHASE4_SCENARIO_NODE_IDS), + ), + MatrixStep( + step=STEP_PHASE4_WEB, + description="Run Phase 4 diagnostics shell tests for tasks/traces/run diagnostics surfaces.", + command=PHASE4_WEB_COMMAND, + coverage="apps/web tasks/traces diagnostics and API client shape", + ), + MatrixStep( + step=STEP_PHASE3_COMPAT, + description="Run Phase 3 compatibility validation matrix.", + command=(resolved_python, "scripts/run_phase3_validation_matrix.py"), + coverage="Phase 3 compatibility chain remains PASS", + ), + MatrixStep( + step=STEP_PHASE2_COMPAT, + description="Run Phase 2 compatibility validation matrix.", + command=(resolved_python, "scripts/run_phase2_validation_matrix.py"), + coverage="Phase 2 compatibility chain remains PASS", + ), + MatrixStep( + step=STEP_MVP_COMPAT, + description="Run MVP compatibility validation matrix alias.", + command=(resolved_python, "scripts/run_mvp_validation_matrix.py"), + coverage="MVP alias compatibility chain remains PASS", + ), + ] + + +def _execute_command(command: tuple[str, ...], cwd: Path) -> int: + completed = subprocess.run( + list(command), + cwd=cwd, + check=False, + ) + return completed.returncode + + +def _build_induced_failure_command(*, step: str, python_executable: str) -> tuple[str, ...]: + return ( + python_executable, + "-c", + ( + "import sys; " + f"print('Induced phase4 validation failure for step: {step}'); " + f"sys.exit({INDUCED_FAILURE_EXIT_CODE})" + ), + ) + + +def run_validation_matrix( + *, + induce_step: str | None = None, + execute_command: CommandExecutor = _execute_command, +) -> list[MatrixStepResult]: + results: list[MatrixStepResult] = [] + matrix_steps = build_validation_matrix_steps() + python_executable = _resolve_python_executable() + + for matrix_step in matrix_steps: + induced_failure = induce_step == matrix_step.step + step_command = ( + _build_induced_failure_command(step=matrix_step.step, python_executable=python_executable) + if induced_failure + else matrix_step.command + ) + + started = time.perf_counter() + exit_code = execute_command(step_command, ROOT_DIR) + duration_seconds = time.perf_counter() - started + status: StepStatus = "PASS" if exit_code == 0 else "FAIL" + results.append( + MatrixStepResult( + step=matrix_step.step, + status=status, + exit_code=exit_code, + duration_seconds=duration_seconds, + command=step_command, + coverage=matrix_step.coverage, + induced_failure=induced_failure, + ) + ) + + return results + + +def exit_code_for_step_results(step_results: list[MatrixStepResult]) -> int: + return 0 if all(result.status == "PASS" for result in step_results) else 1 + + +def _print_step_results(step_results: list[MatrixStepResult]) -> None: + print("Phase 4 validation matrix results:") + for result in step_results: + print(f" - {result.step}: {result.status}") + print(f" command: {shlex.join(result.command)}") + print(f" duration_seconds: {result.duration_seconds:.3f}") + print(f" exit_code: {result.exit_code}") + print(f" coverage: {result.coverage}") + if result.induced_failure: + print(" induced_failure: true") + + failing_steps = [result.step for result in step_results if result.status != "PASS"] + if failing_steps: + print(f"Failing steps: {', '.join(failing_steps)}") + + +def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Run deterministic Phase 4 validation matrix over control docs, " + "canonical Phase 4 acceptance/readiness ownership, magnesium ship-gate evidence, " + "diagnostics shell tests, and Phase 3/2/MVP compatibility." + ), + ) + parser.add_argument( + "--induce-step", + choices=STEP_IDS, + default=None, + help="Force one matrix step to fail deterministically for no-go signaling validation.", + ) + return parser.parse_args(argv) + + +def _collect_quality_evidence_summary(*, python_executable: str) -> dict[str, object]: + command = (python_executable, "scripts/run_phase6_quality_evidence.py") + completed = subprocess.run( + list(command), + cwd=ROOT_DIR, + check=False, + capture_output=True, + text=True, + ) + summary: dict[str, object] = { + "status": "PASS" if completed.returncode == 0 else "WARN", + "command": list(command), + "exit_code": completed.returncode, + "artifact_path": None, + "quality_gate_status": None, + "recommended_review_mode": None, + "recommended_review_action": None, + "detail": None, + } + if completed.returncode != 0: + summary["detail"] = (completed.stderr or completed.stdout).strip() or "quality evidence command failed" + return summary + if not QUALITY_EVIDENCE_ARTIFACT_PATH.exists(): + summary["status"] = "WARN" + summary["detail"] = ( + "quality evidence command exited successfully but artifact path was not found: " + f"{QUALITY_EVIDENCE_ARTIFACT_PATH}" + ) + return summary + + payload = json.loads(QUALITY_EVIDENCE_ARTIFACT_PATH.read_text(encoding="utf-8")) + dashboard = payload.get("dashboard", {}) + quality_gate = dashboard.get("quality_gate", {}) if isinstance(dashboard, dict) else {} + recommended_review = dashboard.get("recommended_review", {}) if isinstance(dashboard, dict) else {} + summary["artifact_path"] = payload.get("artifact_path") + summary["quality_gate_status"] = quality_gate.get("status") + summary["recommended_review_mode"] = recommended_review.get("priority_mode") + summary["recommended_review_action"] = recommended_review.get("action") + return summary + + +def _print_quality_evidence_summary(summary: dict[str, object]) -> None: + print("Phase 6 quality evidence summary:") + print(f" - status: {summary['status']}") + print(f" command: {shlex.join(summary['command'])}") + print(f" exit_code: {summary['exit_code']}") + if summary.get("artifact_path") is not None: + print(f" artifact_path: {summary['artifact_path']}") + if summary.get("quality_gate_status") is not None: + print(f" quality_gate_status: {summary['quality_gate_status']}") + if summary.get("recommended_review_mode") is not None: + print(f" recommended_review_mode: {summary['recommended_review_mode']}") + if summary.get("recommended_review_action") is not None: + print(f" recommended_review_action: {summary['recommended_review_action']}") + if summary.get("detail") is not None: + print(f" detail: {summary['detail']}") + + +def main(argv: list[str] | None = None) -> int: + args = _parse_args(argv) + step_results = run_validation_matrix(induce_step=args.induce_step) + _print_step_results(step_results) + quality_evidence_summary = _collect_quality_evidence_summary( + python_executable=_resolve_python_executable() + ) + _print_quality_evidence_summary(quality_evidence_summary) + + exit_code = exit_code_for_step_results(step_results) + if exit_code == 0: + print("Phase 4 validation matrix result: PASS") + else: + print("Phase 4 validation matrix result: NO_GO") + return exit_code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_phase6_quality_evidence.py b/scripts/run_phase6_quality_evidence.py new file mode 100644 index 0000000..19c1a20 --- /dev/null +++ b/scripts/run_phase6_quality_evidence.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +from uuid import UUID + +from alicebot_api.config import get_settings +from alicebot_api.db import user_connection +from alicebot_api.memory import get_memory_trust_dashboard_summary +from alicebot_api.store import ContinuityStore + + +ROOT_DIR = Path(__file__).resolve().parents[1] +DEFAULT_ARTIFACT_PATH = ROOT_DIR / "artifacts" / "release" / "phase6_quality_evidence.json" +DEFAULT_USER_ID = UUID("00000000-0000-4000-8000-000000000001") +ARTIFACT_VERSION = "phase6_quality_evidence.v1" + + +def _render_artifact_path(path: Path) -> str: + try: + return str(path.relative_to(ROOT_DIR)) + except ValueError: + return str(path) + + +def _resolve_user_id(explicit_user_id: str | None) -> UUID: + raw_value = explicit_user_id or os.getenv("PHASE6_QUALITY_USER_ID") or str(DEFAULT_USER_ID) + try: + return UUID(raw_value) + except ValueError as exc: + raise ValueError(f"user_id must be a valid UUID: {raw_value}") from exc + + +def build_quality_evidence_payload(*, artifact_path: Path, user_id: UUID) -> dict[str, object]: + settings = get_settings() + with user_connection(settings.database_url, user_id) as conn: + dashboard = get_memory_trust_dashboard_summary( + ContinuityStore(conn), + user_id=user_id, + )["dashboard"] + + return { + "artifact_version": ARTIFACT_VERSION, + "artifact_path": _render_artifact_path(artifact_path), + "user_id": str(user_id), + "dashboard": dashboard, + } + + +def _atomic_write_json(*, path: Path, payload: dict[str, object]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temp_path = path.parent / f".{path.name}.tmp.{os.getpid()}" + temp_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + try: + os.replace(temp_path, path) + finally: + if temp_path.exists(): + temp_path.unlink() + + +def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Build deterministic Phase 6 quality evidence from canonical memory trust dashboard " + "semantics for release/readiness reporting." + ), + ) + parser.add_argument( + "--user-id", + default=None, + help=( + "User UUID for quality evidence scope. Defaults to PHASE6_QUALITY_USER_ID env var, " + "or deterministic fallback UUID when unset." + ), + ) + parser.add_argument( + "--output", + default=str(DEFAULT_ARTIFACT_PATH), + help="Artifact output path.", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = _parse_args(argv) + + try: + user_id = _resolve_user_id(args.user_id) + except ValueError as exc: + print(f"Phase 6 quality evidence failed: {exc}") + return 2 + + artifact_path = Path(args.output).expanduser() + if not artifact_path.is_absolute(): + artifact_path = (ROOT_DIR / artifact_path).resolve() + + payload = build_quality_evidence_payload( + artifact_path=artifact_path, + user_id=user_id, + ) + _atomic_write_json(path=artifact_path, payload=payload) + + print(f"Phase 6 quality evidence artifact: {payload['artifact_path']}") + dashboard = payload["dashboard"] + assert isinstance(dashboard, dict) + quality_gate = dashboard.get("quality_gate", {}) + recommended_review = dashboard.get("recommended_review", {}) + print(f"quality_gate_status: {quality_gate.get('status')}") + print( + "recommended_review: " + f"mode={recommended_review.get('priority_mode')} " + f"action={recommended_review.get('action')}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_phase9_eval.py b/scripts/run_phase9_eval.py new file mode 100755 index 0000000..f464262 --- /dev/null +++ b/scripts/run_phase9_eval.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import sys +from uuid import UUID + + +REPO_ROOT = Path(__file__).resolve().parents[1] +_VENV_REEXEC_ENV = "ALICEBOT_PHASE9_EVAL_REEXEC" + + +def _maybe_reexec_into_repo_venv() -> None: + if os.getenv(_VENV_REEXEC_ENV) == "1": + return + + venv_python = (REPO_ROOT / ".venv" / "bin" / "python").resolve() + if not venv_python.exists(): + return + + current_python = Path(sys.executable).expanduser().resolve() + if current_python == venv_python: + return + + os.environ[_VENV_REEXEC_ENV] = "1" + os.execv( + str(venv_python), + [ + str(venv_python), + str(Path(__file__).resolve()), + *sys.argv[1:], + ], + ) + + +_maybe_reexec_into_repo_venv() + +API_SRC = REPO_ROOT / "apps" / "api" / "src" +if str(API_SRC) not in sys.path: + sys.path.insert(0, str(API_SRC)) + +from alicebot_api.db import user_connection +from alicebot_api.retrieval_evaluation import run_phase9_evaluation, write_phase9_evaluation_report +from alicebot_api.store import ContinuityStore + + +DEFAULT_DATABASE_URL = "postgresql://alicebot_app:alicebot_app@localhost:5432/alicebot" +DEFAULT_AUTH_USER_ID = "00000000-0000-0000-0000-000000000001" +DEFAULT_REPORT_PATH = REPO_ROOT / "eval" / "reports" / "phase9_eval_latest.json" +DEFAULT_OPENCLAW_SOURCE = REPO_ROOT / "fixtures" / "openclaw" / "workspace_v1.json" +DEFAULT_MARKDOWN_SOURCE = REPO_ROOT / "fixtures" / "importers" / "markdown" / "workspace_v1.md" +DEFAULT_CHATGPT_SOURCE = REPO_ROOT / "fixtures" / "importers" / "chatgpt" / "workspace_v1.json" + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Run Phase 9 importer and continuity evaluation harness and write a baseline report." + ) + parser.add_argument( + "--database-url", + default=os.getenv("DATABASE_URL", DEFAULT_DATABASE_URL), + help="Database URL used for writes and reads.", + ) + parser.add_argument( + "--user-id", + default=os.getenv("ALICEBOT_AUTH_USER_ID", DEFAULT_AUTH_USER_ID), + help="User ID to own the evaluation run data.", + ) + parser.add_argument( + "--user-email", + default=os.getenv("ALICEBOT_IMPORT_USER_EMAIL", "phase9-eval@example.com"), + help="Email for auto-created user when --user-id is not found.", + ) + parser.add_argument( + "--display-name", + default=os.getenv("ALICEBOT_IMPORT_USER_DISPLAY_NAME", "Phase9 Eval User"), + help="Display name for auto-created user when --user-id is not found.", + ) + parser.add_argument( + "--openclaw-source", + default=str(DEFAULT_OPENCLAW_SOURCE), + help="Path to OpenClaw fixture source.", + ) + parser.add_argument( + "--markdown-source", + default=str(DEFAULT_MARKDOWN_SOURCE), + help="Path to markdown fixture source.", + ) + parser.add_argument( + "--chatgpt-source", + default=str(DEFAULT_CHATGPT_SOURCE), + help="Path to ChatGPT fixture source.", + ) + parser.add_argument( + "--report-path", + default=str(DEFAULT_REPORT_PATH), + help="Output JSON report path.", + ) + return parser.parse_args() + + +def _ensure_user(store: ContinuityStore, *, user_id: UUID, email: str, display_name: str) -> None: + with store.conn.cursor() as cur: + cur.execute("SELECT 1 FROM users WHERE id = %s", (user_id,)) + exists = cur.fetchone() is not None + if exists: + return + store.create_user(user_id, email, display_name) + + +def main() -> int: + args = _parse_args() + user_id = UUID(str(args.user_id)) + + with user_connection(args.database_url, user_id) as conn: + store = ContinuityStore(conn) + _ensure_user( + store, + user_id=user_id, + email=str(args.user_email), + display_name=str(args.display_name), + ) + + report = run_phase9_evaluation( + store, + user_id=user_id, + openclaw_source=Path(str(args.openclaw_source)).expanduser().resolve(), + markdown_source=Path(str(args.markdown_source)).expanduser().resolve(), + chatgpt_source=Path(str(args.chatgpt_source)).expanduser().resolve(), + ) + + output_path = write_phase9_evaluation_report( + report=report, + report_path=Path(str(args.report_path)).expanduser().resolve(), + ) + + print( + json.dumps( + { + "status": report["summary"]["status"], + "report_path": str(output_path), + "summary": report["summary"], + }, + indent=2, + sort_keys=True, + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_phase9_eval.sh b/scripts/run_phase9_eval.sh new file mode 100755 index 0000000..ce6d63b --- /dev/null +++ b/scripts/run_phase9_eval.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" + +if [ -f "${REPO_ROOT}/.env" ]; then + set -a + . "${REPO_ROOT}/.env" + set +a +fi + +PYTHON_BIN="python3" +if [ -x "${REPO_ROOT}/.venv/bin/python" ]; then + PYTHON_BIN="${REPO_ROOT}/.venv/bin/python" +fi + +cd "${REPO_ROOT}" + +exec "${PYTHON_BIN}" "${REPO_ROOT}/scripts/run_phase9_eval.py" "$@" diff --git a/scripts/use_alice_with_openclaw.py b/scripts/use_alice_with_openclaw.py new file mode 100755 index 0000000..d8bb4ee --- /dev/null +++ b/scripts/use_alice_with_openclaw.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import sys +from uuid import UUID, uuid4 + +REPO_ROOT = Path(__file__).resolve().parents[1] +API_SRC = REPO_ROOT / "apps" / "api" / "src" +if str(API_SRC) not in sys.path: + sys.path.insert(0, str(API_SRC)) + +from alicebot_api.continuity_recall import query_continuity_recall +from alicebot_api.continuity_resumption import compile_continuity_resumption_brief +from alicebot_api.contracts import ContinuityRecallQueryInput, ContinuityResumptionBriefRequestInput +from alicebot_api.db import user_connection +from alicebot_api.openclaw_import import import_openclaw_source +from alicebot_api.store import ContinuityStore + +DEFAULT_DATABASE_URL = "postgresql://alicebot_app:alicebot_app@localhost:5432/alicebot" +DEFAULT_SOURCE_PATH = REPO_ROOT / "fixtures" / "openclaw" / "workspace_v1.json" +DEFAULT_THREAD_ID = "cccccccc-cccc-4ccc-8ccc-cccccccccccc" +DEFAULT_PROJECT = "Alice Public Core" +DEFAULT_QUERY = "MCP tool surface" + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Run one-command OpenClaw integration demo: before recall/resume, import, idempotent replay, " + "and after recall/resume verification." + ) + ) + parser.add_argument( + "--source", + default=os.getenv("OPENCLAW_SAMPLE_DATA_PATH", str(DEFAULT_SOURCE_PATH)), + help="Path to an OpenClaw workspace/export file or directory.", + ) + parser.add_argument( + "--database-url", + default=os.getenv("DATABASE_URL", DEFAULT_DATABASE_URL), + help="Database URL used for reads/writes.", + ) + parser.add_argument( + "--user-id", + default=os.getenv("ALICEBOT_AUTH_USER_ID"), + help="Optional user ID. If omitted, a new UUID is generated for isolated demo output.", + ) + parser.add_argument( + "--user-email", + default=None, + help="Optional user email. Defaults to openclaw-demo-<user_id>@example.com.", + ) + parser.add_argument( + "--display-name", + default="OpenClaw Demo User", + help="Display name for auto-created user when needed.", + ) + parser.add_argument( + "--thread-id", + default=DEFAULT_THREAD_ID, + help="Thread UUID used for recall/resume verification.", + ) + parser.add_argument( + "--project", + default=DEFAULT_PROJECT, + help="Project filter used for recall verification.", + ) + parser.add_argument( + "--query", + default=DEFAULT_QUERY, + help="Recall query text used before and after import.", + ) + parser.add_argument( + "--limit", + type=int, + default=20, + help="Recall limit.", + ) + parser.add_argument( + "--max-recent-changes", + type=int, + default=10, + help="Resume max_recent_changes parameter.", + ) + parser.add_argument( + "--max-open-loops", + type=int, + default=10, + help="Resume max_open_loops parameter.", + ) + return parser.parse_args() + + +def _ensure_user(store: ContinuityStore, *, user_id: UUID, email: str, display_name: str) -> None: + with store.conn.cursor() as cur: + cur.execute("SELECT 1 FROM users WHERE id = %s", (user_id,)) + exists = cur.fetchone() is not None + if exists: + return + store.create_user(user_id, email, display_name) + + +def _source_kinds(items: list[dict[str, object]]) -> list[str]: + kinds: list[str] = [] + for item in items: + provenance = item.get("provenance") + if not isinstance(provenance, dict): + continue + source_kind = provenance.get("source_kind") + if isinstance(source_kind, str): + kinds.append(source_kind) + return sorted(set(kinds)) + + +def _source_labels(items: list[dict[str, object]]) -> list[str]: + labels: list[str] = [] + for item in items: + provenance = item.get("provenance") + if not isinstance(provenance, dict): + continue + source_label = provenance.get("source_label") + if isinstance(source_label, str): + labels.append(source_label) + return sorted(set(labels)) + + +def _provenance_value(item: dict[str, object] | None, key: str) -> str | None: + if not isinstance(item, dict): + return None + provenance = item.get("provenance") + if not isinstance(provenance, dict): + return None + value = provenance.get(key) + return value if isinstance(value, str) else None + + +def main() -> int: + args = _parse_args() + + source_path = Path(args.source).expanduser().resolve() + user_id = UUID(str(args.user_id)) if args.user_id else uuid4() + user_email = args.user_email or f"openclaw-demo-{user_id}@example.com" + thread_id = UUID(str(args.thread_id)) + + with user_connection(args.database_url, user_id) as conn: + store = ContinuityStore(conn) + _ensure_user(store, user_id=user_id, email=user_email, display_name=str(args.display_name)) + + recall_before = query_continuity_recall( + store, + user_id=user_id, + request=ContinuityRecallQueryInput( + thread_id=thread_id, + project=args.project, + query=args.query, + limit=args.limit, + ), + ) + resume_before = compile_continuity_resumption_brief( + store, + user_id=user_id, + request=ContinuityResumptionBriefRequestInput( + thread_id=thread_id, + max_recent_changes=args.max_recent_changes, + max_open_loops=args.max_open_loops, + ), + ) + + first_import = import_openclaw_source( + store, + user_id=user_id, + source=source_path, + ) + second_import = import_openclaw_source( + store, + user_id=user_id, + source=source_path, + ) + + recall_after = query_continuity_recall( + store, + user_id=user_id, + request=ContinuityRecallQueryInput( + thread_id=thread_id, + project=args.project, + query=args.query, + limit=args.limit, + ), + ) + resume_after = compile_continuity_resumption_brief( + store, + user_id=user_id, + request=ContinuityResumptionBriefRequestInput( + thread_id=thread_id, + max_recent_changes=args.max_recent_changes, + max_open_loops=args.max_open_loops, + ), + ) + + recall_after_items = recall_after["items"] + resume_after_brief = resume_after["brief"] + last_decision_item = resume_after_brief["last_decision"]["item"] + next_action_item = resume_after_brief["next_action"]["item"] + + checks = { + "first_import_ok": first_import["status"] == "ok", + "first_import_has_openclaw_source_kind": first_import["provenance_source_kind"] == "openclaw_import", + "first_import_has_openclaw_source_label": first_import.get("provenance_source_label") == "OpenClaw", + "second_import_noop": second_import["status"] == "noop", + "second_import_skipped_all_candidates": second_import["skipped_duplicates"] == second_import["total_candidates"], + "recall_after_includes_openclaw_source": any( + isinstance(item.get("provenance"), dict) + and item["provenance"].get("source_kind") == "openclaw_import" + and item["provenance"].get("source_label") == "OpenClaw" + for item in recall_after_items + ), + "resume_after_last_decision_openclaw": isinstance(last_decision_item, dict) + and isinstance(last_decision_item.get("provenance"), dict) + and last_decision_item["provenance"].get("source_kind") == "openclaw_import" + and last_decision_item["provenance"].get("source_label") == "OpenClaw", + "resume_after_next_action_openclaw": isinstance(next_action_item, dict) + and isinstance(next_action_item.get("provenance"), dict) + and next_action_item["provenance"].get("source_kind") == "openclaw_import" + and next_action_item["provenance"].get("source_label") == "OpenClaw", + } + + payload = { + "status": "pass" if all(checks.values()) else "fail", + "source_path": str(source_path), + "user_id": str(user_id), + "user_email": user_email, + "before": { + "recall_returned_count": recall_before["summary"]["returned_count"], + "resume_last_decision_present": resume_before["brief"]["last_decision"]["item"] is not None, + "resume_next_action_present": resume_before["brief"]["next_action"]["item"] is not None, + }, + "import": { + "first": first_import, + "second": second_import, + }, + "after": { + "recall_returned_count": recall_after["summary"]["returned_count"], + "recall_source_kinds": _source_kinds(recall_after_items), + "recall_source_labels": _source_labels(recall_after_items), + "resume_last_decision_source_kind": _provenance_value(last_decision_item, "source_kind"), + "resume_last_decision_source_label": _provenance_value(last_decision_item, "source_label"), + "resume_next_action_source_kind": _provenance_value(next_action_item, "source_kind"), + "resume_next_action_source_label": _provenance_value(next_action_item, "source_label"), + }, + "checks": checks, + } + + print(json.dumps(payload, indent=2, sort_keys=True)) + return 0 if payload["status"] == "pass" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/use_alice_with_openclaw.sh b/scripts/use_alice_with_openclaw.sh new file mode 100755 index 0000000..8e73775 --- /dev/null +++ b/scripts/use_alice_with_openclaw.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" + +if [ -f "${REPO_ROOT}/.env" ]; then + set -a + . "${REPO_ROOT}/.env" + set +a +fi + +PYTHON_BIN="python3" +if [ -x "${REPO_ROOT}/.venv/bin/python" ]; then + PYTHON_BIN="${REPO_ROOT}/.venv/bin/python" +fi + +cd "${REPO_ROOT}" + +exec "${PYTHON_BIN}" "${REPO_ROOT}/scripts/use_alice_with_openclaw.py" "$@" diff --git a/scripts/verify_phase4_mvp_exit_manifest.py b/scripts/verify_phase4_mvp_exit_manifest.py new file mode 100644 index 0000000..3fb0b63 --- /dev/null +++ b/scripts/verify_phase4_mvp_exit_manifest.py @@ -0,0 +1,302 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path + +ROOT_DIR = Path(__file__).resolve().parents[1] +DEFAULT_MANIFEST_PATH = ROOT_DIR / "artifacts" / "release" / "phase4_mvp_exit_manifest.json" +ARCHIVE_INDEX_VERSION = "phase4_rc_archive_index.v1" +MANIFEST_ARTIFACT_VERSION = "phase4_mvp_exit_manifest.v1" +RC_SUMMARY_ARTIFACT_VERSION = "phase4_rc_summary.v1" +REQUIRED_COMPATIBILITY_COMMANDS: tuple[str, ...] = ( + "python3 scripts/run_phase4_validation_matrix.py", + "python3 scripts/run_phase3_validation_matrix.py", + "python3 scripts/run_phase2_validation_matrix.py", + "python3 scripts/run_mvp_validation_matrix.py", +) + + +def _resolve_path(path_value: str) -> Path: + candidate = Path(path_value) + if candidate.is_absolute(): + return candidate + return ROOT_DIR / candidate + + +def _render_path(path: Path) -> str: + try: + return str(path.relative_to(ROOT_DIR)) + except ValueError: + return str(path) + + +def _load_json_object(path: Path) -> dict[str, object]: + payload = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise ValueError(f"{path}: payload must be a JSON object.") + return payload + + +def _sha256_for_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _extract_summary_step_status_by_id(summary_payload: dict[str, object]) -> dict[str, str]: + steps = summary_payload.get("steps") + if not isinstance(steps, list): + raise ValueError("archive summary steps must be a list.") + + step_status_by_id: dict[str, str] = {} + for step_payload in steps: + if not isinstance(step_payload, dict): + raise ValueError("archive summary steps[] entries must be JSON objects.") + step_id = step_payload.get("step") + status = step_payload.get("status") + if not isinstance(step_id, str) or not isinstance(status, str): + raise ValueError("archive summary steps[] must include string step and status fields.") + step_status_by_id[step_id] = status + return step_status_by_id + + +def verify_manifest(*, manifest_path: Path = DEFAULT_MANIFEST_PATH) -> list[str]: + errors: list[str] = [] + if not manifest_path.exists(): + return [f"MVP exit manifest not found: {manifest_path}"] + + try: + manifest_payload = _load_json_object(manifest_path) + except Exception as exc: # pragma: no cover - defensive parse surface + return [f"Failed to parse manifest {manifest_path}: {exc}"] + + if manifest_payload.get("artifact_version") != MANIFEST_ARTIFACT_VERSION: + errors.append( + f"manifest artifact_version must be {MANIFEST_ARTIFACT_VERSION!r}, " + f"got {manifest_payload.get('artifact_version')!r}." + ) + + expected_manifest_path_value = _render_path(manifest_path) + if manifest_payload.get("artifact_path") != expected_manifest_path_value: + errors.append( + "manifest artifact_path mismatch: " + f"expected {expected_manifest_path_value!r}, got {manifest_payload.get('artifact_path')!r}." + ) + + if manifest_payload.get("phase") != "phase4": + errors.append("manifest phase must be 'phase4'.") + if manifest_payload.get("release_gate") != "mvp": + errors.append("manifest release_gate must be 'mvp'.") + + decision = manifest_payload.get("decision") + if not isinstance(decision, dict): + errors.append("manifest decision must be a JSON object.") + else: + if decision.get("final_decision") != "GO": + errors.append("manifest decision.final_decision must be GO.") + if decision.get("summary_exit_code") != 0: + errors.append("manifest decision.summary_exit_code must be 0.") + if decision.get("failing_steps") != []: + errors.append("manifest decision.failing_steps must be [].") + + source_references = manifest_payload.get("source_references") + if not isinstance(source_references, dict): + errors.append("manifest source_references must be a JSON object.") + return errors + + index_path_value = source_references.get("archive_index_path") + archive_entry_index = source_references.get("archive_entry_index") + archive_artifact_path_value = source_references.get("archive_artifact_path") + archive_entry_created_at = source_references.get("archive_entry_created_at") + archive_entry_command_mode = source_references.get("archive_entry_command_mode") + + if not isinstance(index_path_value, str): + errors.append("manifest source_references.archive_index_path must be a string.") + return errors + if not isinstance(archive_entry_index, int) or isinstance(archive_entry_index, bool): + errors.append("manifest source_references.archive_entry_index must be an integer.") + return errors + if not isinstance(archive_artifact_path_value, str): + errors.append("manifest source_references.archive_artifact_path must be a string.") + return errors + if not isinstance(archive_entry_created_at, str): + errors.append("manifest source_references.archive_entry_created_at must be a string.") + if not isinstance(archive_entry_command_mode, str): + errors.append("manifest source_references.archive_entry_command_mode must be a string.") + + index_path = _resolve_path(index_path_value) + if not index_path.exists(): + errors.append(f"manifest source_references.archive_index_path missing file: {index_path_value}") + return errors + + archive_artifact_path = _resolve_path(archive_artifact_path_value) + if not archive_artifact_path.exists(): + errors.append( + "manifest source_references.archive_artifact_path missing file: " + f"{archive_artifact_path_value}" + ) + return errors + + try: + index_payload = _load_json_object(index_path) + except Exception as exc: # pragma: no cover - defensive parse surface + errors.append(f"failed to parse archive index {index_path}: {exc}") + return errors + + if index_payload.get("artifact_version") != ARCHIVE_INDEX_VERSION: + errors.append( + f"archive index artifact_version must be {ARCHIVE_INDEX_VERSION!r}, " + f"got {index_payload.get('artifact_version')!r}." + ) + + entries = index_payload.get("entries") + if not isinstance(entries, list): + errors.append("archive index entries must be a list.") + return errors + + if archive_entry_index < 0 or archive_entry_index >= len(entries): + errors.append( + "manifest source_references.archive_entry_index is out of range for archive index entries." + ) + return errors + + matched_entry = entries[archive_entry_index] + if not isinstance(matched_entry, dict): + errors.append( + "manifest source_references.archive_entry_index must reference a JSON object entry." + ) + return errors + + if matched_entry.get("archive_artifact_path") != archive_artifact_path_value: + errors.append( + "manifest source_references.archive_entry_index does not reference " + "archive_artifact_path in archive index." + ) + return errors + if matched_entry.get("final_decision") != "GO": + errors.append( + "manifest source archive artifact is not present as a GO entry in archive index: " + f"{archive_artifact_path_value}" + ) + return errors + + if archive_entry_created_at is not None and matched_entry.get("created_at") != archive_entry_created_at: + errors.append( + "manifest source_references.archive_entry_created_at mismatch with archive index entry." + ) + if ( + archive_entry_command_mode is not None + and matched_entry.get("command_mode") != archive_entry_command_mode + ): + errors.append( + "manifest source_references.archive_entry_command_mode mismatch with archive index entry." + ) + if matched_entry.get("summary_exit_code") != 0: + errors.append("manifest source archive index entry summary_exit_code must be 0 for GO evidence.") + if matched_entry.get("failing_steps") != []: + errors.append("manifest source archive index entry failing_steps must be [].") + + try: + summary_payload = _load_json_object(archive_artifact_path) + except Exception as exc: # pragma: no cover - defensive parse surface + errors.append(f"failed to parse source archive summary {archive_artifact_path}: {exc}") + return errors + + if summary_payload.get("artifact_version") != RC_SUMMARY_ARTIFACT_VERSION: + errors.append( + f"source archive summary artifact_version must be {RC_SUMMARY_ARTIFACT_VERSION!r}, " + f"got {summary_payload.get('artifact_version')!r}." + ) + if summary_payload.get("final_decision") != "GO": + errors.append("source archive summary final_decision must be GO.") + if summary_payload.get("summary_exit_code") != 0: + errors.append("source archive summary summary_exit_code must be 0.") + if summary_payload.get("failing_steps") != []: + errors.append("source archive summary failing_steps must be [].") + + ordered_steps = summary_payload.get("ordered_steps") + if not isinstance(ordered_steps, list) or not all(isinstance(step, str) for step in ordered_steps): + errors.append("source archive summary ordered_steps must be list[str].") + ordered_steps = None + + try: + summary_step_status_by_id = _extract_summary_step_status_by_id(summary_payload) + except ValueError as exc: + errors.append(str(exc)) + summary_step_status_by_id = {} + + if ordered_steps is not None: + expected_ordered_step_statuses = { + step_id: summary_step_status_by_id.get(step_id, "") for step_id in ordered_steps + } + if any(status != "PASS" for status in expected_ordered_step_statuses.values()): + errors.append("source archive summary GO evidence must have PASS for all ordered steps.") + else: + expected_ordered_step_statuses = {} + + manifest_ordered_steps = manifest_payload.get("ordered_steps") + if manifest_ordered_steps != ordered_steps: + errors.append("manifest ordered_steps must match source archive summary ordered_steps.") + + manifest_step_status_by_id = manifest_payload.get("step_status_by_id") + if manifest_step_status_by_id != expected_ordered_step_statuses: + errors.append("manifest step_status_by_id must match ordered source archive step statuses.") + + compatibility_commands = manifest_payload.get("compatibility_validation_commands") + if compatibility_commands != list(REQUIRED_COMPATIBILITY_COMMANDS): + errors.append( + "manifest compatibility_validation_commands must match required compatibility chain." + ) + + integrity_payload = manifest_payload.get("integrity") + if not isinstance(integrity_payload, dict): + errors.append("manifest integrity must be a JSON object.") + return errors + + expected_archive_hash = _sha256_for_file(archive_artifact_path) + if integrity_payload.get("archive_artifact_sha256") != expected_archive_hash: + errors.append( + "manifest integrity.archive_artifact_sha256 mismatch with source archive artifact." + ) + + return errors + + +def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Verify Phase 4 MVP exit manifest schema, required fields, and referenced " + "release-candidate GO evidence integrity." + ), + ) + parser.add_argument( + "--manifest-path", + default=str(DEFAULT_MANIFEST_PATH), + help="Path to MVP exit manifest JSON artifact.", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = _parse_args(argv) + manifest_path = Path(args.manifest_path) + + errors = verify_manifest(manifest_path=manifest_path) + if errors: + print("Phase 4 MVP exit manifest verification: FAIL") + for error in errors: + print(f" - {error}") + return 1 + + print("Phase 4 MVP exit manifest verification: PASS") + print(f"Validated manifest: {manifest_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_phase4_mvp_signoff_record.py b/scripts/verify_phase4_mvp_signoff_record.py new file mode 100644 index 0000000..94f84b6 --- /dev/null +++ b/scripts/verify_phase4_mvp_signoff_record.py @@ -0,0 +1,323 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +from datetime import datetime +import json +from pathlib import Path +import sys + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +import scripts.run_phase4_mvp_qualification as qualification + + +ROOT_DIR = qualification.ROOT_DIR +DEFAULT_SIGNOFF_PATH = qualification.DEFAULT_SIGNOFF_PATH +SIGNOFF_ARTIFACT_VERSION = qualification.SIGNOFF_ARTIFACT_VERSION +STEP_IDS = qualification.STEP_IDS +STEP_STATUS_VALUES = {"PASS", "FAIL", "NOT_RUN"} + +REQUIRED_REFERENCE_KEYS: tuple[str, ...] = ( + "release_candidate_summary_path", + "release_candidate_archive_index_path", + "mvp_exit_manifest_path", +) + + +def _resolve_path(path_value: str) -> Path: + candidate = Path(path_value) + if candidate.is_absolute(): + return candidate + return ROOT_DIR / candidate + + +def _render_path(path: Path) -> str: + try: + return str(path.relative_to(ROOT_DIR)) + except ValueError: + return str(path) + + +def _load_json_object(path: Path) -> dict[str, object]: + payload = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise ValueError(f"{path}: payload must be a JSON object.") + return payload + + +def _validate_generated_at(value: object, *, errors: list[str]) -> None: + if not isinstance(value, str): + errors.append("sign-off generated_at must be a UTC timestamp string.") + return + try: + datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ") + except ValueError: + errors.append("sign-off generated_at must use UTC format YYYY-MM-DDTHH:MM:SSZ.") + + +def verify_signoff_record( + *, + signoff_path: Path = DEFAULT_SIGNOFF_PATH, + expected_step_ids: tuple[str, ...] = STEP_IDS, + expected_required_references: dict[str, str] | None = None, +) -> list[str]: + errors: list[str] = [] + if not signoff_path.exists(): + return [f"MVP qualification sign-off record not found: {signoff_path}"] + + try: + payload = _load_json_object(signoff_path) + except Exception as exc: # pragma: no cover - defensive parse surface + return [f"Failed to parse sign-off record {signoff_path}: {exc}"] + + if payload.get("artifact_version") != SIGNOFF_ARTIFACT_VERSION: + errors.append( + f"sign-off artifact_version must be {SIGNOFF_ARTIFACT_VERSION!r}, " + f"got {payload.get('artifact_version')!r}." + ) + + expected_artifact_path = _render_path(signoff_path) + if payload.get("artifact_path") != expected_artifact_path: + errors.append( + "sign-off artifact_path mismatch: " + f"expected {expected_artifact_path!r}, got {payload.get('artifact_path')!r}." + ) + + if payload.get("phase") != "phase4": + errors.append("sign-off phase must be 'phase4'.") + if payload.get("release_gate") != "mvp": + errors.append("sign-off release_gate must be 'mvp'.") + + _validate_generated_at(payload.get("generated_at"), errors=errors) + + ordered_steps = payload.get("ordered_steps") + if ordered_steps != list(expected_step_ids): + errors.append("sign-off ordered_steps must match canonical qualification chain.") + + total_steps = payload.get("total_steps") + if total_steps != len(expected_step_ids): + errors.append(f"sign-off total_steps must be {len(expected_step_ids)}.") + + required_references = payload.get("required_references") + if not isinstance(required_references, dict): + errors.append("sign-off required_references must be a JSON object.") + return errors + + expected_references = expected_required_references or qualification.default_required_references() + for key in REQUIRED_REFERENCE_KEYS: + value = required_references.get(key) + if not isinstance(value, str): + errors.append(f"sign-off required_references.{key} must be a string.") + continue + expected_value = expected_references.get(key) + if expected_value is not None and value != expected_value: + errors.append( + f"sign-off required_references.{key} must be {expected_value!r}, got {value!r}." + ) + + steps_payload = payload.get("steps") + if not isinstance(steps_payload, list): + errors.append("sign-off steps must be a list.") + return errors + if len(steps_payload) != len(expected_step_ids): + errors.append("sign-off steps length must match canonical qualification chain.") + return errors + + failing_steps: list[str] = [] + not_run_steps: list[str] = [] + executed_steps = 0 + all_pass = True + non_pass_step_ids: set[str] = set() + step_status_by_id: dict[str, str] = {} + + for idx, step_payload in enumerate(steps_payload): + field_prefix = f"steps[{idx}]" + if not isinstance(step_payload, dict): + errors.append(f"{field_prefix} must be a JSON object.") + all_pass = False + continue + + expected_step_id = expected_step_ids[idx] + step_id = step_payload.get("step") + if step_id != expected_step_id: + errors.append( + f"{field_prefix}.step must be {expected_step_id!r}, got {step_id!r}." + ) + all_pass = False + continue + assert isinstance(step_id, str) + + status = step_payload.get("status") + if status not in STEP_STATUS_VALUES: + errors.append(f"{field_prefix}.status must be PASS, FAIL, or NOT_RUN.") + all_pass = False + continue + assert isinstance(status, str) + step_status_by_id[step_id] = status + + command = step_payload.get("command") + if not isinstance(command, list) or not command or not all(isinstance(item, str) for item in command): + errors.append(f"{field_prefix}.command must be non-empty list[str].") + + required_artifacts = step_payload.get("required_artifacts") + if not isinstance(required_artifacts, list) or not all( + isinstance(path_value, str) for path_value in required_artifacts + ): + errors.append(f"{field_prefix}.required_artifacts must be list[str].") + required_artifacts = [] + + missing_artifacts = step_payload.get("missing_artifacts") + if not isinstance(missing_artifacts, list) or not all( + isinstance(path_value, str) for path_value in missing_artifacts + ): + errors.append(f"{field_prefix}.missing_artifacts must be list[str].") + missing_artifacts = [] + + exit_code = step_payload.get("exit_code") + if status == "PASS": + executed_steps += 1 + if exit_code != 0: + errors.append(f"{field_prefix}.exit_code must be 0 for PASS status.") + if missing_artifacts: + errors.append(f"{field_prefix}.missing_artifacts must be empty for PASS status.") + for artifact_path_value in required_artifacts: + if not _resolve_path(artifact_path_value).exists(): + errors.append( + f"{field_prefix}.required_artifacts missing file: {artifact_path_value}" + ) + elif status == "FAIL": + executed_steps += 1 + all_pass = False + failing_steps.append(step_id) + non_pass_step_ids.add(step_id) + if not isinstance(exit_code, int) or isinstance(exit_code, bool): + errors.append(f"{field_prefix}.exit_code must be an integer for FAIL status.") + else: + all_pass = False + not_run_steps.append(step_id) + non_pass_step_ids.add(step_id) + if exit_code is not None: + errors.append(f"{field_prefix}.exit_code must be null for NOT_RUN status.") + + duration_seconds = step_payload.get("duration_seconds") + if not isinstance(duration_seconds, (float, int)): + errors.append(f"{field_prefix}.duration_seconds must be numeric.") + + if payload.get("executed_steps") != executed_steps: + errors.append("sign-off executed_steps must match count of non-NOT_RUN steps.") + + if payload.get("failing_steps") != failing_steps: + errors.append("sign-off failing_steps must match FAIL steps from sign-off steps[].") + if payload.get("not_run_steps") != not_run_steps: + errors.append("sign-off not_run_steps must match NOT_RUN steps from sign-off steps[].") + + final_decision = payload.get("final_decision") + summary_exit_code = payload.get("summary_exit_code") + blockers = payload.get("blockers") + if not isinstance(blockers, list): + errors.append("sign-off blockers must be a list.") + blockers = [] + + blocker_step_ids: set[str] = set() + for idx, blocker in enumerate(blockers): + field_prefix = f"blockers[{idx}]" + if not isinstance(blocker, dict): + errors.append(f"{field_prefix} must be a JSON object.") + continue + step_id = blocker.get("step") + reason = blocker.get("reason") + detail = blocker.get("detail") + if not isinstance(step_id, str): + errors.append(f"{field_prefix}.step must be a string.") + continue + blocker_step_ids.add(step_id) + if not isinstance(reason, str): + errors.append(f"{field_prefix}.reason must be a string.") + if not isinstance(detail, str): + errors.append(f"{field_prefix}.detail must be a string.") + + if all_pass: + if final_decision != "GO": + errors.append("sign-off final_decision must be GO when all steps PASS.") + if summary_exit_code != 0: + errors.append("sign-off summary_exit_code must be 0 when all steps PASS.") + if blockers: + errors.append("sign-off blockers must be empty when final_decision is GO.") + else: + if final_decision != "NO_GO": + errors.append("sign-off final_decision must be NO_GO when any step is non-PASS.") + if summary_exit_code != 1: + errors.append("sign-off summary_exit_code must be 1 when any step is non-PASS.") + if not blockers: + errors.append("sign-off blockers must be non-empty when final_decision is NO_GO.") + if not non_pass_step_ids.issubset(blocker_step_ids): + errors.append( + "sign-off blockers must include every non-PASS step as a blocker entry." + ) + + reference_path_by_key = { + "release_candidate_summary_path": required_references.get("release_candidate_summary_path"), + "release_candidate_archive_index_path": required_references.get( + "release_candidate_archive_index_path" + ), + "mvp_exit_manifest_path": required_references.get("mvp_exit_manifest_path"), + } + for key, path_value in reference_path_by_key.items(): + if not isinstance(path_value, str): + continue + should_exist = False + if key == "release_candidate_summary_path": + should_exist = step_status_by_id.get(qualification.STEP_RELEASE_CANDIDATE_REHEARSAL) == "PASS" + elif key == "release_candidate_archive_index_path": + should_exist = ( + step_status_by_id.get(qualification.STEP_RELEASE_CANDIDATE_REHEARSAL) == "PASS" + or step_status_by_id.get(qualification.STEP_RELEASE_CANDIDATE_ARCHIVE_VERIFY) == "PASS" + ) + elif key == "mvp_exit_manifest_path": + should_exist = ( + step_status_by_id.get(qualification.STEP_MVP_EXIT_MANIFEST_GENERATE) == "PASS" + or step_status_by_id.get(qualification.STEP_MVP_EXIT_MANIFEST_VERIFY) == "PASS" + ) + + if should_exist and not _resolve_path(path_value).exists(): + errors.append(f"sign-off required_references.{key} missing file: {path_value}") + + return errors + + +def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Verify Phase 4 MVP qualification sign-off record schema, references, and " + "GO/NO_GO consistency." + ), + ) + parser.add_argument( + "--signoff-path", + default=str(DEFAULT_SIGNOFF_PATH), + help="Path to MVP qualification sign-off JSON artifact.", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = _parse_args(argv) + signoff_path = Path(args.signoff_path) + + errors = verify_signoff_record(signoff_path=signoff_path) + if errors: + print("Phase 4 MVP sign-off record verification: FAIL") + for error in errors: + print(f" - {error}") + return 1 + + print("Phase 4 MVP sign-off record verification: PASS") + print(f"Validated sign-off record: {signoff_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_phase4_rc_archive.py b/scripts/verify_phase4_rc_archive.py new file mode 100644 index 0000000..55d1b05 --- /dev/null +++ b/scripts/verify_phase4_rc_archive.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +from datetime import datetime +import json +from pathlib import Path +from typing import Literal + + +ROOT_DIR = Path(__file__).resolve().parents[1] +DEFAULT_ARCHIVE_INDEX_PATH = ROOT_DIR / "artifacts" / "release" / "archive" / "index.json" +ARCHIVE_INDEX_NAME = "index.json" +ARCHIVE_INDEX_LOCK_NAME = "index.lock" +ARCHIVE_INDEX_VERSION = "phase4_rc_archive_index.v1" +SUMMARY_ARTIFACT_VERSION = "phase4_rc_summary.v1" +FinalDecision = Literal["GO", "NO_GO"] + + +def _resolve_path(path_value: str) -> Path: + candidate = Path(path_value) + if candidate.is_absolute(): + return candidate + return ROOT_DIR / candidate + + +def _load_json_object(path: Path) -> dict[str, object]: + payload = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise ValueError(f"{path}: payload must be a JSON object.") + return payload + + +def _validate_created_at(value: object, *, field_name: str, errors: list[str]) -> None: + if not isinstance(value, str): + errors.append(f"{field_name} must be a string.") + return + try: + datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ") + except ValueError: + errors.append(f"{field_name} must use UTC format YYYY-MM-DDTHH:MM:SSZ.") + + +def _validate_entry_command_mode( + *, + value: object, + ordered_steps: set[str], + field_prefix: str, + errors: list[str], +) -> None: + if not isinstance(value, str): + errors.append(f"{field_prefix}.command_mode must be a string.") + return + + if value == "default": + return + + if not value.startswith("induced_failure:"): + errors.append( + f"{field_prefix}.command_mode must be 'default' or 'induced_failure:<step_id>'." + ) + return + + induced_step = value.removeprefix("induced_failure:") + if induced_step not in ordered_steps: + errors.append( + f"{field_prefix}.command_mode references unknown induced step {induced_step!r}." + ) + + +def verify_archive_index(index_path: Path = DEFAULT_ARCHIVE_INDEX_PATH) -> list[str]: + errors: list[str] = [] + if not index_path.exists(): + return [f"Archive index not found: {index_path}"] + + try: + index_payload = _load_json_object(index_path) + except Exception as exc: # pragma: no cover - defensive surface + return [f"Failed to parse archive index {index_path}: {exc}"] + + if index_payload.get("artifact_version") != ARCHIVE_INDEX_VERSION: + errors.append( + f"archive index artifact_version must be {ARCHIVE_INDEX_VERSION!r}, " + f"got {index_payload.get('artifact_version')!r}." + ) + + latest_summary_path_value = index_payload.get("latest_summary_path") + if not isinstance(latest_summary_path_value, str): + errors.append("archive index latest_summary_path must be a string.") + else: + latest_summary_path = _resolve_path(latest_summary_path_value) + if not latest_summary_path.exists(): + errors.append( + f"archive index latest_summary_path does not exist: {latest_summary_path_value}" + ) + + archive_dir_value = index_payload.get("archive_dir") + archive_dir: Path | None = None + if not isinstance(archive_dir_value, str): + errors.append("archive index archive_dir must be a string.") + else: + archive_dir = _resolve_path(archive_dir_value) + if not archive_dir.exists(): + errors.append(f"archive index archive_dir does not exist: {archive_dir_value}") + else: + expected_index_path = (archive_dir / ARCHIVE_INDEX_NAME).resolve(strict=False) + if index_path.resolve(strict=False) != expected_index_path: + errors.append( + "archive index path must be archive_dir/index.json for deterministic " + "lock and atomic-write contract." + ) + lock_path = archive_dir / ARCHIVE_INDEX_LOCK_NAME + if lock_path.exists(): + errors.append( + f"archive index lock file should not persist after RC write completion: {lock_path}" + ) + + entries = index_payload.get("entries") + if not isinstance(entries, list): + errors.append("archive index entries must be a list.") + return errors + + seen_archive_paths: set[str] = set() + previous_created_at: str | None = None + for idx, entry in enumerate(entries): + field_prefix = f"entries[{idx}]" + if not isinstance(entry, dict): + errors.append(f"{field_prefix} must be a JSON object.") + continue + + _validate_created_at(entry.get("created_at"), field_name=f"{field_prefix}.created_at", errors=errors) + + archive_artifact_path_value = entry.get("archive_artifact_path") + if not isinstance(archive_artifact_path_value, str): + errors.append(f"{field_prefix}.archive_artifact_path must be a string.") + continue + + if archive_artifact_path_value in seen_archive_paths: + errors.append( + f"{field_prefix}.archive_artifact_path duplicates a prior index entry: " + f"{archive_artifact_path_value}" + ) + continue + seen_archive_paths.add(archive_artifact_path_value) + + archive_artifact_path = _resolve_path(archive_artifact_path_value) + if archive_dir is not None: + try: + archive_artifact_path.relative_to(archive_dir) + except ValueError: + errors.append( + f"{field_prefix}.archive_artifact_path is outside archive_dir: " + f"{archive_artifact_path_value}" + ) + + if not archive_artifact_path.exists(): + errors.append( + f"{field_prefix}.archive_artifact_path missing file: {archive_artifact_path_value}" + ) + continue + + try: + summary_payload = _load_json_object(archive_artifact_path) + except Exception as exc: # pragma: no cover - defensive surface + errors.append(f"{field_prefix} failed to parse archive summary: {exc}") + continue + + if summary_payload.get("artifact_version") != SUMMARY_ARTIFACT_VERSION: + errors.append( + f"{field_prefix} archive summary artifact_version must be " + f"{SUMMARY_ARTIFACT_VERSION!r}." + ) + + summary_artifact_path = summary_payload.get("artifact_path") + if summary_artifact_path != archive_artifact_path_value: + errors.append( + f"{field_prefix} archive summary artifact_path mismatch: " + f"expected {archive_artifact_path_value!r}, got {summary_artifact_path!r}." + ) + + final_decision = summary_payload.get("final_decision") + if final_decision not in {"GO", "NO_GO"}: + errors.append(f"{field_prefix} archive summary final_decision must be GO or NO_GO.") + continue + assert isinstance(final_decision, str) + typed_final_decision: FinalDecision = final_decision + + entry_final_decision = entry.get("final_decision") + if entry_final_decision != typed_final_decision: + errors.append( + f"{field_prefix}.final_decision mismatch with archive summary: " + f"{entry_final_decision!r} != {typed_final_decision!r}." + ) + + summary_exit_code = summary_payload.get("summary_exit_code") + entry_summary_exit_code = entry.get("summary_exit_code") + if entry_summary_exit_code != summary_exit_code: + errors.append( + f"{field_prefix}.summary_exit_code mismatch with archive summary: " + f"{entry_summary_exit_code!r} != {summary_exit_code!r}." + ) + + failing_steps = summary_payload.get("failing_steps") + entry_failing_steps = entry.get("failing_steps") + if entry_failing_steps != failing_steps: + errors.append( + f"{field_prefix}.failing_steps mismatch with archive summary: " + f"{entry_failing_steps!r} != {failing_steps!r}." + ) + + ordered_steps = summary_payload.get("ordered_steps") + if not isinstance(ordered_steps, list) or not all(isinstance(step, str) for step in ordered_steps): + errors.append(f"{field_prefix} archive summary ordered_steps must be list[str].") + ordered_steps_set: set[str] = set() + else: + ordered_steps_set = set(ordered_steps) + + _validate_entry_command_mode( + value=entry.get("command_mode"), + ordered_steps=ordered_steps_set, + field_prefix=field_prefix, + errors=errors, + ) + + if typed_final_decision == "GO": + if summary_exit_code != 0: + errors.append(f"{field_prefix} GO summary_exit_code must be 0.") + if failing_steps != []: + errors.append(f"{field_prefix} GO failing_steps must be [].") + else: + if summary_exit_code != 1: + errors.append(f"{field_prefix} NO_GO summary_exit_code must be 1.") + if not isinstance(failing_steps, list) or len(failing_steps) == 0: + errors.append(f"{field_prefix} NO_GO failing_steps must be non-empty.") + + created_at_value = entry.get("created_at") + if isinstance(created_at_value, str): + if previous_created_at is not None and created_at_value < previous_created_at: + errors.append( + f"{field_prefix}.created_at must be non-decreasing for append-only ordering." + ) + previous_created_at = created_at_value + + return errors + + +def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Validate Phase 4 release-candidate archive index schema and ensure each index " + "entry matches the retained archive summary artifact." + ), + ) + parser.add_argument( + "--index-path", + default=str(DEFAULT_ARCHIVE_INDEX_PATH), + help="Path to archive index JSON file.", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = _parse_args(argv) + index_path = Path(args.index_path) + + errors = verify_archive_index(index_path=index_path) + if errors: + print("Phase 4 RC archive verification: FAIL") + for error in errors: + print(f" - {error}") + return 1 + + print("Phase 4 RC archive verification: PASS") + print(f"Validated archive index: {index_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/.gitkeep b/tests/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/.gitkeep @@ -0,0 +1 @@ + diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..3cf7ef9 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from collections.abc import Iterator +import os +from urllib.parse import urlsplit, urlunsplit +from uuid import uuid4 + +from alembic import command +import psycopg +from psycopg import sql +import pytest + +import apps.api.src.alicebot_api.main as main_module +from alicebot_api.migrations import make_alembic_config + + +DEFAULT_ADMIN_URL = "postgresql://alicebot_admin:alicebot_admin@localhost:5432/alicebot" +DEFAULT_APP_URL = "postgresql://alicebot_app:alicebot_app@localhost:5432/alicebot" + + +def swap_database_name(database_url: str, database_name: str) -> str: + parsed = urlsplit(database_url) + return urlunsplit((parsed.scheme, parsed.netloc, f"/{database_name}", parsed.query, parsed.fragment)) + + +@pytest.fixture +def database_urls() -> Iterator[dict[str, str]]: + admin_root_url = os.getenv("DATABASE_ADMIN_URL", DEFAULT_ADMIN_URL) + app_root_url = os.getenv("DATABASE_URL", DEFAULT_APP_URL) + database_name = f"alicebot_test_{uuid4().hex[:12]}" + admin_database_url = swap_database_name(admin_root_url, database_name) + app_database_url = swap_database_name(app_root_url, database_name) + + with psycopg.connect(admin_root_url, autocommit=True) as conn: + with conn.cursor() as cur: + cur.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(database_name))) + cur.execute( + sql.SQL("GRANT CONNECT, TEMPORARY ON DATABASE {} TO alicebot_app").format( + sql.Identifier(database_name) + ) + ) + + yield {"admin": admin_database_url, "app": app_database_url} + + with psycopg.connect(admin_root_url, autocommit=True) as conn: + with conn.cursor() as cur: + cur.execute( + sql.SQL("DROP DATABASE IF EXISTS {} WITH (FORCE)").format(sql.Identifier(database_name)) + ) + + +@pytest.fixture +def migrated_database_urls(database_urls: dict[str, str]) -> Iterator[dict[str, str]]: + config = make_alembic_config(database_urls["admin"]) + command.upgrade(config, "head") + yield database_urls + + +@pytest.fixture(autouse=True) +def reset_response_rate_limiter_between_tests() -> Iterator[None]: + main_module.response_rate_limiter.reset() + main_module.entrypoint_rate_limiter.reset() + yield + main_module.response_rate_limiter.reset() + main_module.entrypoint_rate_limiter.reset() diff --git a/tests/integration/test_approval_api.py b/tests/integration/test_approval_api.py new file mode 100644 index 0000000..fb9995c --- /dev/null +++ b/tests/integration/test_approval_api.py @@ -0,0 +1,1130 @@ +from __future__ import annotations + +import json +from typing import Any +from urllib.parse import urlencode +from uuid import UUID, uuid4 + +import anyio + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.db import user_connection +from alicebot_api.store import ContinuityStore + + +def invoke_request( + method: str, + path: str, + *, + query_params: dict[str, str] | None = None, + payload: dict[str, Any] | None = None, +) -> tuple[int, dict[str, Any]]: + messages: list[dict[str, object]] = [] + encoded_body = b"" if payload is None else json.dumps(payload).encode() + request_received = False + + async def receive() -> dict[str, object]: + nonlocal request_received + if request_received: + return {"type": "http.disconnect"} + + request_received = True + return {"type": "http.request", "body": encoded_body, "more_body": False} + + async def send(message: dict[str, object]) -> None: + messages.append(message) + + query_string = urlencode(query_params or {}).encode() + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": query_string, + "headers": [(b"content-type", b"application/json")], + "client": ("testclient", 50000), + "server": ("testserver", 80), + "root_path": "", + } + + anyio.run(main_module.app, scope, receive, send) + + start_message = next(message for message in messages if message["type"] == "http.response.start") + body = b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + return start_message["status"], json.loads(body) + + +def seed_user( + database_url: str, + *, + email: str, + agent_profile_id: str = "assistant_default", +) -> dict[str, UUID]: + user_id = uuid4() + + with user_connection(database_url, user_id) as conn: + store = ContinuityStore(conn) + store.create_user(user_id, email, email.split("@", 1)[0].title()) + thread = store.create_thread("Approval thread", agent_profile_id=agent_profile_id) + + return { + "user_id": user_id, + "thread_id": thread["id"], + } + + +def test_approval_request_persists_record_for_approval_required_route( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_user(migrated_database_urls["app"], email="owner@example.com") + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + + with user_connection(migrated_database_urls["app"], seeded["user_id"]) as conn: + store = ContinuityStore(conn) + tool = store.create_tool( + tool_key="shell.exec", + name="Shell Exec", + description="Run shell commands.", + version="1.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["shell"], + action_hints=["tool.run"], + scope_hints=["workspace"], + domain_hints=[], + risk_hints=[], + metadata={"transport": "local"}, + ) + policy = store.create_policy( + name="Require shell approval", + action="tool.run", + scope="workspace", + effect="require_approval", + priority=10, + active=True, + conditions={"tool_key": "shell.exec"}, + required_consents=[], + ) + + status, payload = invoke_request( + "POST", + "/v0/approvals/requests", + payload={ + "user_id": str(seeded["user_id"]), + "thread_id": str(seeded["thread_id"]), + "tool_id": str(tool["id"]), + "action": "tool.run", + "scope": "workspace", + "attributes": {"command": "ls"}, + }, + ) + + assert status == 200 + assert list(payload) == [ + "request", + "decision", + "tool", + "reasons", + "task", + "approval", + "routing_trace", + "trace", + ] + assert payload["decision"] == "approval_required" + assert payload["task"]["status"] == "pending_approval" + assert payload["task"]["latest_approval_id"] == payload["approval"]["id"] + assert payload["task"]["latest_execution_id"] is None + assert payload["approval"] is not None + assert payload["approval"]["status"] == "pending" + assert payload["approval"]["task_step_id"] is not None + assert payload["approval"]["resolution"] is None + assert payload["approval"]["request"] == payload["request"] + assert payload["approval"]["tool"] == payload["tool"] + assert payload["approval"]["routing"] == { + "decision": "approval_required", + "reasons": payload["reasons"], + "trace": payload["routing_trace"], + } + assert payload["reasons"][-1] == { + "code": "policy_effect_require_approval", + "source": "policy", + "message": "Policy effect resolved the decision to 'require_approval'.", + "tool_id": str(tool["id"]), + "policy_id": str(policy["id"]), + "consent_key": None, + } + assert payload["routing_trace"]["trace_event_count"] == 3 + assert payload["trace"]["trace_event_count"] == 8 + + with user_connection(migrated_database_urls["app"], seeded["user_id"]) as conn: + store = ContinuityStore(conn) + approvals = store.list_approvals() + tasks = store.list_tasks() + task_steps = store.list_task_steps_for_task(tasks[0]["id"]) + approval_trace = store.get_trace(UUID(payload["trace"]["trace_id"])) + approval_trace_events = store.list_trace_events(UUID(payload["trace"]["trace_id"])) + + assert len(approvals) == 1 + assert len(tasks) == 1 + assert len(task_steps) == 1 + assert approvals[0]["id"] == UUID(payload["approval"]["id"]) + assert approvals[0]["task_step_id"] == task_steps[0]["id"] + assert tasks[0]["id"] == UUID(payload["task"]["id"]) + assert approval_trace["kind"] == "approval.request" + assert approval_trace["compiler_version"] == "approval_request_v0" + assert approval_trace["limits"] == { + "order": ["created_at_asc", "id_asc"], + "persisted": True, + } + assert [event["kind"] for event in approval_trace_events] == [ + "approval.request.request", + "approval.request.routing", + "approval.request.persisted", + "approval.request.summary", + "task.lifecycle.state", + "task.lifecycle.summary", + "task.step.lifecycle.state", + "task.step.lifecycle.summary", + ] + assert approval_trace_events[1]["payload"] == { + "decision": "approval_required", + "tool_id": str(tool["id"]), + "tool_key": "shell.exec", + "tool_version": "1.0.0", + "routing_trace_id": payload["routing_trace"]["trace_id"], + "routing_trace_event_count": 3, + "reasons": payload["reasons"], + } + assert approval_trace_events[4]["payload"] == { + "task_id": payload["task"]["id"], + "source": "approval_request", + "previous_status": None, + "current_status": "pending_approval", + "latest_approval_id": payload["approval"]["id"], + "latest_execution_id": None, + } + assert approval_trace_events[2]["payload"] == { + "approval_id": payload["approval"]["id"], + "task_step_id": payload["approval"]["task_step_id"], + "decision": "approval_required", + "persisted": True, + } + assert approval_trace_events[6]["payload"] == { + "task_id": payload["task"]["id"], + "task_step_id": str(task_steps[0]["id"]), + "source": "approval_request", + "sequence_no": 1, + "kind": "governed_request", + "previous_status": None, + "current_status": "created", + "trace": { + "trace_id": payload["trace"]["trace_id"], + "trace_kind": "approval.request", + }, + } + + +def test_approval_request_routing_excludes_profile_mismatched_policies( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_user( + migrated_database_urls["app"], + email="owner@example.com", + agent_profile_id="coach_default", + ) + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + + with user_connection(migrated_database_urls["app"], seeded["user_id"]) as conn: + store = ContinuityStore(conn) + tool = store.create_tool( + tool_key="shell.exec", + name="Shell Exec", + description="Run shell commands.", + version="1.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["shell"], + action_hints=["tool.run"], + scope_hints=["workspace"], + domain_hints=[], + risk_hints=[], + metadata={"transport": "local"}, + ) + mismatched = store.create_policy( + agent_profile_id="assistant_default", + name="Mismatched deny shell", + action="tool.run", + scope="workspace", + effect="deny", + priority=1, + active=True, + conditions={"tool_key": "shell.exec"}, + required_consents=[], + ) + global_allow = store.create_policy( + agent_profile_id=None, + name="Global allow shell", + action="tool.run", + scope="workspace", + effect="allow", + priority=10, + active=True, + conditions={"tool_key": "shell.exec"}, + required_consents=[], + ) + + status, payload = invoke_request( + "POST", + "/v0/approvals/requests", + payload={ + "user_id": str(seeded["user_id"]), + "thread_id": str(seeded["thread_id"]), + "tool_id": str(tool["id"]), + "action": "tool.run", + "scope": "workspace", + "attributes": {"command": "ls"}, + }, + ) + + assert status == 200 + assert payload["decision"] == "ready" + assert payload["task"]["status"] == "approved" + assert payload["approval"] is None + assert payload["reasons"][-1] == { + "code": "policy_effect_allow", + "source": "policy", + "message": "Policy effect resolved the decision to 'allow'.", + "tool_id": str(tool["id"]), + "policy_id": str(global_allow["id"]), + "consent_key": None, + } + + with user_connection(migrated_database_urls["app"], seeded["user_id"]) as conn: + store = ContinuityStore(conn) + approvals = store.list_approvals() + routing_trace = store.get_trace(UUID(payload["routing_trace"]["trace_id"])) + routing_events = store.list_trace_events(UUID(payload["routing_trace"]["trace_id"])) + + assert approvals == [] + assert routing_trace["limits"]["active_policy_count"] == 1 + assert routing_events[1]["payload"]["matched_policy_id"] == str(global_allow["id"]) + assert routing_events[1]["payload"]["matched_policy_id"] != str(mismatched["id"]) + + +def test_approval_request_does_not_create_records_for_ready_or_denied_routes( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_user(migrated_database_urls["app"], email="owner@example.com") + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + + with user_connection(migrated_database_urls["app"], seeded["user_id"]) as conn: + store = ContinuityStore(conn) + store.create_consent( + consent_key="web_access", + status="granted", + metadata={"source": "settings"}, + ) + ready_tool = store.create_tool( + tool_key="browser.open", + name="Browser Open", + description="Open documentation pages.", + version="1.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["browser"], + action_hints=["tool.run"], + scope_hints=["workspace"], + domain_hints=["docs"], + risk_hints=[], + metadata={"transport": "proxy"}, + ) + denied_tool = store.create_tool( + tool_key="calendar.read", + name="Calendar Read", + description="Read calendars.", + version="1.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["calendar"], + action_hints=["calendar.read"], + scope_hints=["calendar"], + domain_hints=[], + risk_hints=[], + metadata={}, + ) + store.create_policy( + name="Allow docs browser", + action="tool.run", + scope="workspace", + effect="allow", + priority=10, + active=True, + conditions={"tool_key": "browser.open", "domain_hint": "docs"}, + required_consents=["web_access"], + ) + + ready_status, ready_payload = invoke_request( + "POST", + "/v0/approvals/requests", + payload={ + "user_id": str(seeded["user_id"]), + "thread_id": str(seeded["thread_id"]), + "tool_id": str(ready_tool["id"]), + "action": "tool.run", + "scope": "workspace", + "domain_hint": "docs", + "attributes": {}, + }, + ) + denied_status, denied_payload = invoke_request( + "POST", + "/v0/approvals/requests", + payload={ + "user_id": str(seeded["user_id"]), + "thread_id": str(seeded["thread_id"]), + "tool_id": str(denied_tool["id"]), + "action": "tool.run", + "scope": "workspace", + "attributes": {}, + }, + ) + + assert ready_status == 200 + assert ready_payload["decision"] == "ready" + assert ready_payload["task"]["status"] == "approved" + assert ready_payload["approval"] is None + assert denied_status == 200 + assert denied_payload["decision"] == "denied" + assert denied_payload["task"]["status"] == "denied" + assert denied_payload["approval"] is None + + with user_connection(migrated_database_urls["app"], seeded["user_id"]) as conn: + store = ContinuityStore(conn) + approvals = store.list_approvals() + tasks = store.list_tasks() + ready_task_steps = store.list_task_steps_for_task(tasks[0]["id"]) + denied_task_steps = store.list_task_steps_for_task(tasks[1]["id"]) + + assert approvals == [] + assert [task["status"] for task in tasks] == ["approved", "denied"] + assert [task_step["status"] for task_step in ready_task_steps] == ["approved"] + assert [task_step["status"] for task_step in denied_task_steps] == ["denied"] + + +def test_approval_endpoints_list_and_detail_are_deterministic_and_user_scoped( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_user(migrated_database_urls["app"], email="owner@example.com") + intruder = seed_user(migrated_database_urls["app"], email="intruder@example.com") + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + store = ContinuityStore(conn) + first_tool = store.create_tool( + tool_key="shell.exec", + name="Shell Exec", + description="Run shell commands.", + version="1.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["shell"], + action_hints=["tool.run"], + scope_hints=["workspace"], + domain_hints=[], + risk_hints=[], + metadata={"transport": "local"}, + ) + second_tool = store.create_tool( + tool_key="shell.exec", + name="Shell Exec", + description="Run shell commands.", + version="2.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["shell"], + action_hints=["tool.run"], + scope_hints=["workspace"], + domain_hints=[], + risk_hints=[], + metadata={"transport": "local"}, + ) + store.create_policy( + name="Require shell approval", + action="tool.run", + scope="workspace", + effect="require_approval", + priority=10, + active=True, + conditions={"tool_key": "shell.exec"}, + required_consents=[], + ) + + first_status, first_payload = invoke_request( + "POST", + "/v0/approvals/requests", + payload={ + "user_id": str(owner["user_id"]), + "thread_id": str(owner["thread_id"]), + "tool_id": str(first_tool["id"]), + "action": "tool.run", + "scope": "workspace", + "attributes": {"command": "pwd"}, + }, + ) + second_status, second_payload = invoke_request( + "POST", + "/v0/approvals/requests", + payload={ + "user_id": str(owner["user_id"]), + "thread_id": str(owner["thread_id"]), + "tool_id": str(second_tool["id"]), + "action": "tool.run", + "scope": "workspace", + "attributes": {"command": "ls"}, + }, + ) + list_status, list_payload = invoke_request( + "GET", + "/v0/approvals", + query_params={"user_id": str(owner["user_id"])}, + ) + detail_status, detail_payload = invoke_request( + "GET", + f"/v0/approvals/{second_payload['approval']['id']}", + query_params={"user_id": str(owner['user_id'])}, + ) + isolated_list_status, isolated_list_payload = invoke_request( + "GET", + "/v0/approvals", + query_params={"user_id": str(intruder["user_id"])}, + ) + isolated_detail_status, isolated_detail_payload = invoke_request( + "GET", + f"/v0/approvals/{first_payload['approval']['id']}", + query_params={"user_id": str(intruder['user_id'])}, + ) + + assert first_status == 200 + assert second_status == 200 + assert list_status == 200 + assert [item["id"] for item in list_payload["items"]] == [ + first_payload["approval"]["id"], + second_payload["approval"]["id"], + ] + assert list_payload["summary"] == { + "total_count": 2, + "order": ["created_at_asc", "id_asc"], + } + assert detail_status == 200 + assert detail_payload == {"approval": second_payload["approval"]} + + assert isolated_list_status == 200 + assert isolated_list_payload == { + "items": [], + "summary": {"total_count": 0, "order": ["created_at_asc", "id_asc"]}, + } + assert isolated_detail_status == 404 + assert isolated_detail_payload == { + "detail": f"approval {first_payload['approval']['id']} was not found" + } + + +def test_approval_resolution_endpoints_update_reads_and_emit_trace( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_user(migrated_database_urls["app"], email="owner@example.com") + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + store = ContinuityStore(conn) + first_tool = store.create_tool( + tool_key="shell.exec", + name="Shell Exec", + description="Run shell commands.", + version="1.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["shell"], + action_hints=["tool.run"], + scope_hints=["workspace"], + domain_hints=[], + risk_hints=[], + metadata={"transport": "local"}, + ) + second_tool = store.create_tool( + tool_key="shell.exec", + name="Shell Exec", + description="Run shell commands.", + version="2.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["shell"], + action_hints=["tool.run"], + scope_hints=["workspace"], + domain_hints=[], + risk_hints=[], + metadata={"transport": "local"}, + ) + store.create_policy( + name="Require shell approval", + action="tool.run", + scope="workspace", + effect="require_approval", + priority=10, + active=True, + conditions={"tool_key": "shell.exec"}, + required_consents=[], + ) + + _, first_request_payload = invoke_request( + "POST", + "/v0/approvals/requests", + payload={ + "user_id": str(owner["user_id"]), + "thread_id": str(owner["thread_id"]), + "tool_id": str(first_tool["id"]), + "action": "tool.run", + "scope": "workspace", + "attributes": {"command": "pwd"}, + }, + ) + _, second_request_payload = invoke_request( + "POST", + "/v0/approvals/requests", + payload={ + "user_id": str(owner["user_id"]), + "thread_id": str(owner["thread_id"]), + "tool_id": str(second_tool["id"]), + "action": "tool.run", + "scope": "workspace", + "attributes": {"command": "ls"}, + }, + ) + approve_status, approve_payload = invoke_request( + "POST", + f"/v0/approvals/{first_request_payload['approval']['id']}/approve", + payload={"user_id": str(owner["user_id"])}, + ) + reject_status, reject_payload = invoke_request( + "POST", + f"/v0/approvals/{second_request_payload['approval']['id']}/reject", + payload={"user_id": str(owner['user_id'])}, + ) + list_status, list_payload = invoke_request( + "GET", + "/v0/approvals", + query_params={"user_id": str(owner["user_id"])}, + ) + detail_status, detail_payload = invoke_request( + "GET", + f"/v0/approvals/{second_request_payload['approval']['id']}", + query_params={"user_id": str(owner['user_id'])}, + ) + + assert approve_status == 200 + assert list(approve_payload) == ["approval", "trace"] + assert approve_payload["approval"]["status"] == "approved" + assert approve_payload["approval"]["task_step_id"] == first_request_payload["approval"]["task_step_id"] + assert approve_payload["approval"]["resolution"] is not None + assert approve_payload["trace"]["trace_event_count"] == 7 + + assert reject_status == 200 + assert list(reject_payload) == ["approval", "trace"] + assert reject_payload["approval"]["status"] == "rejected" + assert reject_payload["approval"]["task_step_id"] == second_request_payload["approval"]["task_step_id"] + assert reject_payload["approval"]["resolution"] is not None + assert reject_payload["trace"]["trace_event_count"] == 7 + + assert list_status == 200 + assert [item["id"] for item in list_payload["items"]] == [ + first_request_payload["approval"]["id"], + second_request_payload["approval"]["id"], + ] + assert [item["status"] for item in list_payload["items"]] == ["approved", "rejected"] + assert list_payload["summary"] == { + "total_count": 2, + "order": ["created_at_asc", "id_asc"], + } + assert detail_status == 200 + assert detail_payload == {"approval": reject_payload["approval"]} + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + store = ContinuityStore(conn) + approve_trace = store.get_trace(UUID(approve_payload["trace"]["trace_id"])) + approve_trace_events = store.list_trace_events(UUID(approve_payload["trace"]["trace_id"])) + reject_trace = store.get_trace(UUID(reject_payload["trace"]["trace_id"])) + reject_trace_events = store.list_trace_events(UUID(reject_payload["trace"]["trace_id"])) + + assert approve_trace["kind"] == "approval.resolve" + assert approve_trace["compiler_version"] == "approval_resolution_v0" + assert approve_trace["limits"] == { + "order": ["created_at_asc", "id_asc"], + "requested_action": "approve", + "outcome": "resolved", + } + assert [event["kind"] for event in approve_trace_events] == [ + "approval.resolution.request", + "approval.resolution.state", + "approval.resolution.summary", + "task.lifecycle.state", + "task.lifecycle.summary", + "task.step.lifecycle.state", + "task.step.lifecycle.summary", + ] + assert approve_trace_events[1]["payload"]["current_status"] == "approved" + assert approve_trace_events[1]["payload"]["task_step_id"] == first_request_payload["approval"]["task_step_id"] + assert approve_trace_events[1]["payload"]["resolved_by_user_id"] == str(owner["user_id"]) + + assert reject_trace["kind"] == "approval.resolve" + assert reject_trace["compiler_version"] == "approval_resolution_v0" + assert reject_trace["limits"] == { + "order": ["created_at_asc", "id_asc"], + "requested_action": "reject", + "outcome": "resolved", + } + assert [event["kind"] for event in reject_trace_events] == [ + "approval.resolution.request", + "approval.resolution.state", + "approval.resolution.summary", + "task.lifecycle.state", + "task.lifecycle.summary", + "task.step.lifecycle.state", + "task.step.lifecycle.summary", + ] + assert reject_trace_events[1]["payload"]["current_status"] == "rejected" + assert reject_trace_events[1]["payload"]["task_step_id"] == second_request_payload["approval"]["task_step_id"] + assert reject_trace_events[1]["payload"]["resolved_by_user_id"] == str(owner["user_id"]) + + +def test_approval_resolution_does_not_reopen_cancelled_linked_task_run( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_user(migrated_database_urls["app"], email="owner-cancelled-run@example.com") + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + store = ContinuityStore(conn) + tool = store.create_tool( + tool_key="shell.exec", + name="Shell Exec", + description="Run shell commands.", + version="1.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["shell"], + action_hints=["tool.run"], + scope_hints=["workspace"], + domain_hints=[], + risk_hints=[], + metadata={"transport": "local"}, + ) + store.create_policy( + name="Require shell approval", + action="tool.run", + scope="workspace", + effect="require_approval", + priority=10, + active=True, + conditions={"tool_key": "shell.exec"}, + required_consents=[], + ) + + create_status, create_payload = invoke_request( + "POST", + "/v0/approvals/requests", + payload={ + "user_id": str(owner["user_id"]), + "thread_id": str(owner["thread_id"]), + "tool_id": str(tool["id"]), + "action": "tool.run", + "scope": "workspace", + "attributes": {"command": "ls"}, + }, + ) + assert create_status == 200 + task_id = create_payload["task"]["id"] + approval_id = create_payload["approval"]["id"] + + run_create_status, run_create_payload = invoke_request( + "POST", + f"/v0/tasks/{task_id}/runs", + payload={ + "user_id": str(owner["user_id"]), + "max_ticks": 3, + "checkpoint": { + "cursor": 0, + "target_steps": 1, + "wait_for_signal": False, + }, + }, + ) + assert run_create_status == 201 + run_id = run_create_payload["task_run"]["id"] + + run_tick_status, run_tick_payload = invoke_request( + "POST", + f"/v0/task-runs/{run_id}/tick", + payload={"user_id": str(owner["user_id"])}, + ) + assert run_tick_status == 200 + assert run_tick_payload["task_run"]["status"] == "waiting_approval" + + run_cancel_status, run_cancel_payload = invoke_request( + "POST", + f"/v0/task-runs/{run_id}/cancel", + payload={"user_id": str(owner["user_id"])}, + ) + assert run_cancel_status == 200 + assert run_cancel_payload["task_run"]["status"] == "cancelled" + assert run_cancel_payload["task_run"]["stop_reason"] == "cancelled" + + approve_status, approve_payload = invoke_request( + "POST", + f"/v0/approvals/{approval_id}/approve", + payload={"user_id": str(owner["user_id"])}, + ) + assert approve_status == 200 + assert approve_payload["approval"]["status"] == "approved" + assert approve_payload["trace"]["trace_event_count"] == 7 + + run_detail_status, run_detail_payload = invoke_request( + "GET", + f"/v0/task-runs/{run_id}", + query_params={"user_id": str(owner["user_id"])}, + ) + assert run_detail_status == 200 + assert run_detail_payload["task_run"]["status"] == "cancelled" + assert run_detail_payload["task_run"]["stop_reason"] == "cancelled" + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + store = ContinuityStore(conn) + trace_events = store.list_trace_events(UUID(approve_payload["trace"]["trace_id"])) + + assert "approval.resolution.run" not in [event["kind"] for event in trace_events] + + +def test_approval_resolution_rejects_duplicate_conflicting_and_cross_user_attempts( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_user(migrated_database_urls["app"], email="owner@example.com") + intruder = seed_user(migrated_database_urls["app"], email="intruder@example.com") + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + store = ContinuityStore(conn) + tool = store.create_tool( + tool_key="shell.exec", + name="Shell Exec", + description="Run shell commands.", + version="1.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["shell"], + action_hints=["tool.run"], + scope_hints=["workspace"], + domain_hints=[], + risk_hints=[], + metadata={"transport": "local"}, + ) + store.create_policy( + name="Require shell approval", + action="tool.run", + scope="workspace", + effect="require_approval", + priority=10, + active=True, + conditions={"tool_key": "shell.exec"}, + required_consents=[], + ) + + _, request_payload = invoke_request( + "POST", + "/v0/approvals/requests", + payload={ + "user_id": str(owner["user_id"]), + "thread_id": str(owner["thread_id"]), + "tool_id": str(tool["id"]), + "action": "tool.run", + "scope": "workspace", + "attributes": {"command": "ls"}, + }, + ) + approval_id = request_payload["approval"]["id"] + + first_approve_status, _ = invoke_request( + "POST", + f"/v0/approvals/{approval_id}/approve", + payload={"user_id": str(owner["user_id"])}, + ) + duplicate_status, duplicate_payload = invoke_request( + "POST", + f"/v0/approvals/{approval_id}/approve", + payload={"user_id": str(owner["user_id"])}, + ) + conflict_status, conflict_payload = invoke_request( + "POST", + f"/v0/approvals/{approval_id}/reject", + payload={"user_id": str(owner["user_id"])}, + ) + intruder_status, intruder_payload = invoke_request( + "POST", + f"/v0/approvals/{approval_id}/reject", + payload={"user_id": str(intruder["user_id"])}, + ) + + assert first_approve_status == 200 + assert duplicate_status == 409 + assert duplicate_payload == {"detail": f"approval {approval_id} was already approved"} + assert conflict_status == 409 + assert conflict_payload == { + "detail": f"approval {approval_id} was already approved and cannot be rejected" + } + assert intruder_status == 404 + assert intruder_payload == {"detail": f"approval {approval_id} was not found"} + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + store = ContinuityStore(conn) + approval = store.get_approval_optional(UUID(approval_id)) + with conn.cursor() as cur: + cur.execute( + """ + SELECT id, limits + FROM traces + WHERE thread_id = %s + AND kind = 'approval.resolve' + ORDER BY created_at ASC, id ASC + """, + (owner["thread_id"],), + ) + trace_rows = cur.fetchall() + duplicate_trace = trace_rows[-2] + conflict_trace = trace_rows[-1] + duplicate_events = store.list_trace_events(duplicate_trace["id"]) + conflict_events = store.list_trace_events(conflict_trace["id"]) + + assert approval is not None + assert approval["status"] == "approved" + assert duplicate_trace["limits"] == { + "order": ["created_at_asc", "id_asc"], + "requested_action": "approve", + "outcome": "duplicate_rejected", + } + assert [event["kind"] for event in duplicate_events] == [ + "approval.resolution.request", + "approval.resolution.state", + "approval.resolution.summary", + "task.lifecycle.state", + "task.lifecycle.summary", + "task.step.lifecycle.state", + "task.step.lifecycle.summary", + ] + assert duplicate_events[1]["payload"] == { + "approval_id": approval_id, + "task_step_id": str(approval["task_step_id"]), + "requested_action": "approve", + "previous_status": "approved", + "outcome": "duplicate_rejected", + "current_status": "approved", + "resolved_at": approval["resolved_at"].isoformat(), + "resolved_by_user_id": str(owner["user_id"]), + } + assert conflict_trace["limits"] == { + "order": ["created_at_asc", "id_asc"], + "requested_action": "reject", + "outcome": "conflict_rejected", + } + assert [event["kind"] for event in conflict_events] == [ + "approval.resolution.request", + "approval.resolution.state", + "approval.resolution.summary", + "task.lifecycle.state", + "task.lifecycle.summary", + "task.step.lifecycle.state", + "task.step.lifecycle.summary", + ] + assert conflict_events[1]["payload"] == { + "approval_id": approval_id, + "task_step_id": str(approval["task_step_id"]), + "requested_action": "reject", + "previous_status": "approved", + "outcome": "conflict_rejected", + "current_status": "approved", + "resolved_at": approval["resolved_at"].isoformat(), + "resolved_by_user_id": str(owner["user_id"]), + } + + +def test_approval_resolution_rejects_inconsistent_linkage_without_mutating_task_steps( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_user(migrated_database_urls["app"], email="owner-boundary@example.com") + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + store = ContinuityStore(conn) + tool = store.create_tool( + tool_key="proxy.echo", + name="Proxy Echo", + description="Deterministic proxy handler.", + version="1.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["proxy"], + action_hints=["tool.run"], + scope_hints=["workspace"], + domain_hints=[], + risk_hints=[], + metadata={"transport": "proxy"}, + ) + store.create_policy( + name="Require proxy approval", + action="tool.run", + scope="workspace", + effect="require_approval", + priority=10, + active=True, + conditions={"tool_key": "proxy.echo"}, + required_consents=[], + ) + + _, request_payload = invoke_request( + "POST", + "/v0/approvals/requests", + payload={ + "user_id": str(owner["user_id"]), + "thread_id": str(owner["thread_id"]), + "tool_id": str(tool["id"]), + "action": "tool.run", + "scope": "workspace", + "attributes": {"message": "initial"}, + }, + ) + approve_status, approve_payload = invoke_request( + "POST", + f"/v0/approvals/{request_payload['approval']['id']}/approve", + payload={"user_id": str(owner["user_id"])}, + ) + assert approve_status == 200 + execute_status, execute_payload = invoke_request( + "POST", + f"/v0/approvals/{request_payload['approval']['id']}/execute", + payload={"user_id": str(owner["user_id"])}, + ) + assert execute_status == 200 + + detail_status, detail_payload = invoke_request( + "GET", + f"/v0/tasks/{request_payload['task']['id']}", + query_params={"user_id": str(owner["user_id"])}, + ) + step_list_status, step_list_payload = invoke_request( + "GET", + f"/v0/tasks/{request_payload['task']['id']}/steps", + query_params={"user_id": str(owner["user_id"])}, + ) + assert detail_status == 200 + assert step_list_status == 200 + initial_execution_id = detail_payload["task"]["latest_execution_id"] + assert initial_execution_id is not None + + create_step_status, create_step_payload = invoke_request( + "POST", + f"/v0/tasks/{request_payload['task']['id']}/steps", + payload={ + "user_id": str(owner["user_id"]), + "kind": "governed_request", + "status": "created", + "request": { + "thread_id": str(owner["thread_id"]), + "tool_id": str(tool["id"]), + "action": "tool.run", + "scope": "workspace", + "attributes": {"message": "step-2"}, + }, + "outcome": { + "routing_decision": "approval_required", + "approval_status": None, + "approval_id": None, + "execution_id": None, + "execution_status": None, + "blocked_reason": None, + }, + "lineage": { + "parent_step_id": step_list_payload["items"][0]["id"], + "source_approval_id": request_payload["approval"]["id"], + "source_execution_id": initial_execution_id, + }, + }, + ) + assert create_step_status == 201 + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + conn.execute( + "UPDATE approvals SET task_step_id = %s WHERE id = %s", + ( + create_step_payload["task_step"]["id"], + request_payload["approval"]["id"], + ), + ) + + boundary_status, boundary_payload = invoke_request( + "POST", + f"/v0/approvals/{request_payload['approval']['id']}/approve", + payload={"user_id": str(owner["user_id"])}, + ) + + assert boundary_status == 409 + assert boundary_payload == { + "detail": ( + f"approval {request_payload['approval']['id']} is inconsistent with linked task step " + f"{create_step_payload['task_step']['id']}" + ) + } + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + store = ContinuityStore(conn) + task = store.get_task_optional(UUID(request_payload["task"]["id"])) + task_steps = store.list_task_steps_for_task(UUID(request_payload["task"]["id"])) + approval = store.get_approval_optional(UUID(request_payload["approval"]["id"])) + approval_resolve_traces = store.conn.execute( + """ + SELECT id + FROM traces + WHERE thread_id = %s + AND kind = 'approval.resolve' + ORDER BY created_at ASC, id ASC + """, + (owner["thread_id"],), + ).fetchall() + + assert task is not None + assert approval is not None + assert task["status"] == "pending_approval" + assert task["latest_approval_id"] == UUID(request_payload["approval"]["id"]) + assert task["latest_execution_id"] is None + assert len(task_steps) == 2 + assert task_steps[0]["status"] == "executed" + assert task_steps[0]["trace_id"] == UUID(execute_payload["trace"]["trace_id"]) + assert task_steps[0]["outcome"]["execution_id"] == initial_execution_id + assert task_steps[1]["status"] == "created" + assert task_steps[1]["id"] == UUID(create_step_payload["task_step"]["id"]) + assert task_steps[1]["trace_kind"] == "task.step.continuation" + assert approval["status"] == "approved" + assert approval["task_step_id"] == UUID(create_step_payload["task_step"]["id"]) + assert len(approval_resolve_traces) == 1 diff --git a/tests/integration/test_calendar_accounts_api.py b/tests/integration/test_calendar_accounts_api.py new file mode 100644 index 0000000..ce018b3 --- /dev/null +++ b/tests/integration/test_calendar_accounts_api.py @@ -0,0 +1,732 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any +from urllib.parse import urlencode +from uuid import UUID, uuid4 + +import anyio +import psycopg + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +import alicebot_api.calendar as calendar_module +from alicebot_api.db import user_connection +from alicebot_api.store import ContinuityStore + + +def invoke_request( + method: str, + path: str, + *, + query_params: dict[str, str] | None = None, + payload: dict[str, Any] | None = None, +) -> tuple[int, dict[str, Any]]: + messages: list[dict[str, object]] = [] + encoded_body = b"" if payload is None else json.dumps(payload).encode() + request_received = False + + async def receive() -> dict[str, object]: + nonlocal request_received + if request_received: + return {"type": "http.disconnect"} + + request_received = True + return {"type": "http.request", "body": encoded_body, "more_body": False} + + async def send(message: dict[str, object]) -> None: + messages.append(message) + + query_string = urlencode(query_params or {}).encode() + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": query_string, + "headers": [(b"content-type", b"application/json")], + "client": ("testclient", 50000), + "server": ("testserver", 80), + "root_path": "", + } + + anyio.run(main_module.app, scope, receive, send) + + start_message = next(message for message in messages if message["type"] == "http.response.start") + body = b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + return start_message["status"], json.loads(body) + + +def _build_calendar_secret_manager_url(root: Path) -> str: + return root.resolve().as_uri() + + +def seed_user(database_url: str, *, email: str) -> dict[str, UUID]: + user_id = uuid4() + + with user_connection(database_url, user_id) as conn: + store = ContinuityStore(conn) + store.create_user(user_id, email, email.split("@", 1)[0].title()) + + return {"user_id": user_id} + + +def seed_task(database_url: str, *, email: str) -> dict[str, UUID]: + user_id = uuid4() + + with user_connection(database_url, user_id) as conn: + store = ContinuityStore(conn) + store.create_user(user_id, email, email.split("@", 1)[0].title()) + thread = store.create_thread("Calendar thread") + tool = store.create_tool( + tool_key="proxy.echo", + name="Proxy Echo", + description="Deterministic proxy handler.", + version="1.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["proxy"], + action_hints=["tool.run"], + scope_hints=["workspace"], + domain_hints=[], + risk_hints=[], + metadata={"transport": "proxy"}, + ) + task = store.create_task( + thread_id=thread["id"], + tool_id=tool["id"], + status="approved", + request={ + "thread_id": str(thread["id"]), + "tool_id": str(tool["id"]), + "action": "tool.run", + "scope": "workspace", + "domain_hint": None, + "risk_hint": None, + "attributes": {}, + }, + tool={ + "id": str(tool["id"]), + "tool_key": "proxy.echo", + "name": "Proxy Echo", + "description": "Deterministic proxy handler.", + "version": "1.0.0", + "metadata_version": "tool_metadata_v0", + "active": True, + "tags": ["proxy"], + "action_hints": ["tool.run"], + "scope_hints": ["workspace"], + "domain_hints": [], + "risk_hints": [], + "metadata": {"transport": "proxy"}, + "created_at": tool["created_at"].isoformat(), + }, + latest_approval_id=None, + latest_execution_id=None, + ) + + return { + "user_id": user_id, + "task_id": task["id"], + } + + +def _connect_calendar_account( + *, + user_id: UUID, + provider_account_id: str, + email_address: str, +) -> tuple[int, dict[str, Any]]: + return invoke_request( + "POST", + "/v0/calendar-accounts", + payload={ + "user_id": str(user_id), + "provider_account_id": provider_account_id, + "email_address": email_address, + "display_name": email_address.split("@", 1)[0].title(), + "scope": "https://www.googleapis.com/auth/calendar.readonly", + "access_token": f"token-for-{provider_account_id}", + }, + ) + + +def test_calendar_account_endpoints_connect_list_detail_and_isolate( + migrated_database_urls, + monkeypatch, + tmp_path, +) -> None: + owner = seed_user(migrated_database_urls["app"], email="owner@example.com") + intruder = seed_user(migrated_database_urls["app"], email="intruder@example.com") + calendar_secret_root = tmp_path / "calendar-secrets" + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + calendar_secret_manager_url=_build_calendar_secret_manager_url(calendar_secret_root), + ), + ) + + create_status, create_payload = _connect_calendar_account( + user_id=owner["user_id"], + provider_account_id="acct-owner-001", + email_address="owner@gmail.example", + ) + list_status, list_payload = invoke_request( + "GET", + "/v0/calendar-accounts", + query_params={"user_id": str(owner["user_id"])}, + ) + detail_status, detail_payload = invoke_request( + "GET", + f"/v0/calendar-accounts/{create_payload['account']['id']}", + query_params={"user_id": str(owner["user_id"])}, + ) + duplicate_status, duplicate_payload = _connect_calendar_account( + user_id=owner["user_id"], + provider_account_id="acct-owner-001", + email_address="owner@gmail.example", + ) + isolated_list_status, isolated_list_payload = invoke_request( + "GET", + "/v0/calendar-accounts", + query_params={"user_id": str(intruder["user_id"])}, + ) + isolated_detail_status, isolated_detail_payload = invoke_request( + "GET", + f"/v0/calendar-accounts/{create_payload['account']['id']}", + query_params={"user_id": str(intruder["user_id"])}, + ) + + assert create_status == 201 + assert create_payload == { + "account": { + "id": create_payload["account"]["id"], + "provider": "google_calendar", + "auth_kind": "oauth_access_token", + "provider_account_id": "acct-owner-001", + "email_address": "owner@gmail.example", + "display_name": "Owner", + "scope": "https://www.googleapis.com/auth/calendar.readonly", + "created_at": create_payload["account"]["created_at"], + "updated_at": create_payload["account"]["updated_at"], + } + } + assert list_status == 200 + assert list_payload == { + "items": [create_payload["account"]], + "summary": {"total_count": 1, "order": ["created_at_asc", "id_asc"]}, + } + assert detail_status == 200 + assert detail_payload == {"account": create_payload["account"]} + assert duplicate_status == 409 + assert duplicate_payload == {"detail": "calendar account acct-owner-001 is already connected"} + assert isolated_list_status == 200 + assert isolated_list_payload == { + "items": [], + "summary": {"total_count": 0, "order": ["created_at_asc", "id_asc"]}, + } + assert isolated_detail_status == 404 + assert isolated_detail_payload == { + "detail": f"calendar account {create_payload['account']['id']} was not found" + } + assert '"access_token":' not in json.dumps(create_payload) + assert '"access_token":' not in json.dumps(list_payload) + assert '"access_token":' not in json.dumps(detail_payload) + + with psycopg.connect(migrated_database_urls["admin"]) as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT + auth_kind, + credential_kind, + secret_manager_kind, + secret_ref, + credential_blob IS NULL + FROM calendar_account_credentials + WHERE calendar_account_id = %s + """, + (UUID(create_payload["account"]["id"]),), + ) + credential_row = cur.fetchone() + + assert credential_row is not None + assert credential_row[0] == "oauth_access_token" + assert credential_row[1] == "calendar_oauth_access_token_v1" + assert credential_row[2] == "file_v1" + assert credential_row[4] is True + assert credential_row[3] is not None + secret_payload = json.loads((calendar_secret_root / credential_row[3]).read_text(encoding="utf-8")) + assert secret_payload == { + "credential_kind": "calendar_oauth_access_token_v1", + "access_token": "token-for-acct-owner-001", + } + + +def test_calendar_event_list_endpoint_is_deterministic_and_limit_bounded( + migrated_database_urls, + monkeypatch, + tmp_path, +) -> None: + owner = seed_user(migrated_database_urls["app"], email="owner@example.com") + calendar_secret_root = tmp_path / "calendar-secrets" + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + calendar_secret_manager_url=_build_calendar_secret_manager_url(calendar_secret_root), + ), + ) + monkeypatch.setattr( + calendar_module, + "fetch_calendar_event_list_payload", + lambda **_kwargs: [ + { + "id": "evt-c", + "summary": "Third", + "status": "confirmed", + "start": {"dateTime": "2026-03-25T10:00:00+02:00"}, + "end": {"dateTime": "2026-03-25T10:30:00+02:00"}, + "htmlLink": "https://calendar.google.com/event?eid=evt-c", + "updated": "2026-03-24T09:00:00+00:00", + }, + { + "id": "evt-a", + "summary": "First", + "status": "tentative", + "start": {"date": "2026-03-20"}, + "end": {"date": "2026-03-21"}, + "updated": "2026-03-19T09:00:00+00:00", + }, + { + "id": "evt-b", + "summary": "Second", + "status": "confirmed", + "start": {"dateTime": "2026-03-25T08:30:00+00:00"}, + "end": {"dateTime": "2026-03-25T09:15:00+00:00"}, + "updated": "2026-03-24T08:30:00+00:00", + }, + ], + ) + + _, account_payload = _connect_calendar_account( + user_id=owner["user_id"], + provider_account_id="acct-owner-001", + email_address="owner@gmail.example", + ) + status, payload = invoke_request( + "GET", + f"/v0/calendar-accounts/{account_payload['account']['id']}/events", + query_params={ + "user_id": str(owner["user_id"]), + "limit": "2", + "time_min": "2026-03-20T00:00:00+00:00", + "time_max": "2026-03-27T00:00:00+00:00", + }, + ) + tighter_status, tighter_payload = invoke_request( + "GET", + f"/v0/calendar-accounts/{account_payload['account']['id']}/events", + query_params={ + "user_id": str(owner["user_id"]), + "limit": "1", + }, + ) + + assert status == 200 + assert payload == { + "account": account_payload["account"], + "items": [ + { + "provider_event_id": "evt-a", + "status": "tentative", + "summary": "First", + "start_time": "2026-03-20", + "end_time": "2026-03-21", + "html_link": None, + "updated_at": "2026-03-19T09:00:00+00:00", + }, + { + "provider_event_id": "evt-c", + "status": "confirmed", + "summary": "Third", + "start_time": "2026-03-25T10:00:00+02:00", + "end_time": "2026-03-25T10:30:00+02:00", + "html_link": "https://calendar.google.com/event?eid=evt-c", + "updated_at": "2026-03-24T09:00:00+00:00", + }, + ], + "summary": { + "total_count": 2, + "limit": 2, + "order": ["start_time_asc", "provider_event_id_asc"], + "time_min": "2026-03-20T00:00:00+00:00", + "time_max": "2026-03-27T00:00:00+00:00", + }, + } + assert tighter_status == 200 + assert tighter_payload["summary"]["limit"] == 1 + assert tighter_payload["summary"]["total_count"] == 1 + assert len(tighter_payload["items"]) == 1 + + +def test_calendar_event_list_endpoint_isolates_users_and_handles_missing_accounts( + migrated_database_urls, + monkeypatch, + tmp_path, +) -> None: + owner = seed_user(migrated_database_urls["app"], email="owner@example.com") + intruder = seed_user(migrated_database_urls["app"], email="intruder@example.com") + calendar_secret_root = tmp_path / "calendar-secrets" + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + calendar_secret_manager_url=_build_calendar_secret_manager_url(calendar_secret_root), + ), + ) + monkeypatch.setattr( + calendar_module, + "fetch_calendar_event_list_payload", + lambda **_kwargs: [], + ) + + _, account_payload = _connect_calendar_account( + user_id=owner["user_id"], + provider_account_id="acct-owner-001", + email_address="owner@gmail.example", + ) + isolated_status, isolated_payload = invoke_request( + "GET", + f"/v0/calendar-accounts/{account_payload['account']['id']}/events", + query_params={"user_id": str(intruder["user_id"])}, + ) + missing_status, missing_payload = invoke_request( + "GET", + f"/v0/calendar-accounts/{uuid4()}/events", + query_params={"user_id": str(owner["user_id"])}, + ) + + assert isolated_status == 404 + assert isolated_payload == { + "detail": f"calendar account {account_payload['account']['id']} was not found" + } + assert missing_status == 404 + assert missing_payload["detail"].endswith("was not found") + + +def test_calendar_event_list_endpoint_maps_credential_fetch_and_validation_failures( + migrated_database_urls, + monkeypatch, + tmp_path, +) -> None: + owner = seed_user(migrated_database_urls["app"], email="owner@example.com") + calendar_secret_root = tmp_path / "calendar-secrets" + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + calendar_secret_manager_url=_build_calendar_secret_manager_url(calendar_secret_root), + ), + ) + + _, account_payload = _connect_calendar_account( + user_id=owner["user_id"], + provider_account_id="acct-owner-001", + email_address="owner@gmail.example", + ) + account_id = account_payload["account"]["id"] + + monkeypatch.setattr( + calendar_module, + "resolve_calendar_access_token", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + calendar_module.CalendarCredentialNotFoundError( + f"calendar account {account_id} is missing protected credentials" + ) + ), + ) + credential_status, credential_payload = invoke_request( + "GET", + f"/v0/calendar-accounts/{account_id}/events", + query_params={"user_id": str(owner["user_id"])}, + ) + assert credential_status == 409 + assert credential_payload == { + "detail": f"calendar account {account_id} is missing protected credentials" + } + + monkeypatch.setattr( + calendar_module, + "resolve_calendar_access_token", + lambda *_args, **_kwargs: "token-for-acct-owner-001", + ) + monkeypatch.setattr( + calendar_module, + "fetch_calendar_event_list_payload", + lambda **_kwargs: (_ for _ in ()).throw( + calendar_module.CalendarEventFetchError("calendar events could not be fetched") + ), + ) + fetch_status, fetch_payload = invoke_request( + "GET", + f"/v0/calendar-accounts/{account_id}/events", + query_params={"user_id": str(owner["user_id"])}, + ) + assert fetch_status == 502 + assert fetch_payload == {"detail": "calendar events could not be fetched"} + + invalid_window_status, invalid_window_payload = invoke_request( + "GET", + f"/v0/calendar-accounts/{account_id}/events", + query_params={ + "user_id": str(owner["user_id"]), + "time_min": "2026-03-27T00:00:00+00:00", + "time_max": "2026-03-20T00:00:00+00:00", + }, + ) + assert invalid_window_status == 400 + assert invalid_window_payload == { + "detail": "calendar event time_min must be less than or equal to time_max" + } + + +def test_calendar_event_ingestion_endpoint_persists_artifact_and_chunks( + migrated_database_urls, + monkeypatch, + tmp_path, +) -> None: + owner = seed_task(migrated_database_urls["app"], email="owner@example.com") + workspace_root = tmp_path / "task-workspaces" + calendar_secret_root = tmp_path / "calendar-secrets" + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + task_workspace_root=str(workspace_root), + calendar_secret_manager_url=_build_calendar_secret_manager_url(calendar_secret_root), + ), + ) + monkeypatch.setattr( + calendar_module, + "fetch_calendar_event_payload", + lambda **_kwargs: { + "id": "evt-001", + "summary": "Sprint Planning", + "description": "Discuss sprint scope and timelines.", + "location": "Room 1", + "status": "confirmed", + "start": {"dateTime": "2026-03-20T09:00:00+00:00"}, + "end": {"dateTime": "2026-03-20T09:30:00+00:00"}, + "organizer": {"email": "owner@gmail.example"}, + "htmlLink": "https://calendar.google.com/event?eid=evt-001", + }, + ) + + account_status, account_payload = _connect_calendar_account( + user_id=owner["user_id"], + provider_account_id="acct-owner-001", + email_address="owner@gmail.example", + ) + workspace_status, workspace_payload = invoke_request( + "POST", + f"/v0/tasks/{owner['task_id']}/workspace", + payload={"user_id": str(owner["user_id"])}, + ) + ingest_status, ingest_payload = invoke_request( + "POST", + f"/v0/calendar-accounts/{account_payload['account']['id']}/events/evt-001/ingest", + payload={ + "user_id": str(owner["user_id"]), + "task_workspace_id": workspace_payload["workspace"]["id"], + }, + ) + + assert account_status == 201 + assert workspace_status == 201 + assert ingest_status == 200 + assert ingest_payload == { + "account": account_payload["account"], + "event": { + "provider_event_id": "evt-001", + "artifact_relative_path": "calendar/acct-owner-001/evt-001.txt", + "media_type": "text/plain", + }, + "artifact": { + "id": ingest_payload["artifact"]["id"], + "task_id": str(owner["task_id"]), + "task_workspace_id": workspace_payload["workspace"]["id"], + "status": "registered", + "ingestion_status": "ingested", + "relative_path": "calendar/acct-owner-001/evt-001.txt", + "media_type_hint": "text/plain", + "created_at": ingest_payload["artifact"]["created_at"], + "updated_at": ingest_payload["artifact"]["updated_at"], + }, + "summary": { + "total_count": ingest_payload["summary"]["total_count"], + "total_characters": ingest_payload["summary"]["total_characters"], + "media_type": "text/plain", + "chunking_rule": "normalized_utf8_text_fixed_window_1000_chars_v1", + "order": ["sequence_no_asc", "id_asc"], + }, + } + assert ingest_payload["summary"]["total_count"] >= 1 + artifact_file = ( + Path(workspace_payload["workspace"]["local_path"]) / "calendar" / "acct-owner-001" / "evt-001.txt" + ) + assert artifact_file.is_file() + artifact_text = artifact_file.read_text(encoding="utf-8") + assert "Summary: Sprint Planning" in artifact_text + assert "Start: 2026-03-20T09:00:00+00:00" in artifact_text + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + store = ContinuityStore(conn) + artifact_rows = store.list_task_artifacts_for_task(owner["task_id"]) + assert len(artifact_rows) == 1 + assert artifact_rows[0]["relative_path"] == "calendar/acct-owner-001/evt-001.txt" + assert artifact_rows[0]["ingestion_status"] == "ingested" + chunk_rows = store.list_task_artifact_chunks(artifact_rows[0]["id"]) + assert len(chunk_rows) == ingest_payload["summary"]["total_count"] + assert chunk_rows[0]["text"].startswith("Provider: google_calendar") + + +def test_calendar_event_ingestion_endpoint_rejects_cross_user_workspace_access( + migrated_database_urls, + monkeypatch, + tmp_path, +) -> None: + owner = seed_task(migrated_database_urls["app"], email="owner@example.com") + intruder = seed_task(migrated_database_urls["app"], email="intruder@example.com") + workspace_root = tmp_path / "task-workspaces" + calendar_secret_root = tmp_path / "calendar-secrets" + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + task_workspace_root=str(workspace_root), + calendar_secret_manager_url=_build_calendar_secret_manager_url(calendar_secret_root), + ), + ) + monkeypatch.setattr( + calendar_module, + "fetch_calendar_event_payload", + lambda **_kwargs: { + "id": "evt-001", + "start": {"dateTime": "2026-03-20T09:00:00+00:00"}, + "end": {"dateTime": "2026-03-20T09:30:00+00:00"}, + }, + ) + + _, owner_workspace_payload = invoke_request( + "POST", + f"/v0/tasks/{owner['task_id']}/workspace", + payload={"user_id": str(owner["user_id"])}, + ) + _, intruder_account_payload = _connect_calendar_account( + user_id=intruder["user_id"], + provider_account_id="acct-intruder-001", + email_address="intruder@gmail.example", + ) + ingest_status, ingest_payload = invoke_request( + "POST", + f"/v0/calendar-accounts/{intruder_account_payload['account']['id']}/events/evt-001/ingest", + payload={ + "user_id": str(intruder["user_id"]), + "task_workspace_id": owner_workspace_payload["workspace"]["id"], + }, + ) + + assert ingest_status == 404 + assert ingest_payload == { + "detail": f"task workspace {owner_workspace_payload['workspace']['id']} was not found" + } + + +def test_calendar_event_ingestion_endpoint_rejects_missing_and_unsupported_events( + migrated_database_urls, + monkeypatch, + tmp_path, +) -> None: + owner = seed_task(migrated_database_urls["app"], email="owner@example.com") + workspace_root = tmp_path / "task-workspaces" + calendar_secret_root = tmp_path / "calendar-secrets" + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + task_workspace_root=str(workspace_root), + calendar_secret_manager_url=_build_calendar_secret_manager_url(calendar_secret_root), + ), + ) + + _, account_payload = _connect_calendar_account( + user_id=owner["user_id"], + provider_account_id="acct-owner-001", + email_address="owner@gmail.example", + ) + _, workspace_payload = invoke_request( + "POST", + f"/v0/tasks/{owner['task_id']}/workspace", + payload={"user_id": str(owner["user_id"])}, + ) + + monkeypatch.setattr( + calendar_module, + "fetch_calendar_event_payload", + lambda **_kwargs: (_ for _ in ()).throw( + calendar_module.CalendarEventNotFoundError("calendar event evt-missing was not found") + ), + ) + missing_status, missing_payload = invoke_request( + "POST", + f"/v0/calendar-accounts/{account_payload['account']['id']}/events/evt-missing/ingest", + payload={ + "user_id": str(owner["user_id"]), + "task_workspace_id": workspace_payload["workspace"]["id"], + }, + ) + + monkeypatch.setattr( + calendar_module, + "fetch_calendar_event_payload", + lambda **_kwargs: { + "id": "evt-unsupported", + "start": {"dateTime": "2026-03-20T09:00:00+00:00"}, + }, + ) + unsupported_status, unsupported_payload = invoke_request( + "POST", + f"/v0/calendar-accounts/{account_payload['account']['id']}/events/evt-unsupported/ingest", + payload={ + "user_id": str(owner["user_id"]), + "task_workspace_id": workspace_payload["workspace"]["id"], + }, + ) + + assert missing_status == 404 + assert missing_payload == {"detail": "calendar event evt-missing was not found"} + assert unsupported_status == 400 + assert unsupported_payload == { + "detail": "calendar event evt-unsupported is not supported for ingestion" + } + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + store = ContinuityStore(conn) + assert store.list_task_artifacts_for_task(owner["task_id"]) == [] diff --git a/tests/integration/test_chatgpt_import.py b/tests/integration/test_chatgpt_import.py new file mode 100644 index 0000000..5893be7 --- /dev/null +++ b/tests/integration/test_chatgpt_import.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from pathlib import Path +from uuid import UUID, uuid4 + +from alicebot_api.chatgpt_import import import_chatgpt_source +from alicebot_api.continuity_recall import query_continuity_recall +from alicebot_api.continuity_resumption import compile_continuity_resumption_brief +from alicebot_api.contracts import ContinuityRecallQueryInput, ContinuityResumptionBriefRequestInput +from alicebot_api.db import user_connection +from alicebot_api.store import ContinuityStore + + +REPO_ROOT = Path(__file__).resolve().parents[2] +CHATGPT_FIXTURE_PATH = REPO_ROOT / "fixtures" / "importers" / "chatgpt" / "workspace_v1.json" +THREAD_ID = UUID("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb") + + +def seed_user(database_url: str, *, email: str) -> UUID: + user_id = uuid4() + with user_connection(database_url, user_id) as conn: + ContinuityStore(conn).create_user(user_id, email, email.split("@", 1)[0].title()) + return user_id + + +def test_chatgpt_import_supports_recall_resumption_and_idempotent_dedupe(migrated_database_urls) -> None: + user_id = seed_user(migrated_database_urls["app"], email="chatgpt-import@example.com") + + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + + first_import = import_chatgpt_source( + store, + user_id=user_id, + source=CHATGPT_FIXTURE_PATH, + ) + + assert first_import["status"] == "ok" + assert first_import["fixture_id"] == "chatgpt-s37-workspace-v1" + assert first_import["workspace_id"] == "chatgpt-workspace-demo-001" + assert first_import["total_candidates"] == 5 + assert first_import["imported_count"] == 4 + assert first_import["skipped_duplicates"] == 1 + assert first_import["provenance_source_kind"] == "chatgpt_import" + + recall = query_continuity_recall( + store, + user_id=user_id, + request=ContinuityRecallQueryInput( + thread_id=THREAD_ID, + project="ChatGPT Import Project", + query="ChatGPT import provenance explicit", + limit=20, + ), + ) + + assert recall["summary"]["returned_count"] == 4 + assert all(item["provenance"]["source_kind"] == "chatgpt_import" for item in recall["items"]) + assert all( + item["provenance"].get("chatgpt_workspace_id") == "chatgpt-workspace-demo-001" + for item in recall["items"] + ) + + resumption = compile_continuity_resumption_brief( + store, + user_id=user_id, + request=ContinuityResumptionBriefRequestInput( + thread_id=THREAD_ID, + project="ChatGPT Import Project", + query="ChatGPT import provenance explicit", + max_recent_changes=10, + max_open_loops=10, + ), + ) + + brief = resumption["brief"] + assert brief["last_decision"]["item"] is not None + assert brief["last_decision"]["item"]["provenance"]["source_kind"] == "chatgpt_import" + assert brief["next_action"]["item"] is not None + assert brief["next_action"]["item"]["provenance"]["source_kind"] == "chatgpt_import" + + second_import = import_chatgpt_source( + store, + user_id=user_id, + source=CHATGPT_FIXTURE_PATH, + ) + + assert second_import["status"] == "noop" + assert second_import["total_candidates"] == 5 + assert second_import["imported_count"] == 0 + assert second_import["skipped_duplicates"] == 5 diff --git a/tests/integration/test_chief_of_staff_api.py b/tests/integration/test_chief_of_staff_api.py new file mode 100644 index 0000000..a990517 --- /dev/null +++ b/tests/integration/test_chief_of_staff_api.py @@ -0,0 +1,864 @@ +from __future__ import annotations + +from datetime import UTC, datetime +import json +from typing import Any +from urllib.parse import urlencode +from uuid import UUID, uuid4 + +import anyio +import psycopg + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.db import user_connection +from alicebot_api.store import ContinuityStore + + +def invoke_request( + method: str, + path: str, + *, + query_params: dict[str, str] | None = None, + payload: dict[str, Any] | None = None, +) -> tuple[int, dict[str, Any]]: + messages: list[dict[str, object]] = [] + encoded_body = b"" if payload is None else json.dumps(payload).encode() + request_received = False + + async def receive() -> dict[str, object]: + nonlocal request_received + if request_received: + return {"type": "http.disconnect"} + + request_received = True + return {"type": "http.request", "body": encoded_body, "more_body": False} + + async def send(message: dict[str, object]) -> None: + messages.append(message) + + query_string = urlencode(query_params or {}).encode() + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": query_string, + "headers": [(b"content-type", b"application/json")], + "client": ("testclient", 50000), + "server": ("testserver", 80), + "root_path": "", + } + + anyio.run(main_module.app, scope, receive, send) + + start_message = next(message for message in messages if message["type"] == "http.response.start") + body = b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + return start_message["status"], json.loads(body) + + +def seed_user(database_url: str, *, email: str) -> UUID: + user_id = uuid4() + with user_connection(database_url, user_id) as conn: + ContinuityStore(conn).create_user(user_id, email, email.split("@", 1)[0].title()) + return user_id + + +def set_continuity_timestamps( + admin_database_url: str, + *, + continuity_object_id: UUID, + created_at: datetime, +) -> None: + with psycopg.connect(admin_database_url, autocommit=True) as conn: + with conn.cursor() as cur: + cur.execute( + "UPDATE continuity_objects SET created_at = %s, updated_at = %s WHERE id = %s", + (created_at, created_at, continuity_object_id), + ) + + +def test_chief_of_staff_priority_brief_is_deterministic_and_trust_aware( + migrated_database_urls, + monkeypatch, +) -> None: + user_id = seed_user(migrated_database_urls["app"], email="chief-of-staff@example.com") + thread_id = UUID("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") + + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + + next_capture = store.create_continuity_capture_event( + raw_content="Next Action: Ship the dashboard", + explicit_signal="next_action", + admission_posture="DERIVED", + admission_reason="explicit_signal_next_action", + ) + next_object = store.create_continuity_object( + capture_event_id=next_capture["id"], + object_type="NextAction", + status="active", + title="Next Action: Ship the dashboard", + body={"action_text": "Ship the dashboard", "confirmation_status": "confirmed"}, + provenance={"thread_id": str(thread_id), "source_event_ids": ["event-next"]}, + confidence=1.0, + ) + + commitment_capture = store.create_continuity_capture_event( + raw_content="Commitment: Publish sprint report", + explicit_signal="commitment", + admission_posture="DERIVED", + admission_reason="explicit_signal_commitment", + ) + commitment_object = store.create_continuity_object( + capture_event_id=commitment_capture["id"], + object_type="Commitment", + status="active", + title="Commitment: Publish sprint report", + body={"commitment_text": "Publish sprint report", "confirmation_status": "confirmed"}, + provenance={"thread_id": str(thread_id), "source_event_ids": ["event-commitment"]}, + confidence=0.95, + ) + + waiting_capture = store.create_continuity_capture_event( + raw_content="Waiting For: Vendor legal review", + explicit_signal="waiting_for", + admission_posture="DERIVED", + admission_reason="explicit_signal_waiting_for", + ) + waiting_object = store.create_continuity_object( + capture_event_id=waiting_capture["id"], + object_type="WaitingFor", + status="active", + title="Waiting For: Vendor legal review", + body={"waiting_for_text": "Vendor legal review"}, + provenance={"thread_id": str(thread_id), "source_event_ids": ["event-waiting"]}, + confidence=0.9, + ) + + blocker_capture = store.create_continuity_capture_event( + raw_content="Blocker: Missing release token", + explicit_signal="blocker", + admission_posture="DERIVED", + admission_reason="explicit_signal_blocker", + ) + blocker_object = store.create_continuity_object( + capture_event_id=blocker_capture["id"], + object_type="Blocker", + status="active", + title="Blocker: Missing release token", + body={"blocking_reason": "Missing release token"}, + provenance={"thread_id": str(thread_id), "source_event_ids": ["event-blocker"]}, + confidence=0.9, + ) + + stale_capture = store.create_continuity_capture_event( + raw_content="Waiting For: Old finance response", + explicit_signal="waiting_for", + admission_posture="DERIVED", + admission_reason="explicit_signal_waiting_for", + ) + stale_object = store.create_continuity_object( + capture_event_id=stale_capture["id"], + object_type="WaitingFor", + status="stale", + title="Waiting For: Old finance response", + body={"waiting_for_text": "Old finance response"}, + provenance={"thread_id": str(thread_id), "source_event_ids": ["event-stale"]}, + confidence=0.85, + ) + + set_continuity_timestamps( + migrated_database_urls["admin"], + continuity_object_id=next_object["id"], + created_at=datetime(2026, 3, 31, 10, 5, tzinfo=UTC), + ) + set_continuity_timestamps( + migrated_database_urls["admin"], + continuity_object_id=commitment_object["id"], + created_at=datetime(2026, 3, 28, 10, 0, tzinfo=UTC), + ) + set_continuity_timestamps( + migrated_database_urls["admin"], + continuity_object_id=waiting_object["id"], + created_at=datetime(2026, 3, 31, 9, 30, tzinfo=UTC), + ) + set_continuity_timestamps( + migrated_database_urls["admin"], + continuity_object_id=blocker_object["id"], + created_at=datetime(2026, 3, 23, 9, 0, tzinfo=UTC), + ) + set_continuity_timestamps( + migrated_database_urls["admin"], + continuity_object_id=stale_object["id"], + created_at=datetime(2026, 3, 27, 8, 30, tzinfo=UTC), + ) + + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + params = { + "user_id": str(user_id), + "thread_id": str(thread_id), + "limit": "5", + } + + status_one, payload_one = invoke_request("GET", "/v0/chief-of-staff", query_params=params) + status_two, payload_two = invoke_request("GET", "/v0/chief-of-staff", query_params=params) + + assert status_one == 200 + assert status_two == 200 + assert payload_one == payload_two + + brief = payload_one["brief"] + assert brief["assembly_version"] == "chief_of_staff_priority_brief_v0" + assert brief["summary"]["posture_order"] == ["urgent", "important", "waiting", "blocked", "stale", "defer"] + assert brief["summary"]["follow_through_posture_order"] == [ + "overdue", + "stale_waiting_for", + "slipped_commitment", + ] + assert brief["summary"]["follow_through_item_order"] == [ + "recommendation_action_desc", + "age_hours_desc", + "created_at_desc", + "id_desc", + ] + assert brief["summary"]["quality_gate_status"] == "insufficient_sample" + assert brief["summary"]["trust_confidence_posture"] == "low" + + ranked = brief["ranked_items"] + assert ranked[0]["title"] == "Next Action: Ship the dashboard" + assert ranked[0]["priority_posture"] == "urgent" + assert ranked[0]["confidence_posture"] == "low" + assert ranked[0]["rationale"]["trust_signals"]["downgraded_by_trust"] is True + assert ranked[0]["rationale"]["provenance_references"] + assert ranked[0]["rationale"]["reasons"] + + assert {item["priority_posture"] for item in ranked} >= {"urgent", "waiting", "blocked", "stale"} + assert brief["summary"]["follow_through_total_count"] >= 3 + assert brief["summary"]["overdue_count"] >= 1 + assert brief["summary"]["stale_waiting_for_count"] >= 1 + assert brief["summary"]["slipped_commitment_count"] >= 1 + assert brief["overdue_items"] + assert brief["overdue_items"][0]["recommendation_action"] == "escalate" + assert brief["stale_waiting_for_items"] + assert brief["slipped_commitments"] + assert brief["escalation_posture"]["posture"] == "critical" + assert brief["draft_follow_up"]["status"] == "drafted" + assert brief["draft_follow_up"]["mode"] == "draft_only" + assert brief["draft_follow_up"]["approval_required"] is True + assert brief["draft_follow_up"]["auto_send"] is False + assert brief["draft_follow_up"]["target_metadata"]["continuity_object_id"] == brief["overdue_items"][0]["id"] + assert "artifact-only" in brief["draft_follow_up"]["content"]["body"] + + recommendation = brief["recommended_next_action"] + assert recommendation["target_priority_id"] == ranked[0]["id"] + assert recommendation["confidence_posture"] == "low" + assert recommendation["action_type"] in { + "execute_next_action", + "progress_commitment", + "follow_up_waiting_for", + "unblock_blocker", + "refresh_stale_item", + "review_and_defer", + "capture_new_priority", + } + assert brief["preparation_brief"]["summary"]["order"] == [ + "rank_asc", + "created_at_desc", + "id_desc", + ] + assert brief["what_changed_summary"]["summary"]["order"] == [ + "rank_asc", + "created_at_desc", + "id_desc", + ] + assert brief["prep_checklist"]["summary"]["order"] == [ + "rank_asc", + "created_at_desc", + "id_desc", + ] + assert brief["suggested_talking_points"]["summary"]["order"] == [ + "rank_asc", + "created_at_desc", + "id_desc", + ] + assert brief["resumption_supervision"]["summary"]["order"] == [ + "rank_asc", + ] + assert brief["preparation_brief"]["confidence_posture"] == "low" + assert brief["what_changed_summary"]["confidence_posture"] == "low" + assert brief["prep_checklist"]["confidence_posture"] == "low" + assert brief["suggested_talking_points"]["confidence_posture"] == "low" + assert brief["resumption_supervision"]["confidence_posture"] == "low" + assert brief["preparation_brief"]["context_items"] + assert brief["what_changed_summary"]["items"] + assert brief["prep_checklist"]["items"] + assert brief["suggested_talking_points"]["items"] + assert brief["resumption_supervision"]["recommendations"] + assert any( + recommendation["action"] == "review_scope" and recommendation["provenance_references"] + for recommendation in brief["resumption_supervision"]["recommendations"] + ) + assert brief["weekly_review_brief"]["summary"]["guidance_order"] == ["close", "defer", "escalate"] + assert len(brief["weekly_review_brief"]["guidance"]) == 3 + assert brief["recommendation_outcomes"]["summary"]["total_count"] == 0 + assert brief["priority_learning_summary"]["total_count"] == 0 + assert brief["pattern_drift_summary"]["posture"] == "insufficient_signal" + assert brief["action_handoff_brief"]["order"] == [ + "score_desc", + "source_order_asc", + "source_reference_id_asc", + ] + assert brief["action_handoff_brief"]["source_order"] == [ + "recommended_next_action", + "follow_through", + "prep_checklist", + "weekly_review", + ] + assert brief["handoff_items"] + assert brief["summary"]["handoff_item_count"] == len(brief["handoff_items"]) + assert brief["summary"]["handoff_item_order"] == [ + "score_desc", + "source_order_asc", + "source_reference_id_asc", + ] + assert brief["handoff_queue_summary"]["state_order"] == [ + "ready", + "pending_approval", + "executed", + "stale", + "expired", + ] + assert brief["handoff_queue_summary"]["item_order"] == [ + "queue_rank_asc", + "handoff_rank_asc", + "score_desc", + "handoff_item_id_asc", + ] + assert brief["summary"]["handoff_queue_total_count"] == len(brief["handoff_items"]) + assert brief["summary"]["handoff_queue_state_order"] == [ + "ready", + "pending_approval", + "executed", + "stale", + "expired", + ] + assert brief["summary"]["handoff_queue_item_order"] == [ + "queue_rank_asc", + "handoff_rank_asc", + "score_desc", + "handoff_item_id_asc", + ] + assert brief["handoff_review_actions"] == [] + assert brief["handoff_outcome_summary"]["total_count"] == 0 + assert brief["handoff_outcome_summary"]["latest_total_count"] == 0 + assert brief["closure_quality_summary"]["posture"] == "insufficient_signal" + assert brief["conversion_signal_summary"]["total_handoff_count"] == len(brief["handoff_items"]) + assert brief["conversion_signal_summary"]["latest_outcome_count"] == 0 + assert brief["stale_ignored_escalation_posture"]["supporting_signals"] + assert brief["summary"]["handoff_outcome_total_count"] == 0 + assert brief["summary"]["handoff_outcome_latest_count"] == 0 + assert brief["execution_routing_summary"]["total_handoff_count"] == len(brief["handoff_items"]) + assert brief["execution_routing_summary"]["routed_handoff_count"] == 0 + assert brief["execution_routing_summary"]["route_target_order"] == [ + "task_workflow_draft", + "approval_workflow_draft", + "follow_up_draft_only", + ] + assert brief["routed_handoff_items"] + assert brief["routed_handoff_items"][0]["routed_targets"] == [] + assert brief["routing_audit_trail"] == [] + assert brief["execution_readiness_posture"]["posture"] == "approval_required_draft_only" + assert brief["execution_readiness_posture"]["approval_required"] is True + assert brief["execution_readiness_posture"]["autonomous_execution"] is False + assert brief["execution_readiness_posture"]["external_side_effects_allowed"] is False + assert brief["execution_readiness_posture"]["approval_path_visible"] is True + assert brief["execution_readiness_posture"]["transition_order"] == ["routed", "reaffirmed"] + assert brief["summary"]["execution_posture_order"] == ["approval_bounded_artifact_only"] + assert brief["task_draft"]["source_handoff_item_id"] == brief["handoff_items"][0]["handoff_item_id"] + assert brief["approval_draft"]["source_handoff_item_id"] == brief["handoff_items"][0]["handoff_item_id"] + assert brief["task_draft"]["approval_required"] is True + assert brief["task_draft"]["auto_execute"] is False + assert brief["approval_draft"]["decision"] == "approval_required" + assert brief["approval_draft"]["approval_required"] is True + assert brief["approval_draft"]["auto_submit"] is False + assert brief["execution_posture"]["posture"] == "approval_bounded_artifact_only" + assert brief["execution_posture"]["approval_required"] is True + assert brief["execution_posture"]["autonomous_execution"] is False + assert brief["execution_posture"]["external_side_effects_allowed"] is False + assert brief["execution_posture"]["default_routing_decision"] == "approval_required" + assert "No task, approval, connector send, or external side effect is executed" in brief[ + "execution_posture" + ]["non_autonomous_guarantee"] + + +def test_chief_of_staff_handoff_review_action_updates_queue_lifecycle( + migrated_database_urls, + monkeypatch, +) -> None: + user_id = seed_user(migrated_database_urls["app"], email="chief-of-staff-queue@example.com") + thread_id = UUID("dddddddd-dddd-4ddd-8ddd-dddddddddddd") + + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + capture = store.create_continuity_capture_event( + raw_content="Next Action: Queue review validation", + explicit_signal="next_action", + admission_posture="DERIVED", + admission_reason="explicit_signal_next_action", + ) + continuity_object = store.create_continuity_object( + capture_event_id=capture["id"], + object_type="NextAction", + status="active", + title="Next Action: Queue review validation", + body={"action_text": "Queue review validation"}, + provenance={"thread_id": str(thread_id), "source_event_ids": ["event-next"]}, + confidence=0.95, + ) + + set_continuity_timestamps( + migrated_database_urls["admin"], + continuity_object_id=continuity_object["id"], + created_at=datetime(2026, 3, 31, 10, 0, tzinfo=UTC), + ) + + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + status, brief_payload = invoke_request( + "GET", + "/v0/chief-of-staff", + query_params={ + "user_id": str(user_id), + "thread_id": str(thread_id), + "limit": "5", + }, + ) + assert status == 200 + handoff_item_id = brief_payload["brief"]["handoff_items"][0]["handoff_item_id"] + + action_status, action_payload = invoke_request( + "POST", + "/v0/chief-of-staff/handoff-review-actions", + payload={ + "user_id": str(user_id), + "handoff_item_id": handoff_item_id, + "review_action": "mark_stale", + "thread_id": str(thread_id), + "note": "operator review test", + }, + ) + + assert action_status == 200 + assert action_payload["review_action"]["handoff_item_id"] == handoff_item_id + assert action_payload["review_action"]["review_action"] == "mark_stale" + assert action_payload["review_action"]["next_lifecycle_state"] == "stale" + assert action_payload["handoff_queue_summary"]["stale_count"] >= 1 + + refreshed_status, refreshed_brief_payload = invoke_request( + "GET", + "/v0/chief-of-staff", + query_params={ + "user_id": str(user_id), + "thread_id": str(thread_id), + "limit": "5", + }, + ) + assert refreshed_status == 200 + refreshed_brief = refreshed_brief_payload["brief"] + assert refreshed_brief["handoff_review_actions"] + assert refreshed_brief["handoff_review_actions"][0]["review_action"] == "mark_stale" + assert any( + item["handoff_item_id"] == handoff_item_id + for item in refreshed_brief["handoff_queue_groups"]["stale"]["items"] + ) + + +def test_chief_of_staff_execution_routing_action_updates_routing_audit( + migrated_database_urls, + monkeypatch, +) -> None: + user_id = seed_user(migrated_database_urls["app"], email="chief-of-staff-routing@example.com") + thread_id = UUID("eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee") + + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + capture = store.create_continuity_capture_event( + raw_content="Next Action: Governed routing validation", + explicit_signal="next_action", + admission_posture="DERIVED", + admission_reason="explicit_signal_next_action", + ) + continuity_object = store.create_continuity_object( + capture_event_id=capture["id"], + object_type="NextAction", + status="active", + title="Next Action: Governed routing validation", + body={"action_text": "Governed routing validation"}, + provenance={"thread_id": str(thread_id), "source_event_ids": ["event-next"]}, + confidence=0.95, + ) + + set_continuity_timestamps( + migrated_database_urls["admin"], + continuity_object_id=continuity_object["id"], + created_at=datetime(2026, 3, 31, 10, 0, tzinfo=UTC), + ) + + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + status, brief_payload = invoke_request( + "GET", + "/v0/chief-of-staff", + query_params={ + "user_id": str(user_id), + "thread_id": str(thread_id), + "limit": "5", + }, + ) + assert status == 200 + handoff_item_id = brief_payload["brief"]["handoff_items"][0]["handoff_item_id"] + + routing_status, routing_payload = invoke_request( + "POST", + "/v0/chief-of-staff/execution-routing-actions", + payload={ + "user_id": str(user_id), + "handoff_item_id": handoff_item_id, + "route_target": "task_workflow_draft", + "thread_id": str(thread_id), + "note": "operator routing test", + }, + ) + + assert routing_status == 200 + assert routing_payload["routing_action"]["handoff_item_id"] == handoff_item_id + assert routing_payload["routing_action"]["route_target"] == "task_workflow_draft" + assert routing_payload["routing_action"]["transition"] == "routed" + assert routing_payload["execution_routing_summary"]["routed_handoff_count"] >= 1 + assert any( + item["handoff_item_id"] == handoff_item_id and "task_workflow_draft" in item["routed_targets"] + for item in routing_payload["routed_handoff_items"] + ) + assert routing_payload["execution_readiness_posture"]["approval_required"] is True + + reaffirm_status, reaffirm_payload = invoke_request( + "POST", + "/v0/chief-of-staff/execution-routing-actions", + payload={ + "user_id": str(user_id), + "handoff_item_id": handoff_item_id, + "route_target": "task_workflow_draft", + "thread_id": str(thread_id), + "note": "operator reaffirm test", + }, + ) + assert reaffirm_status == 200 + assert reaffirm_payload["routing_action"]["transition"] == "reaffirmed" + assert reaffirm_payload["routing_action"]["previously_routed"] is True + + refreshed_status, refreshed_brief_payload = invoke_request( + "GET", + "/v0/chief-of-staff", + query_params={ + "user_id": str(user_id), + "thread_id": str(thread_id), + "limit": "5", + }, + ) + assert refreshed_status == 200 + refreshed_brief = refreshed_brief_payload["brief"] + assert refreshed_brief["routing_audit_trail"] + assert refreshed_brief["routing_audit_trail"][0]["route_target"] == "task_workflow_draft" + assert any( + item["handoff_item_id"] == handoff_item_id and "task_workflow_draft" in item["routed_targets"] + for item in refreshed_brief["routed_handoff_items"] + ) + + +def test_chief_of_staff_handoff_outcome_capture_updates_closure_and_conversion_rollups( + migrated_database_urls, + monkeypatch, +) -> None: + user_id = seed_user(migrated_database_urls["app"], email="chief-of-staff-handoff-outcomes@example.com") + thread_id = UUID("f1f1f1f1-f1f1-4f1f-8f1f-f1f1f1f1f1f1") + + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + capture = store.create_continuity_capture_event( + raw_content="Next Action: Outcome seam validation", + explicit_signal="next_action", + admission_posture="DERIVED", + admission_reason="explicit_signal_next_action", + ) + continuity_object = store.create_continuity_object( + capture_event_id=capture["id"], + object_type="NextAction", + status="active", + title="Next Action: Outcome seam validation", + body={"action_text": "Outcome seam validation"}, + provenance={"thread_id": str(thread_id), "source_event_ids": ["event-next"]}, + confidence=0.95, + ) + + set_continuity_timestamps( + migrated_database_urls["admin"], + continuity_object_id=continuity_object["id"], + created_at=datetime(2026, 4, 1, 10, 0, tzinfo=UTC), + ) + + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + brief_status, brief_payload = invoke_request( + "GET", + "/v0/chief-of-staff", + query_params={ + "user_id": str(user_id), + "thread_id": str(thread_id), + "limit": "5", + }, + ) + assert brief_status == 200 + handoff_item_id = brief_payload["brief"]["handoff_items"][0]["handoff_item_id"] + + routing_status, _routing_payload = invoke_request( + "POST", + "/v0/chief-of-staff/execution-routing-actions", + payload={ + "user_id": str(user_id), + "handoff_item_id": handoff_item_id, + "route_target": "task_workflow_draft", + "thread_id": str(thread_id), + "note": "route for outcome capture test", + }, + ) + assert routing_status == 200 + + outcome_status, outcome_payload = invoke_request( + "POST", + "/v0/chief-of-staff/handoff-outcomes", + payload={ + "user_id": str(user_id), + "handoff_item_id": handoff_item_id, + "outcome_status": "executed", + "thread_id": str(thread_id), + "note": "explicit execution outcome", + }, + ) + assert outcome_status == 200 + assert outcome_payload["handoff_outcome"]["handoff_item_id"] == handoff_item_id + assert outcome_payload["handoff_outcome"]["outcome_status"] == "executed" + assert outcome_payload["handoff_outcome_summary"]["latest_status_counts"]["executed"] >= 1 + assert outcome_payload["closure_quality_summary"]["closed_loop_count"] >= 1 + assert outcome_payload["conversion_signal_summary"]["executed_count"] >= 1 + assert outcome_payload["conversion_signal_summary"]["recommendation_to_execution_conversion_rate"] >= 0.0 + assert outcome_payload["stale_ignored_escalation_posture"]["supporting_signals"] + + refreshed_status, refreshed_brief_payload = invoke_request( + "GET", + "/v0/chief-of-staff", + query_params={ + "user_id": str(user_id), + "thread_id": str(thread_id), + "limit": "5", + }, + ) + assert refreshed_status == 200 + refreshed_brief = refreshed_brief_payload["brief"] + assert refreshed_brief["handoff_outcomes"] + assert refreshed_brief["handoff_outcomes"][0]["handoff_item_id"] == handoff_item_id + assert refreshed_brief["handoff_outcome_summary"]["latest_status_counts"]["executed"] >= 1 + assert refreshed_brief["conversion_signal_summary"]["executed_count"] >= 1 + + +def test_chief_of_staff_handoff_outcome_capture_rejects_invalid_and_unrouted_or_out_of_scope_items( + migrated_database_urls, + monkeypatch, +) -> None: + user_id = seed_user(migrated_database_urls["app"], email="chief-of-staff-handoff-outcomes-negative@example.com") + thread_id = UUID("a1a1a1a1-a1a1-4a1a-8a1a-a1a1a1a1a1a1") + + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + capture = store.create_continuity_capture_event( + raw_content="Next Action: Outcome validation negative-path coverage", + explicit_signal="next_action", + admission_posture="DERIVED", + admission_reason="explicit_signal_next_action", + ) + continuity_object = store.create_continuity_object( + capture_event_id=capture["id"], + object_type="NextAction", + status="active", + title="Next Action: Outcome validation negative-path coverage", + body={"action_text": "Outcome validation negative-path coverage"}, + provenance={"thread_id": str(thread_id), "source_event_ids": ["event-next-negative"]}, + confidence=0.95, + ) + + set_continuity_timestamps( + migrated_database_urls["admin"], + continuity_object_id=continuity_object["id"], + created_at=datetime(2026, 4, 1, 11, 0, tzinfo=UTC), + ) + + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + brief_status, brief_payload = invoke_request( + "GET", + "/v0/chief-of-staff", + query_params={ + "user_id": str(user_id), + "thread_id": str(thread_id), + "limit": "5", + }, + ) + assert brief_status == 200 + handoff_item_id = brief_payload["brief"]["handoff_items"][0]["handoff_item_id"] + + invalid_status_code, invalid_status_payload = invoke_request( + "POST", + "/v0/chief-of-staff/handoff-outcomes", + payload={ + "user_id": str(user_id), + "handoff_item_id": handoff_item_id, + "outcome_status": "invalid_status", + "thread_id": str(thread_id), + "note": "invalid status should fail", + }, + ) + assert invalid_status_code == 400 + assert "outcome_status must be one of" in invalid_status_payload["detail"] + + unrouted_status_code, unrouted_status_payload = invoke_request( + "POST", + "/v0/chief-of-staff/handoff-outcomes", + payload={ + "user_id": str(user_id), + "handoff_item_id": handoff_item_id, + "outcome_status": "executed", + "thread_id": str(thread_id), + "note": "unrouted handoff should fail", + }, + ) + assert unrouted_status_code == 400 + assert "has no routed targets yet" in unrouted_status_payload["detail"] + + out_of_scope_status_code, out_of_scope_status_payload = invoke_request( + "POST", + "/v0/chief-of-staff/handoff-outcomes", + payload={ + "user_id": str(user_id), + "handoff_item_id": "handoff-item-outside-scope", + "outcome_status": "executed", + "thread_id": str(thread_id), + "note": "out-of-scope handoff should fail", + }, + ) + assert out_of_scope_status_code == 400 + assert "was not found in the scoped deterministic routed handoff list" in out_of_scope_status_payload["detail"] + + +def test_chief_of_staff_recommendation_outcome_capture_is_auditable_and_updates_learning( + migrated_database_urls, + monkeypatch, +) -> None: + user_id = seed_user(migrated_database_urls["app"], email="chief-of-staff-outcomes@example.com") + thread_id = UUID("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb") + + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + next_capture = store.create_continuity_capture_event( + raw_content="Next Action: Ship outcome learning panel", + explicit_signal="next_action", + admission_posture="DERIVED", + admission_reason="explicit_signal_next_action", + ) + next_object = store.create_continuity_object( + capture_event_id=next_capture["id"], + object_type="NextAction", + status="active", + title="Next Action: Ship outcome learning panel", + body={"action_text": "Ship outcome learning panel"}, + provenance={"thread_id": str(thread_id), "source_event_ids": ["event-next"]}, + confidence=0.95, + ) + + set_continuity_timestamps( + migrated_database_urls["admin"], + continuity_object_id=next_object["id"], + created_at=datetime(2026, 3, 31, 10, 0, tzinfo=UTC), + ) + + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + capture_payload = { + "user_id": str(user_id), + "outcome": "accept", + "recommendation_action_type": "execute_next_action", + "recommendation_title": "Next Action: Ship outcome learning panel", + "rationale": "Accepted after weekly review.", + "target_priority_id": str(next_object["id"]), + "thread_id": str(thread_id), + } + capture_status, capture_response = invoke_request( + "POST", + "/v0/chief-of-staff/recommendation-outcomes", + payload=capture_payload, + ) + + assert capture_status == 200 + assert capture_response["outcome"]["outcome"] == "accept" + assert capture_response["outcome"]["recommendation_action_type"] == "execute_next_action" + assert capture_response["priority_learning_summary"]["accept_count"] == 1 + assert capture_response["pattern_drift_summary"]["posture"] == "improving" + + status, brief_payload = invoke_request( + "GET", + "/v0/chief-of-staff", + query_params={ + "user_id": str(user_id), + "thread_id": str(thread_id), + "limit": "5", + }, + ) + + assert status == 200 + brief = brief_payload["brief"] + assert brief["recommendation_outcomes"]["summary"]["total_count"] >= 1 + assert brief["recommendation_outcomes"]["summary"]["outcome_counts"]["accept"] >= 1 + assert brief["priority_learning_summary"]["accept_count"] >= 1 + assert "Prioritization is" in brief["priority_learning_summary"]["priority_shift_explanation"] + assert brief["pattern_drift_summary"]["supporting_signals"] diff --git a/tests/integration/test_cli_integration.py b/tests/integration/test_cli_integration.py new file mode 100644 index 0000000..0090eec --- /dev/null +++ b/tests/integration/test_cli_integration.py @@ -0,0 +1,295 @@ +from __future__ import annotations + +from datetime import UTC, datetime +import json +import os +from pathlib import Path +import subprocess +import sys +from uuid import UUID, uuid4 + +from alicebot_api.db import user_connection +from alicebot_api.store import ContinuityStore + + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def seed_user(database_url: str, *, email: str) -> UUID: + user_id = uuid4() + with user_connection(database_url, user_id) as conn: + ContinuityStore(conn).create_user(user_id, email, email.split("@", 1)[0].title()) + return user_id + + +def build_cli_env(*, database_url: str, user_id: UUID) -> dict[str, str]: + env = os.environ.copy() + env["DATABASE_URL"] = database_url + env["ALICEBOT_AUTH_USER_ID"] = str(user_id) + + pythonpath_entries = [str(REPO_ROOT / "apps" / "api" / "src"), str(REPO_ROOT / "workers")] + existing_pythonpath = env.get("PYTHONPATH") + if existing_pythonpath: + pythonpath_entries.append(existing_pythonpath) + env["PYTHONPATH"] = os.pathsep.join(pythonpath_entries) + return env + + +def run_cli(args: list[str], *, env: dict[str, str]) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, "-m", "alicebot_api", *args], + cwd=REPO_ROOT, + env=env, + check=False, + capture_output=True, + text=True, + ) + + +def test_cli_command_surface_and_correction_flow(migrated_database_urls) -> None: + user_id = seed_user(migrated_database_urls["app"], email="cli-user@example.com") + thread_id = UUID("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") + + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + + legacy_capture = store.create_continuity_capture_event( + raw_content="Decision: Legacy rollout plan", + explicit_signal="decision", + admission_posture="DERIVED", + admission_reason="explicit_signal_decision", + ) + legacy_decision = store.create_continuity_object( + capture_event_id=legacy_capture["id"], + object_type="Decision", + status="active", + title="Decision: Legacy rollout plan", + body={"decision_text": "Legacy rollout plan"}, + provenance={"thread_id": str(thread_id), "source_event_ids": ["cli-seed-1"]}, + confidence=0.91, + last_confirmed_at=datetime(2026, 3, 30, 9, 0, tzinfo=UTC), + ) + + waiting_capture = store.create_continuity_capture_event( + raw_content="Waiting For: Reviewer PASS", + explicit_signal="waiting_for", + admission_posture="DERIVED", + admission_reason="explicit_signal_waiting_for", + ) + waiting_for = store.create_continuity_object( + capture_event_id=waiting_capture["id"], + object_type="WaitingFor", + status="active", + title="Waiting For: Reviewer PASS", + body={"waiting_for_text": "Reviewer PASS"}, + provenance={"thread_id": str(thread_id), "source_event_ids": ["cli-seed-2"]}, + confidence=0.9, + ) + + env = build_cli_env(database_url=migrated_database_urls["app"], user_id=user_id) + + help_result = run_cli(["--help"], env=env) + assert help_result.returncode == 0 + assert "capture" in help_result.stdout + assert "review" in help_result.stdout + assert "status" in help_result.stdout + + status_result = run_cli(["status"], env=env) + assert status_result.returncode == 0 + assert "database: reachable" in status_result.stdout + assert "continuity_capture_events: 2" in status_result.stdout + assert "continuity_object_lifecycle:" in status_result.stdout + + capture_result = run_cli( + [ + "capture", + "Task: Publish CLI usage docs", + "--explicit-signal", + "task", + ], + env=env, + ) + assert capture_result.returncode == 0 + assert "capture_event_id:" in capture_result.stdout + assert "derived_object_type: NextAction" in capture_result.stdout + + recall_before = run_cli( + [ + "recall", + "--query", + "rollout", + "--thread-id", + str(thread_id), + "--limit", + "20", + ], + env=env, + ) + assert recall_before.returncode == 0 + assert "Decision: Legacy rollout plan" in recall_before.stdout + assert "lifecycle=preserved:True searchable:True promotable:True" in recall_before.stdout + + lifecycle_list_result = run_cli( + ["lifecycle", "list", "--limit", "20"], + env=env, + ) + assert lifecycle_list_result.returncode == 0 + assert "continuity lifecycle" in lifecycle_list_result.stdout + assert "promotable=3" in lifecycle_list_result.stdout + + lifecycle_show_result = run_cli( + ["lifecycle", "show", str(legacy_decision["id"])], + env=env, + ) + assert lifecycle_show_result.returncode == 0 + assert f"continuity_object_id: {legacy_decision['id']}" in lifecycle_show_result.stdout + + resume_before = run_cli( + [ + "resume", + "--thread-id", + str(thread_id), + "--max-recent-changes", + "5", + "--max-open-loops", + "5", + ], + env=env, + ) + assert resume_before.returncode == 0 + assert "last_decision:" in resume_before.stdout + assert "Decision: Legacy rollout plan" in resume_before.stdout + + open_loops_result = run_cli( + ["open-loops", "--thread-id", str(thread_id), "--limit", "20"], + env=env, + ) + assert open_loops_result.returncode == 0 + assert "waiting_for (returned=1 total=1 limit=20)" in open_loops_result.stdout + assert waiting_for["title"] in open_loops_result.stdout + + review_queue_result = run_cli( + ["review", "queue", "--status", "correction_ready", "--limit", "20"], + env=env, + ) + assert review_queue_result.returncode == 0 + assert str(legacy_decision["id"]) in review_queue_result.stdout + + review_show_result = run_cli( + ["review", "show", str(legacy_decision["id"])], + env=env, + ) + assert review_show_result.returncode == 0 + assert f"continuity_object_id: {legacy_decision['id']}" in review_show_result.stdout + + replacement_provenance = json.dumps({"thread_id": str(thread_id), "source_event_ids": ["cli-correction-1"]}) + review_apply_result = run_cli( + [ + "review", + "apply", + str(legacy_decision["id"]), + "--action", + "supersede", + "--reason", + "Latest decision supersedes legacy plan", + "--replacement-title", + "Decision: Updated rollout plan", + "--replacement-body-json", + '{"decision_text":"Updated rollout plan"}', + "--replacement-provenance-json", + replacement_provenance, + "--replacement-confidence", + "0.97", + ], + env=env, + ) + assert review_apply_result.returncode == 0 + assert "replacement_object_id: " in review_apply_result.stdout + assert "replacement_object_id: none" not in review_apply_result.stdout + + recall_after = run_cli( + [ + "recall", + "--thread-id", + str(thread_id), + "--query", + "rollout", + "--limit", + "20", + ], + env=env, + ) + assert recall_after.returncode == 0 + assert "Decision: Updated rollout plan" in recall_after.stdout + + resume_after = run_cli( + [ + "resume", + "--thread-id", + str(thread_id), + "--max-recent-changes", + "5", + "--max-open-loops", + "5", + ], + env=env, + ) + assert resume_after.returncode == 0 + assert "Decision: Updated rollout plan" in resume_after.stdout + + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + hidden_fact_capture = store.create_continuity_capture_event( + raw_content="Remember: searchable but not promotable", + explicit_signal="remember_this", + admission_posture="DERIVED", + admission_reason="explicit_signal_remember_this", + ) + hidden_fact = store.create_continuity_object( + capture_event_id=hidden_fact_capture["id"], + object_type="MemoryFact", + status="active", + title="Memory Fact: searchable but not promotable", + body={"fact_text": "searchable but not promotable"}, + provenance={"thread_id": str(thread_id), "source_event_ids": ["cli-seed-3"]}, + confidence=0.88, + is_promotable=False, + ) + + lifecycle_hidden_result = run_cli( + ["lifecycle", "show", str(hidden_fact["id"])], + env=env, + ) + assert lifecycle_hidden_result.returncode == 0 + assert "promotable=False" in lifecycle_hidden_result.stdout + + resume_default_fact_result = run_cli( + [ + "resume", + "--thread-id", + str(thread_id), + "--max-recent-changes", + "10", + "--max-open-loops", + "5", + ], + env=env, + ) + assert resume_default_fact_result.returncode == 0 + assert "Memory Fact: searchable but not promotable" not in resume_default_fact_result.stdout + + resume_override_fact_result = run_cli( + [ + "resume", + "--thread-id", + str(thread_id), + "--max-recent-changes", + "10", + "--max-open-loops", + "5", + "--include-non-promotable-facts", + ], + env=env, + ) + assert resume_override_fact_result.returncode == 0 + assert "Memory Fact: searchable but not promotable" in resume_override_fact_result.stdout diff --git a/tests/integration/test_context_compile.py b/tests/integration/test_context_compile.py new file mode 100644 index 0000000..4740feb --- /dev/null +++ b/tests/integration/test_context_compile.py @@ -0,0 +1,2337 @@ +from __future__ import annotations + +import json +from datetime import UTC, datetime +from typing import Any +from uuid import UUID, uuid4 + +import anyio +import psycopg +import pytest + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.db import user_connection +from alicebot_api.store import ContinuityStore + + +def invoke_compile_context(payload: dict[str, Any]) -> tuple[int, dict[str, Any]]: + messages: list[dict[str, object]] = [] + encoded_body = json.dumps(payload).encode() + request_received = False + + async def receive() -> dict[str, object]: + nonlocal request_received + if request_received: + return {"type": "http.disconnect"} + + request_received = True + return {"type": "http.request", "body": encoded_body, "more_body": False} + + async def send(message: dict[str, object]) -> None: + messages.append(message) + + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": "POST", + "scheme": "http", + "path": "/v0/context/compile", + "raw_path": b"/v0/context/compile", + "query_string": b"", + "headers": [(b"content-type", b"application/json")], + "client": ("testclient", 50000), + "server": ("testserver", 80), + "root_path": "", + } + + anyio.run(main_module.app, scope, receive, send) + + start_message = next(message for message in messages if message["type"] == "http.response.start") + body = b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + return start_message["status"], json.loads(body) + + +def seed_traceable_thread( + database_url: str, + *, + email: str = "owner@example.com", + display_name: str = "Owner", +) -> dict[str, object]: + user_id = uuid4() + included_edge_valid_from = datetime(2026, 3, 12, 10, 0, tzinfo=UTC) + + with user_connection(database_url, user_id) as conn: + store = ContinuityStore(conn) + store.create_user(user_id, email, display_name) + thread = store.create_thread("Context thread") + first_session = store.create_session(thread["id"], status="complete") + second_session = store.create_session(thread["id"], status="active") + event_ids = [ + store.append_event(thread["id"], first_session["id"], "message.user", {"text": "old"})["id"], + store.append_event(thread["id"], second_session["id"], "message.assistant", {"text": "newer"})["id"], + store.append_event(thread["id"], second_session["id"], "message.user", {"text": "newest"})["id"], + ] + breakfast_memory = store.create_memory( + memory_key="user.preference.breakfast", + value={"likes": "toast"}, + status="active", + source_event_ids=[str(event_ids[0])], + ) + coffee_memory = store.create_memory( + memory_key="user.preference.coffee", + value={"likes": "oat milk"}, + status="active", + source_event_ids=[str(event_ids[1])], + ) + deleted_memory = store.create_memory( + memory_key="user.preference.old", + value={"likes": "black"}, + status="active", + source_event_ids=[str(event_ids[1])], + ) + deleted_memory = store.update_memory( + memory_id=deleted_memory["id"], + value=deleted_memory["value"], + status="deleted", + source_event_ids=[str(event_ids[2])], + ) + person = store.create_entity( + entity_type="person", + name="Alex", + source_memory_ids=[str(breakfast_memory["id"])], + ) + merchant = store.create_entity( + entity_type="merchant", + name="Neighborhood Cafe", + source_memory_ids=[str(coffee_memory["id"])], + ) + project = store.create_entity( + entity_type="project", + name="AliceBot", + source_memory_ids=[str(breakfast_memory["id"]), str(coffee_memory["id"])], + ) + excluded_edge = store.create_entity_edge( + from_entity_id=person["id"], + to_entity_id=project["id"], + relationship_type="visited_by", + valid_from=None, + valid_to=None, + source_memory_ids=[str(breakfast_memory["id"])], + ) + included_edge = store.create_entity_edge( + from_entity_id=project["id"], + to_entity_id=merchant["id"], + relationship_type="depends_on", + valid_from=included_edge_valid_from, + valid_to=None, + source_memory_ids=[str(coffee_memory["id"])], + ) + ignored_when_project_only_edge = store.create_entity_edge( + from_entity_id=person["id"], + to_entity_id=merchant["id"], + relationship_type="introduced_to", + valid_from=None, + valid_to=None, + source_memory_ids=[str(breakfast_memory["id"])], + ) + entities = store.list_entities() + entity_edges = store.list_entity_edges_for_entities([person["id"], merchant["id"], project["id"]]) + + return { + "user_id": user_id, + "thread_id": thread["id"], + "event_ids": event_ids, + "memories": { + "breakfast": breakfast_memory, + "coffee": coffee_memory, + "deleted": deleted_memory, + }, + "entities": entities, + "entity_edges": entity_edges, + "project_only_candidate_edges": { + "excluded": excluded_edge, + "included": included_edge, + "ignored": ignored_when_project_only_edge, + }, + "included_edge_valid_from": included_edge_valid_from, + } + + +def seed_thread_with_updated_active_memory(database_url: str) -> dict[str, object]: + user_id = uuid4() + included_edge_valid_from = datetime(2026, 3, 12, 11, 0, tzinfo=UTC) + + with user_connection(database_url, user_id) as conn: + store = ContinuityStore(conn) + store.create_user(user_id, "owner@example.com", "Owner") + thread = store.create_thread("Updated memory thread") + session = store.create_session(thread["id"], status="active") + event_ids = [ + store.append_event( + thread["id"], + session["id"], + "message.user", + {"text": "baseline memory evidence"}, + )["id"], + store.append_event( + thread["id"], + session["id"], + "message.user", + {"text": "updated memory evidence"}, + )["id"], + ] + store.create_memory( + memory_key="user.preference.breakfast", + value={"likes": "toast"}, + status="active", + source_event_ids=[str(event_ids[0])], + ) + coffee_memory = store.create_memory( + memory_key="user.preference.coffee", + value={"likes": "black"}, + status="active", + source_event_ids=[str(event_ids[0])], + ) + store.update_memory( + memory_id=coffee_memory["id"], + value={"likes": "oat milk"}, + status="active", + source_event_ids=[str(event_ids[1])], + ) + routine = store.create_entity( + entity_type="routine", + name="Breakfast", + source_memory_ids=[str(coffee_memory["id"])], + ) + project = store.create_entity( + entity_type="project", + name="AliceBot", + source_memory_ids=[str(coffee_memory["id"])], + ) + included_edge = store.create_entity_edge( + from_entity_id=project["id"], + to_entity_id=routine["id"], + relationship_type="references", + valid_from=included_edge_valid_from, + valid_to=None, + source_memory_ids=[str(coffee_memory["id"])], + ) + store.create_entity_edge( + from_entity_id=routine["id"], + to_entity_id=routine["id"], + relationship_type="superseded_by", + valid_from=None, + valid_to=None, + source_memory_ids=[str(coffee_memory["id"])], + ) + entities = store.list_entities() + + return { + "user_id": user_id, + "thread_id": thread["id"], + "event_ids": event_ids, + "entities": entities, + "included_edge": included_edge, + "included_edge_valid_from": included_edge_valid_from, + } + + +def seed_embedding_config_for_user( + database_url: str, + *, + user_id: UUID, + dimensions: int = 3, +) -> UUID: + with user_connection(database_url, user_id) as conn: + store = ContinuityStore(conn) + config = store.create_embedding_config( + provider="openai", + model="text-embedding-3-large", + version="2026-03-12", + dimensions=dimensions, + status="active", + metadata={"task": "compile_semantic_retrieval"}, + ) + return config["id"] + + +def seed_memory_embedding_for_user( + database_url: str, + *, + user_id: UUID, + memory_id: UUID, + embedding_config_id: UUID, + vector: list[float], +) -> None: + with user_connection(database_url, user_id) as conn: + ContinuityStore(conn).create_memory_embedding( + memory_id=memory_id, + embedding_config_id=embedding_config_id, + dimensions=len(vector), + vector=vector, + ) + + +def seed_task_artifact_chunk_embedding_for_user( + database_url: str, + *, + user_id: UUID, + task_artifact_chunk_id: UUID, + embedding_config_id: UUID, + vector: list[float], +) -> None: + with user_connection(database_url, user_id) as conn: + ContinuityStore(conn).create_task_artifact_chunk_embedding( + task_artifact_chunk_id=task_artifact_chunk_id, + embedding_config_id=embedding_config_id, + dimensions=len(vector), + vector=vector, + ) + + +def seed_compile_artifact_scope( + database_url: str, + *, + user_id: UUID, + thread_id: UUID, +) -> dict[str, object]: + with user_connection(database_url, user_id) as conn: + store = ContinuityStore(conn) + tool = store.create_tool( + tool_key="artifact.search", + name="Artifact Search", + description="Compile artifact retrieval fixture", + version="2026-03-14", + metadata_version="tool_metadata_v0", + active=True, + tags=[], + action_hints=["retrieve"], + scope_hints=["task"], + domain_hints=[], + risk_hints=[], + metadata={}, + ) + task = store.create_task( + thread_id=thread_id, + tool_id=tool["id"], + status="approved", + request={"action": "retrieve"}, + tool={"tool_key": "artifact.search"}, + latest_approval_id=None, + latest_execution_id=None, + ) + workspace = store.create_task_workspace( + task_id=task["id"], + status="active", + local_path=f"/tmp/alicebot/{task['id']}", + ) + docs_artifact = store.create_task_artifact( + task_id=task["id"], + task_workspace_id=workspace["id"], + status="registered", + ingestion_status="ingested", + relative_path="docs/a.txt", + media_type_hint="text/plain", + ) + notes_artifact = store.create_task_artifact( + task_id=task["id"], + task_workspace_id=workspace["id"], + status="registered", + ingestion_status="ingested", + relative_path="notes/b.md", + media_type_hint="text/markdown", + ) + pending_artifact = store.create_task_artifact( + task_id=task["id"], + task_workspace_id=workspace["id"], + status="registered", + ingestion_status="pending", + relative_path="notes/hidden.txt", + media_type_hint="text/plain", + ) + weak_artifact = store.create_task_artifact( + task_id=task["id"], + task_workspace_id=workspace["id"], + status="registered", + ingestion_status="ingested", + relative_path="notes/c.txt", + media_type_hint="text/plain", + ) + docs_chunk = store.create_task_artifact_chunk( + task_artifact_id=docs_artifact["id"], + sequence_no=1, + char_start=0, + char_end_exclusive=14, + text="beta alpha doc", + ) + notes_chunk = store.create_task_artifact_chunk( + task_artifact_id=notes_artifact["id"], + sequence_no=1, + char_start=0, + char_end_exclusive=15, + text="alpha beta note", + ) + pending_chunk = store.create_task_artifact_chunk( + task_artifact_id=pending_artifact["id"], + sequence_no=1, + char_start=0, + char_end_exclusive=17, + text="alpha beta hidden", + ) + weak_chunk = store.create_task_artifact_chunk( + task_artifact_id=weak_artifact["id"], + sequence_no=1, + char_start=0, + char_end_exclusive=9, + text="beta only", + ) + + return { + "task_id": task["id"], + "artifact_ids": { + "docs": docs_artifact["id"], + "notes": notes_artifact["id"], + "pending": pending_artifact["id"], + "weak": weak_artifact["id"], + }, + "chunk_ids": { + "docs": docs_chunk["id"], + "notes": notes_chunk["id"], + "pending": pending_chunk["id"], + "weak": weak_chunk["id"], + }, + } + + +def test_compile_context_endpoint_persists_trace_and_trace_events(migrated_database_urls, monkeypatch) -> None: + seeded = seed_traceable_thread(migrated_database_urls["app"]) + user_id = seeded["user_id"] + thread_id = seeded["thread_id"] + event_ids = seeded["event_ids"] + entities = seeded["entities"] + included_entity = entities[-1] + project_only_candidate_edges = seeded["project_only_candidate_edges"] + included_entity_edge = project_only_candidate_edges["included"] + excluded_entity_edge = project_only_candidate_edges["excluded"] + ignored_entity_edge = project_only_candidate_edges["ignored"] + included_edge_valid_from = seeded["included_edge_valid_from"] + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + status_code, payload = invoke_compile_context( + { + "user_id": str(user_id), + "thread_id": str(thread_id), + "max_sessions": 1, + "max_events": 1, + "max_memories": 1, + "max_entities": 1, + "max_entity_edges": 1, + } + ) + + assert status_code == 200 + assert payload["trace_event_count"] > 0 + assert payload["context_pack"]["limits"] == { + "max_sessions": 1, + "max_events": 1, + "max_memories": 1, + "max_entities": 1, + "max_entity_edges": 1, + } + assert [session["status"] for session in payload["context_pack"]["sessions"]] == ["active"] + assert [event["sequence_no"] for event in payload["context_pack"]["events"]] == [3] + assert payload["context_pack"]["memories"] == [ + { + "id": payload["context_pack"]["memories"][0]["id"], + "memory_key": "user.preference.coffee", + "value": {"likes": "oat milk"}, + "status": "active", + "source_event_ids": [str(event_ids[1])], + "memory_type": "preference", + "confidence": None, + "salience": None, + "confirmation_status": "unconfirmed", + "trust_class": "deterministic", + "promotion_eligibility": "promotable", + "evidence_count": None, + "independent_source_count": None, + "extracted_by_model": None, + "trust_reason": None, + "valid_from": None, + "valid_to": None, + "last_confirmed_at": None, + "created_at": payload["context_pack"]["memories"][0]["created_at"], + "updated_at": payload["context_pack"]["memories"][0]["updated_at"], + "source_provenance": {"sources": ["symbolic"], "semantic_score": None}, + } + ] + assert payload["context_pack"]["memory_summary"] == { + "candidate_count": 2, + "included_count": 1, + "excluded_deleted_count": 1, + "excluded_limit_count": 0, + "hybrid_retrieval": { + "requested": False, + "embedding_config_id": None, + "query_vector_dimensions": 0, + "semantic_limit": 0, + "symbolic_selected_count": 1, + "semantic_selected_count": 0, + "merged_candidate_count": 1, + "deduplicated_count": 0, + "included_symbolic_only_count": 1, + "included_semantic_only_count": 0, + "included_dual_source_count": 0, + "similarity_metric": None, + "source_precedence": ["symbolic", "semantic"], + "symbolic_order": ["updated_at_asc", "created_at_asc", "id_asc"], + "semantic_order": ["score_desc", "created_at_asc", "id_asc"], + }, + } + assert payload["context_pack"]["artifact_chunks"] == [] + assert payload["context_pack"]["artifact_chunk_summary"] == { + "requested": False, + "lexical_requested": False, + "semantic_requested": False, + "scope": None, + "query": None, + "query_terms": [], + "embedding_config_id": None, + "query_vector_dimensions": 0, + "limit": 0, + "lexical_limit": 0, + "semantic_limit": 0, + "searched_artifact_count": 0, + "lexical_candidate_count": 0, + "semantic_candidate_count": 0, + "merged_candidate_count": 0, + "deduplicated_count": 0, + "included_count": 0, + "included_lexical_only_count": 0, + "included_semantic_only_count": 0, + "included_dual_source_count": 0, + "excluded_uningested_artifact_count": 0, + "excluded_limit_count": 0, + "matching_rule": None, + "similarity_metric": None, + "source_precedence": ["lexical", "semantic"], + "lexical_order": [ + "matched_query_term_count_desc", + "first_match_char_start_asc", + "relative_path_asc", + "sequence_no_asc", + "id_asc", + ], + "semantic_order": ["score_desc", "relative_path_asc", "sequence_no_asc", "id_asc"], + "merged_order": [ + "source_precedence_asc", + "lexical_rank_asc", + "semantic_rank_asc", + "relative_path_asc", + "sequence_no_asc", + "id_asc", + ], + } + assert payload["context_pack"]["entities"] == [ + { + "id": str(included_entity["id"]), + "entity_type": included_entity["entity_type"], + "name": included_entity["name"], + "source_memory_ids": included_entity["source_memory_ids"], + "created_at": included_entity["created_at"].isoformat(), + } + ] + assert payload["context_pack"]["entity_summary"] == { + "candidate_count": 3, + "included_count": 1, + "excluded_limit_count": 2, + } + assert payload["context_pack"]["entity_edges"] == [ + { + "id": str(included_entity_edge["id"]), + "from_entity_id": str(included_entity_edge["from_entity_id"]), + "to_entity_id": str(included_entity_edge["to_entity_id"]), + "relationship_type": included_entity_edge["relationship_type"], + "valid_from": included_edge_valid_from.isoformat(), + "valid_to": None, + "source_memory_ids": included_entity_edge["source_memory_ids"], + "created_at": payload["context_pack"]["entity_edges"][0]["created_at"], + } + ] + assert payload["context_pack"]["entity_edge_summary"] == { + "anchor_entity_count": 1, + "candidate_count": 2, + "included_count": 1, + "excluded_limit_count": 1, + } + + trace_id = UUID(payload["trace_id"]) + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + trace = store.get_trace(trace_id) + trace_events = store.list_trace_events(trace_id) + + assert trace["thread_id"] == thread_id + assert trace["kind"] == "context.compile" + assert trace["limits"] == { + "max_sessions": 1, + "max_events": 1, + "max_memories": 1, + "max_entities": 1, + "max_entity_edges": 1, + } + assert trace_events[0]["kind"] == "context.included" + assert trace_events[-1]["kind"] == "context.summary" + assert any( + event["payload"]["reason"] == "session_limit_exceeded" + for event in trace_events + if event["kind"] == "context.excluded" + ) + assert any( + event["payload"]["reason"] == "event_limit_exceeded" + for event in trace_events + if event["kind"] == "context.excluded" + ) + assert any( + event["payload"]["reason"] == "hybrid_memory_deleted" + for event in trace_events + if event["kind"] == "context.excluded" + ) + assert any( + event["payload"]["reason"] == "within_hybrid_memory_limit" + and event["payload"]["memory_key"] == "user.preference.coffee" + and event["payload"]["selected_sources"] == ["symbolic"] + for event in trace_events + if event["kind"] == "context.included" + ) + assert any( + event["payload"]["reason"] == "entity_limit_exceeded" + for event in trace_events + if event["kind"] == "context.excluded" + ) + assert any( + event["payload"]["reason"] == "within_entity_limit" + and event["payload"]["name"] == included_entity["name"] + and event["payload"]["record_entity_type"] == included_entity["entity_type"] + for event in trace_events + if event["kind"] == "context.included" + ) + assert any( + event["payload"]["reason"] == "entity_edge_limit_exceeded" + and event["payload"]["entity_id"] == str(excluded_entity_edge["id"]) + for event in trace_events + if event["kind"] == "context.excluded" + ) + assert any( + event["payload"]["reason"] == "within_entity_edge_limit" + and event["payload"]["entity_id"] == str(included_entity_edge["id"]) + and event["payload"]["valid_from"] == included_edge_valid_from.isoformat() + for event in trace_events + if event["kind"] == "context.included" + ) + assert all( + event["payload"].get("entity_id") != str(ignored_entity_edge["id"]) + for event in trace_events + ) + assert trace_events[-1]["payload"]["included_memory_count"] == 1 + assert trace_events[-1]["payload"]["excluded_deleted_memory_count"] == 1 + assert trace_events[-1]["payload"]["excluded_memory_limit_count"] == 0 + assert trace_events[-1]["payload"]["hybrid_memory_requested"] is False + assert trace_events[-1]["payload"]["hybrid_memory_candidate_count"] == 2 + assert trace_events[-1]["payload"]["hybrid_memory_merged_candidate_count"] == 1 + assert trace_events[-1]["payload"]["hybrid_memory_deduplicated_count"] == 0 + assert trace_events[-1]["payload"]["artifact_lexical_retrieval_requested"] is False + assert trace_events[-1]["payload"]["artifact_semantic_retrieval_requested"] is False + assert trace_events[-1]["payload"]["artifact_lexical_candidate_count"] == 0 + assert trace_events[-1]["payload"]["artifact_semantic_candidate_count"] == 0 + assert trace_events[-1]["payload"]["artifact_merged_candidate_count"] == 0 + assert trace_events[-1]["payload"]["artifact_deduplicated_count"] == 0 + assert trace_events[-1]["payload"]["included_dual_source_artifact_chunk_count"] == 0 + assert trace_events[-1]["payload"]["included_entity_count"] == 1 + assert trace_events[-1]["payload"]["excluded_entity_limit_count"] == 2 + assert trace_events[-1]["payload"]["included_entity_edge_count"] == 1 + assert trace_events[-1]["payload"]["excluded_entity_edge_limit_count"] == 1 + + with psycopg.connect(migrated_database_urls["admin"]) as conn: + with conn.cursor() as cur: + with pytest.raises(psycopg.Error, match="append-only"): + cur.execute("UPDATE trace_events SET kind = 'mutated' WHERE trace_id = %s", (trace_id,)) + + +def test_compile_context_prefers_updated_active_memory_within_same_transaction( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_thread_with_updated_active_memory(migrated_database_urls["app"]) + user_id = seeded["user_id"] + thread_id = seeded["thread_id"] + event_ids = seeded["event_ids"] + entities = seeded["entities"] + excluded_entity = entities[0] + included_edge = seeded["included_edge"] + included_edge_valid_from = seeded["included_edge_valid_from"] + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + status_code, payload = invoke_compile_context( + { + "user_id": str(user_id), + "thread_id": str(thread_id), + "max_sessions": 1, + "max_events": 2, + "max_memories": 1, + "max_entities": 1, + "max_entity_edges": 1, + } + ) + + assert status_code == 200 + assert payload["trace_event_count"] > 0 + assert payload["context_pack"]["memories"] == [ + { + "id": payload["context_pack"]["memories"][0]["id"], + "memory_key": "user.preference.coffee", + "value": {"likes": "oat milk"}, + "status": "active", + "source_event_ids": [str(event_ids[1])], + "memory_type": "preference", + "confidence": None, + "salience": None, + "confirmation_status": "unconfirmed", + "trust_class": "deterministic", + "promotion_eligibility": "promotable", + "evidence_count": None, + "independent_source_count": None, + "extracted_by_model": None, + "trust_reason": None, + "valid_from": None, + "valid_to": None, + "last_confirmed_at": None, + "created_at": payload["context_pack"]["memories"][0]["created_at"], + "updated_at": payload["context_pack"]["memories"][0]["updated_at"], + "source_provenance": {"sources": ["symbolic"], "semantic_score": None}, + } + ] + assert payload["context_pack"]["memory_summary"] == { + "candidate_count": 1, + "included_count": 1, + "excluded_deleted_count": 0, + "excluded_limit_count": 0, + "hybrid_retrieval": { + "requested": False, + "embedding_config_id": None, + "query_vector_dimensions": 0, + "semantic_limit": 0, + "symbolic_selected_count": 1, + "semantic_selected_count": 0, + "merged_candidate_count": 1, + "deduplicated_count": 0, + "included_symbolic_only_count": 1, + "included_semantic_only_count": 0, + "included_dual_source_count": 0, + "similarity_metric": None, + "source_precedence": ["symbolic", "semantic"], + "symbolic_order": ["updated_at_asc", "created_at_asc", "id_asc"], + "semantic_order": ["score_desc", "created_at_asc", "id_asc"], + }, + } + assert payload["context_pack"]["artifact_chunks"] == [] + assert payload["context_pack"]["artifact_chunk_summary"] == { + "requested": False, + "lexical_requested": False, + "semantic_requested": False, + "scope": None, + "query": None, + "query_terms": [], + "embedding_config_id": None, + "query_vector_dimensions": 0, + "limit": 0, + "lexical_limit": 0, + "semantic_limit": 0, + "searched_artifact_count": 0, + "lexical_candidate_count": 0, + "semantic_candidate_count": 0, + "merged_candidate_count": 0, + "deduplicated_count": 0, + "included_count": 0, + "included_lexical_only_count": 0, + "included_semantic_only_count": 0, + "included_dual_source_count": 0, + "excluded_uningested_artifact_count": 0, + "excluded_limit_count": 0, + "matching_rule": None, + "similarity_metric": None, + "source_precedence": ["lexical", "semantic"], + "lexical_order": [ + "matched_query_term_count_desc", + "first_match_char_start_asc", + "relative_path_asc", + "sequence_no_asc", + "id_asc", + ], + "semantic_order": ["score_desc", "relative_path_asc", "sequence_no_asc", "id_asc"], + "merged_order": [ + "source_precedence_asc", + "lexical_rank_asc", + "semantic_rank_asc", + "relative_path_asc", + "sequence_no_asc", + "id_asc", + ], + } + assert payload["context_pack"]["entity_summary"] == { + "candidate_count": 2, + "included_count": 1, + "excluded_limit_count": 1, + } + assert payload["context_pack"]["entity_edges"] == [ + { + "id": str(included_edge["id"]), + "from_entity_id": str(included_edge["from_entity_id"]), + "to_entity_id": str(included_edge["to_entity_id"]), + "relationship_type": included_edge["relationship_type"], + "valid_from": included_edge_valid_from.isoformat(), + "valid_to": None, + "source_memory_ids": included_edge["source_memory_ids"], + "created_at": payload["context_pack"]["entity_edges"][0]["created_at"], + } + ] + assert payload["context_pack"]["entity_edge_summary"] == { + "anchor_entity_count": 1, + "candidate_count": 1, + "included_count": 1, + "excluded_limit_count": 0, + } + + trace_id = UUID(payload["trace_id"]) + with user_connection(migrated_database_urls["app"], user_id) as conn: + trace_events = ContinuityStore(conn).list_trace_events(trace_id) + + assert any( + event["payload"]["reason"] == "within_hybrid_memory_limit" + and event["payload"]["memory_key"] == "user.preference.coffee" + for event in trace_events + if event["kind"] == "context.included" + ) + assert any( + event["payload"]["reason"] == "entity_limit_exceeded" + and event["payload"]["name"] == excluded_entity["name"] + for event in trace_events + if event["kind"] == "context.excluded" + ) + assert any( + event["payload"]["reason"] == "within_entity_edge_limit" + and event["payload"]["entity_id"] == str(included_edge["id"]) + for event in trace_events + if event["kind"] == "context.included" + ) + + +def test_compile_context_endpoint_merges_hybrid_memory_provenance_and_trace_events( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_traceable_thread(migrated_database_urls["app"]) + user_id = seeded["user_id"] + thread_id = seeded["thread_id"] + memories = seeded["memories"] + config_id = seed_embedding_config_for_user( + migrated_database_urls["app"], + user_id=user_id, + ) + seed_memory_embedding_for_user( + migrated_database_urls["app"], + user_id=user_id, + memory_id=memories["breakfast"]["id"], + embedding_config_id=config_id, + vector=[1.0, 0.0, 0.0], + ) + seed_memory_embedding_for_user( + migrated_database_urls["app"], + user_id=user_id, + memory_id=memories["coffee"]["id"], + embedding_config_id=config_id, + vector=[1.0, 0.0, 0.0], + ) + seed_memory_embedding_for_user( + migrated_database_urls["app"], + user_id=user_id, + memory_id=memories["deleted"]["id"], + embedding_config_id=config_id, + vector=[1.0, 0.0, 0.0], + ) + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + status_code, payload = invoke_compile_context( + { + "user_id": str(user_id), + "thread_id": str(thread_id), + "max_sessions": 1, + "max_events": 1, + "max_memories": 1, + "max_entities": 1, + "max_entity_edges": 1, + "semantic": { + "embedding_config_id": str(config_id), + "query_vector": [1.0, 0.0, 0.0], + "limit": 2, + }, + } + ) + + assert status_code == 200 + assert payload["trace_event_count"] > 0 + assert payload["context_pack"]["memories"] == [ + { + "id": str(memories["coffee"]["id"]), + "memory_key": "user.preference.coffee", + "value": {"likes": "oat milk"}, + "status": "active", + "source_event_ids": memories["coffee"]["source_event_ids"], + "memory_type": memories["coffee"]["memory_type"], + "confidence": memories["coffee"]["confidence"], + "salience": memories["coffee"]["salience"], + "confirmation_status": memories["coffee"]["confirmation_status"], + "trust_class": memories["coffee"]["trust_class"], + "promotion_eligibility": memories["coffee"]["promotion_eligibility"], + "evidence_count": memories["coffee"]["evidence_count"], + "independent_source_count": memories["coffee"]["independent_source_count"], + "extracted_by_model": memories["coffee"]["extracted_by_model"], + "trust_reason": memories["coffee"]["trust_reason"], + "valid_from": ( + None + if memories["coffee"]["valid_from"] is None + else memories["coffee"]["valid_from"].isoformat() + ), + "valid_to": ( + None + if memories["coffee"]["valid_to"] is None + else memories["coffee"]["valid_to"].isoformat() + ), + "last_confirmed_at": ( + None + if memories["coffee"]["last_confirmed_at"] is None + else memories["coffee"]["last_confirmed_at"].isoformat() + ), + "created_at": memories["coffee"]["created_at"].isoformat(), + "updated_at": memories["coffee"]["updated_at"].isoformat(), + "source_provenance": { + "sources": ["symbolic", "semantic"], + "semantic_score": 1.0, + }, + } + ] + assert payload["context_pack"]["memory_summary"] == { + "candidate_count": 3, + "included_count": 1, + "excluded_deleted_count": 1, + "excluded_limit_count": 1, + "hybrid_retrieval": { + "requested": True, + "embedding_config_id": str(config_id), + "query_vector_dimensions": 3, + "semantic_limit": 2, + "symbolic_selected_count": 1, + "semantic_selected_count": 2, + "merged_candidate_count": 2, + "deduplicated_count": 1, + "included_symbolic_only_count": 0, + "included_semantic_only_count": 0, + "included_dual_source_count": 1, + "similarity_metric": "cosine_similarity", + "source_precedence": ["symbolic", "semantic"], + "symbolic_order": ["updated_at_asc", "created_at_asc", "id_asc"], + "semantic_order": ["score_desc", "created_at_asc", "id_asc"], + }, + } + + trace_id = UUID(payload["trace_id"]) + with user_connection(migrated_database_urls["app"], user_id) as conn: + trace_events = ContinuityStore(conn).list_trace_events(trace_id) + + assert any( + event["payload"]["reason"] == "within_hybrid_memory_limit" + and event["payload"]["entity_id"] == str(memories["coffee"]["id"]) + and event["payload"]["embedding_config_id"] == str(config_id) + and event["payload"]["semantic_score"] == 1.0 + and event["payload"]["selected_sources"] == ["symbolic", "semantic"] + for event in trace_events + if event["kind"] == "context.included" + ) + assert any( + event["payload"]["reason"] == "hybrid_memory_deduplicated" + and event["payload"]["entity_id"] == str(memories["coffee"]["id"]) + and event["payload"]["embedding_config_id"] == str(config_id) + and event["payload"]["semantic_score"] == 1.0 + for event in trace_events + if event["kind"] == "context.included" + ) + assert any( + event["payload"]["reason"] == "hybrid_memory_limit_exceeded" + and event["payload"]["entity_id"] == str(memories["breakfast"]["id"]) + and event["payload"]["embedding_config_id"] == str(config_id) + and event["payload"]["semantic_score"] == 1.0 + and event["payload"]["selected_sources"] == ["semantic"] + for event in trace_events + if event["kind"] == "context.excluded" + ) + assert any( + event["payload"]["reason"] == "hybrid_memory_deleted" + and event["payload"]["entity_id"] == str(memories["deleted"]["id"]) + and event["payload"]["embedding_config_id"] == str(config_id) + and event["payload"]["semantic_score"] is None + and event["payload"]["selected_sources"] == ["symbolic"] + for event in trace_events + if event["kind"] == "context.excluded" + ) + assert trace_events[-1]["payload"]["hybrid_memory_requested"] is True + assert trace_events[-1]["payload"]["hybrid_memory_candidate_count"] == 3 + assert trace_events[-1]["payload"]["hybrid_memory_merged_candidate_count"] == 2 + assert trace_events[-1]["payload"]["hybrid_memory_deduplicated_count"] == 1 + assert trace_events[-1]["payload"]["included_dual_source_memory_count"] == 1 + + +def test_compile_context_semantic_validation_rejects_missing_config_dimension_mismatch_and_cross_user_access( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_traceable_thread(migrated_database_urls["app"]) + intruder = seed_traceable_thread( + migrated_database_urls["app"], + email="intruder@example.com", + display_name="Intruder", + ) + owner_config_id = seed_embedding_config_for_user( + migrated_database_urls["app"], + user_id=owner["user_id"], + ) + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + missing_status, missing_payload = invoke_compile_context( + { + "user_id": str(owner["user_id"]), + "thread_id": str(owner["thread_id"]), + "semantic": { + "embedding_config_id": str(uuid4()), + "query_vector": [1.0, 0.0, 0.0], + "limit": 1, + }, + } + ) + mismatch_status, mismatch_payload = invoke_compile_context( + { + "user_id": str(owner["user_id"]), + "thread_id": str(owner["thread_id"]), + "semantic": { + "embedding_config_id": str(owner_config_id), + "query_vector": [1.0, 0.0], + "limit": 1, + }, + } + ) + cross_user_status, cross_user_payload = invoke_compile_context( + { + "user_id": str(intruder["user_id"]), + "thread_id": str(intruder["thread_id"]), + "semantic": { + "embedding_config_id": str(owner_config_id), + "query_vector": [1.0, 0.0, 0.0], + "limit": 1, + }, + } + ) + + assert missing_status == 400 + assert missing_payload["detail"].startswith( + "embedding_config_id must reference an existing embedding config owned by the user" + ) + assert mismatch_status == 400 + assert mismatch_payload["detail"] == "query_vector length must match embedding config dimensions (3): 2" + assert cross_user_status == 400 + assert cross_user_payload["detail"] == ( + "embedding_config_id must reference an existing embedding config owned by the user: " + f"{owner_config_id}" + ) + + +def test_compile_context_artifact_retrieval_integrates_chunks_traces_and_exclusion_rules( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_traceable_thread(migrated_database_urls["app"]) + artifact_scope = seed_compile_artifact_scope( + migrated_database_urls["app"], + user_id=seeded["user_id"], + thread_id=seeded["thread_id"], + ) + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + status_code, payload = invoke_compile_context( + { + "user_id": str(seeded["user_id"]), + "thread_id": str(seeded["thread_id"]), + "artifact_retrieval": { + "kind": "task", + "task_id": str(artifact_scope["task_id"]), + "query": "Alpha beta", + "limit": 2, + }, + } + ) + + assert status_code == 200 + assert payload["context_pack"]["artifact_chunks"] == [ + { + "id": str(artifact_scope["chunk_ids"]["docs"]), + "task_id": str(artifact_scope["task_id"]), + "task_artifact_id": str(artifact_scope["artifact_ids"]["docs"]), + "relative_path": "docs/a.txt", + "media_type": "text/plain", + "sequence_no": 1, + "char_start": 0, + "char_end_exclusive": 14, + "text": "beta alpha doc", + "source_provenance": { + "sources": ["lexical"], + "lexical_match": { + "matched_query_terms": ["alpha", "beta"], + "matched_query_term_count": 2, + "first_match_char_start": 0, + }, + "semantic_score": None, + }, + }, + { + "id": str(artifact_scope["chunk_ids"]["notes"]), + "task_id": str(artifact_scope["task_id"]), + "task_artifact_id": str(artifact_scope["artifact_ids"]["notes"]), + "relative_path": "notes/b.md", + "media_type": "text/markdown", + "sequence_no": 1, + "char_start": 0, + "char_end_exclusive": 15, + "text": "alpha beta note", + "source_provenance": { + "sources": ["lexical"], + "lexical_match": { + "matched_query_terms": ["alpha", "beta"], + "matched_query_term_count": 2, + "first_match_char_start": 0, + }, + "semantic_score": None, + }, + }, + ] + assert payload["context_pack"]["artifact_chunk_summary"] == { + "requested": True, + "lexical_requested": True, + "semantic_requested": False, + "scope": {"kind": "task", "task_id": str(artifact_scope["task_id"])}, + "query": "Alpha beta", + "query_terms": ["alpha", "beta"], + "embedding_config_id": None, + "query_vector_dimensions": 0, + "limit": 2, + "lexical_limit": 2, + "semantic_limit": 0, + "searched_artifact_count": 3, + "lexical_candidate_count": 3, + "semantic_candidate_count": 0, + "merged_candidate_count": 3, + "deduplicated_count": 0, + "included_count": 2, + "included_lexical_only_count": 2, + "included_semantic_only_count": 0, + "included_dual_source_count": 0, + "excluded_uningested_artifact_count": 1, + "excluded_limit_count": 1, + "matching_rule": "casefolded_unicode_word_overlap_unique_query_terms_v1", + "similarity_metric": None, + "source_precedence": ["lexical", "semantic"], + "lexical_order": [ + "matched_query_term_count_desc", + "first_match_char_start_asc", + "relative_path_asc", + "sequence_no_asc", + "id_asc", + ], + "semantic_order": ["score_desc", "relative_path_asc", "sequence_no_asc", "id_asc"], + "merged_order": [ + "source_precedence_asc", + "lexical_rank_asc", + "semantic_rank_asc", + "relative_path_asc", + "sequence_no_asc", + "id_asc", + ], + } + assert payload["context_pack"]["memories"] + assert payload["context_pack"]["entities"] + + trace_id = UUID(payload["trace_id"]) + with user_connection(migrated_database_urls["app"], seeded["user_id"]) as conn: + trace_events = ContinuityStore(conn).list_trace_events(trace_id) + + assert any( + event["payload"]["reason"] == "within_hybrid_artifact_chunk_limit" + and event["payload"]["entity_id"] == str(artifact_scope["chunk_ids"]["docs"]) + and event["payload"]["relative_path"] == "docs/a.txt" + and event["payload"]["matched_query_terms"] == ["alpha", "beta"] + for event in trace_events + if event["kind"] == "context.included" + ) + assert any( + event["payload"]["reason"] == "within_hybrid_artifact_chunk_limit" + and event["payload"]["entity_id"] == str(artifact_scope["chunk_ids"]["notes"]) + and event["payload"]["relative_path"] == "notes/b.md" + for event in trace_events + if event["kind"] == "context.included" + ) + assert any( + event["payload"]["reason"] == "hybrid_artifact_chunk_limit_exceeded" + and event["payload"]["entity_id"] == str(artifact_scope["chunk_ids"]["weak"]) + and event["payload"]["relative_path"] == "notes/c.txt" + for event in trace_events + if event["kind"] == "context.excluded" + ) + assert any( + event["payload"]["reason"] == "hybrid_artifact_not_ingested" + and event["payload"]["entity_id"] == str(artifact_scope["artifact_ids"]["pending"]) + and event["payload"]["relative_path"] == "notes/hidden.txt" + and event["payload"]["ingestion_status"] == "pending" + for event in trace_events + if event["kind"] == "context.excluded" + ) + assert trace_events[-1]["payload"]["artifact_retrieval_requested"] is True + assert trace_events[-1]["payload"]["artifact_retrieval_scope_kind"] == "task" + assert trace_events[-1]["payload"]["artifact_lexical_retrieval_requested"] is True + assert trace_events[-1]["payload"]["artifact_semantic_retrieval_requested"] is False + assert trace_events[-1]["payload"]["artifact_lexical_candidate_count"] == 3 + assert trace_events[-1]["payload"]["artifact_semantic_candidate_count"] == 0 + assert trace_events[-1]["payload"]["artifact_merged_candidate_count"] == 3 + assert trace_events[-1]["payload"]["artifact_deduplicated_count"] == 0 + assert trace_events[-1]["payload"]["included_artifact_chunk_count"] == 2 + assert trace_events[-1]["payload"]["included_dual_source_artifact_chunk_count"] == 0 + assert trace_events[-1]["payload"]["excluded_artifact_chunk_limit_count"] == 1 + assert trace_events[-1]["payload"]["excluded_uningested_artifact_count"] == 1 + + +def test_compile_context_artifact_scoped_retrieval_returns_only_visible_artifact_chunks( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_traceable_thread(migrated_database_urls["app"]) + artifact_scope = seed_compile_artifact_scope( + migrated_database_urls["app"], + user_id=seeded["user_id"], + thread_id=seeded["thread_id"], + ) + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + status_code, payload = invoke_compile_context( + { + "user_id": str(seeded["user_id"]), + "thread_id": str(seeded["thread_id"]), + "artifact_retrieval": { + "kind": "artifact", + "task_artifact_id": str(artifact_scope["artifact_ids"]["notes"]), + "query": "Alpha beta", + "limit": 2, + }, + } + ) + + assert status_code == 200 + assert payload["context_pack"]["artifact_chunks"] == [ + { + "id": str(artifact_scope["chunk_ids"]["notes"]), + "task_id": str(artifact_scope["task_id"]), + "task_artifact_id": str(artifact_scope["artifact_ids"]["notes"]), + "relative_path": "notes/b.md", + "media_type": "text/markdown", + "sequence_no": 1, + "char_start": 0, + "char_end_exclusive": 15, + "text": "alpha beta note", + "source_provenance": { + "sources": ["lexical"], + "lexical_match": { + "matched_query_terms": ["alpha", "beta"], + "matched_query_term_count": 2, + "first_match_char_start": 0, + }, + "semantic_score": None, + }, + } + ] + assert payload["context_pack"]["artifact_chunk_summary"] == { + "requested": True, + "lexical_requested": True, + "semantic_requested": False, + "scope": { + "kind": "artifact", + "task_id": str(artifact_scope["task_id"]), + "task_artifact_id": str(artifact_scope["artifact_ids"]["notes"]), + }, + "query": "Alpha beta", + "query_terms": ["alpha", "beta"], + "embedding_config_id": None, + "query_vector_dimensions": 0, + "limit": 2, + "lexical_limit": 2, + "semantic_limit": 0, + "searched_artifact_count": 1, + "lexical_candidate_count": 1, + "semantic_candidate_count": 0, + "merged_candidate_count": 1, + "deduplicated_count": 0, + "included_count": 1, + "included_lexical_only_count": 1, + "included_semantic_only_count": 0, + "included_dual_source_count": 0, + "excluded_uningested_artifact_count": 0, + "excluded_limit_count": 0, + "matching_rule": "casefolded_unicode_word_overlap_unique_query_terms_v1", + "similarity_metric": None, + "source_precedence": ["lexical", "semantic"], + "lexical_order": [ + "matched_query_term_count_desc", + "first_match_char_start_asc", + "relative_path_asc", + "sequence_no_asc", + "id_asc", + ], + "semantic_order": ["score_desc", "relative_path_asc", "sequence_no_asc", "id_asc"], + "merged_order": [ + "source_precedence_asc", + "lexical_rank_asc", + "semantic_rank_asc", + "relative_path_asc", + "sequence_no_asc", + "id_asc", + ], + } + + trace_id = UUID(payload["trace_id"]) + with user_connection(migrated_database_urls["app"], seeded["user_id"]) as conn: + trace_events = ContinuityStore(conn).list_trace_events(trace_id) + + assert any( + event["payload"]["reason"] == "within_hybrid_artifact_chunk_limit" + and event["payload"]["entity_id"] == str(artifact_scope["chunk_ids"]["notes"]) + and event["payload"]["scope_kind"] == "artifact" + and event["payload"]["task_artifact_id"] == str(artifact_scope["artifact_ids"]["notes"]) + for event in trace_events + if event["kind"] == "context.included" + ) + assert trace_events[-1]["payload"]["artifact_retrieval_requested"] is True + assert trace_events[-1]["payload"]["artifact_retrieval_scope_kind"] == "artifact" + assert trace_events[-1]["payload"]["artifact_lexical_retrieval_requested"] is True + assert trace_events[-1]["payload"]["artifact_semantic_retrieval_requested"] is False + assert trace_events[-1]["payload"]["artifact_lexical_candidate_count"] == 1 + assert trace_events[-1]["payload"]["artifact_semantic_candidate_count"] == 0 + assert trace_events[-1]["payload"]["artifact_merged_candidate_count"] == 1 + assert trace_events[-1]["payload"]["artifact_deduplicated_count"] == 0 + assert trace_events[-1]["payload"]["included_artifact_chunk_count"] == 1 + assert trace_events[-1]["payload"]["included_dual_source_artifact_chunk_count"] == 0 + assert trace_events[-1]["payload"]["excluded_artifact_chunk_limit_count"] == 0 + assert trace_events[-1]["payload"]["excluded_uningested_artifact_count"] == 0 + + +def test_compile_context_hybrid_artifact_merge_preserves_dual_source_provenance_and_limits( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_traceable_thread(migrated_database_urls["app"]) + artifact_scope = seed_compile_artifact_scope( + migrated_database_urls["app"], + user_id=seeded["user_id"], + thread_id=seeded["thread_id"], + ) + config_id = seed_embedding_config_for_user( + migrated_database_urls["app"], + user_id=seeded["user_id"], + ) + seed_task_artifact_chunk_embedding_for_user( + migrated_database_urls["app"], + user_id=seeded["user_id"], + task_artifact_chunk_id=artifact_scope["chunk_ids"]["docs"], + embedding_config_id=config_id, + vector=[1.0, 0.0, 0.0], + ) + seed_task_artifact_chunk_embedding_for_user( + migrated_database_urls["app"], + user_id=seeded["user_id"], + task_artifact_chunk_id=artifact_scope["chunk_ids"]["notes"], + embedding_config_id=config_id, + vector=[1.0, 0.0, 0.0], + ) + seed_task_artifact_chunk_embedding_for_user( + migrated_database_urls["app"], + user_id=seeded["user_id"], + task_artifact_chunk_id=artifact_scope["chunk_ids"]["weak"], + embedding_config_id=config_id, + vector=[0.0, 1.0, 0.0], + ) + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + status_code, payload = invoke_compile_context( + { + "user_id": str(seeded["user_id"]), + "thread_id": str(seeded["thread_id"]), + "artifact_retrieval": { + "kind": "task", + "task_id": str(artifact_scope["task_id"]), + "query": "Alpha beta", + "limit": 2, + }, + "semantic_artifact_retrieval": { + "kind": "task", + "task_id": str(artifact_scope["task_id"]), + "embedding_config_id": str(config_id), + "query_vector": [1.0, 0.0, 0.0], + "limit": 2, + }, + } + ) + + assert status_code == 200 + assert payload["context_pack"]["artifact_chunks"] == [ + { + "id": str(artifact_scope["chunk_ids"]["docs"]), + "task_id": str(artifact_scope["task_id"]), + "task_artifact_id": str(artifact_scope["artifact_ids"]["docs"]), + "relative_path": "docs/a.txt", + "media_type": "text/plain", + "sequence_no": 1, + "char_start": 0, + "char_end_exclusive": 14, + "text": "beta alpha doc", + "source_provenance": { + "sources": ["lexical", "semantic"], + "lexical_match": { + "matched_query_terms": ["alpha", "beta"], + "matched_query_term_count": 2, + "first_match_char_start": 0, + }, + "semantic_score": 1.0, + }, + }, + { + "id": str(artifact_scope["chunk_ids"]["notes"]), + "task_id": str(artifact_scope["task_id"]), + "task_artifact_id": str(artifact_scope["artifact_ids"]["notes"]), + "relative_path": "notes/b.md", + "media_type": "text/markdown", + "sequence_no": 1, + "char_start": 0, + "char_end_exclusive": 15, + "text": "alpha beta note", + "source_provenance": { + "sources": ["lexical", "semantic"], + "lexical_match": { + "matched_query_terms": ["alpha", "beta"], + "matched_query_term_count": 2, + "first_match_char_start": 0, + }, + "semantic_score": 1.0, + }, + }, + ] + assert payload["context_pack"]["artifact_chunk_summary"] == { + "requested": True, + "lexical_requested": True, + "semantic_requested": True, + "scope": {"kind": "task", "task_id": str(artifact_scope["task_id"])}, + "query": "Alpha beta", + "query_terms": ["alpha", "beta"], + "embedding_config_id": str(config_id), + "query_vector_dimensions": 3, + "limit": 2, + "lexical_limit": 2, + "semantic_limit": 2, + "searched_artifact_count": 3, + "lexical_candidate_count": 3, + "semantic_candidate_count": 3, + "merged_candidate_count": 3, + "deduplicated_count": 3, + "included_count": 2, + "included_lexical_only_count": 0, + "included_semantic_only_count": 0, + "included_dual_source_count": 2, + "excluded_uningested_artifact_count": 1, + "excluded_limit_count": 1, + "matching_rule": "casefolded_unicode_word_overlap_unique_query_terms_v1", + "similarity_metric": "cosine_similarity", + "source_precedence": ["lexical", "semantic"], + "lexical_order": [ + "matched_query_term_count_desc", + "first_match_char_start_asc", + "relative_path_asc", + "sequence_no_asc", + "id_asc", + ], + "semantic_order": ["score_desc", "relative_path_asc", "sequence_no_asc", "id_asc"], + "merged_order": [ + "source_precedence_asc", + "lexical_rank_asc", + "semantic_rank_asc", + "relative_path_asc", + "sequence_no_asc", + "id_asc", + ], + } + + trace_id = UUID(payload["trace_id"]) + with user_connection(migrated_database_urls["app"], seeded["user_id"]) as conn: + trace_events = ContinuityStore(conn).list_trace_events(trace_id) + + assert any( + event["payload"]["reason"] == "hybrid_artifact_chunk_deduplicated" + and event["payload"]["entity_id"] == str(artifact_scope["chunk_ids"]["docs"]) + and event["payload"]["selected_sources"] == ["lexical", "semantic"] + and event["payload"]["score"] == 1.0 + for event in trace_events + if event["kind"] == "context.included" + ) + assert any( + event["payload"]["reason"] == "within_hybrid_artifact_chunk_limit" + and event["payload"]["entity_id"] == str(artifact_scope["chunk_ids"]["notes"]) + and event["payload"]["selected_sources"] == ["lexical", "semantic"] + for event in trace_events + if event["kind"] == "context.included" + ) + assert any( + event["payload"]["reason"] == "hybrid_artifact_chunk_limit_exceeded" + and event["payload"]["entity_id"] == str(artifact_scope["chunk_ids"]["weak"]) + and event["payload"]["selected_sources"] == ["lexical", "semantic"] + and event["payload"]["score"] == 0.0 + for event in trace_events + if event["kind"] == "context.excluded" + ) + assert trace_events[-1]["payload"]["artifact_retrieval_requested"] is True + assert trace_events[-1]["payload"]["artifact_retrieval_scope_kind"] == "task" + assert trace_events[-1]["payload"]["artifact_lexical_retrieval_requested"] is True + assert trace_events[-1]["payload"]["artifact_semantic_retrieval_requested"] is True + assert trace_events[-1]["payload"]["artifact_lexical_candidate_count"] == 3 + assert trace_events[-1]["payload"]["artifact_semantic_candidate_count"] == 3 + assert trace_events[-1]["payload"]["artifact_merged_candidate_count"] == 3 + assert trace_events[-1]["payload"]["artifact_deduplicated_count"] == 3 + assert trace_events[-1]["payload"]["included_artifact_chunk_count"] == 2 + assert trace_events[-1]["payload"]["included_dual_source_artifact_chunk_count"] == 2 + assert trace_events[-1]["payload"]["excluded_artifact_chunk_limit_count"] == 1 + assert trace_events[-1]["payload"]["excluded_uningested_artifact_count"] == 1 + + +def test_compile_context_semantic_artifact_retrieval_integrates_chunks_traces_and_exclusion_rules( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_traceable_thread(migrated_database_urls["app"]) + artifact_scope = seed_compile_artifact_scope( + migrated_database_urls["app"], + user_id=seeded["user_id"], + thread_id=seeded["thread_id"], + ) + config_id = seed_embedding_config_for_user( + migrated_database_urls["app"], + user_id=seeded["user_id"], + ) + seed_task_artifact_chunk_embedding_for_user( + migrated_database_urls["app"], + user_id=seeded["user_id"], + task_artifact_chunk_id=artifact_scope["chunk_ids"]["docs"], + embedding_config_id=config_id, + vector=[1.0, 0.0, 0.0], + ) + seed_task_artifact_chunk_embedding_for_user( + migrated_database_urls["app"], + user_id=seeded["user_id"], + task_artifact_chunk_id=artifact_scope["chunk_ids"]["notes"], + embedding_config_id=config_id, + vector=[1.0, 0.0, 0.0], + ) + seed_task_artifact_chunk_embedding_for_user( + migrated_database_urls["app"], + user_id=seeded["user_id"], + task_artifact_chunk_id=artifact_scope["chunk_ids"]["weak"], + embedding_config_id=config_id, + vector=[0.0, 1.0, 0.0], + ) + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + status_code, payload = invoke_compile_context( + { + "user_id": str(seeded["user_id"]), + "thread_id": str(seeded["thread_id"]), + "semantic_artifact_retrieval": { + "kind": "task", + "task_id": str(artifact_scope["task_id"]), + "embedding_config_id": str(config_id), + "query_vector": [1.0, 0.0, 0.0], + "limit": 2, + }, + } + ) + + assert status_code == 200 + assert payload["context_pack"]["artifact_chunks"] == [ + { + "id": str(artifact_scope["chunk_ids"]["docs"]), + "task_id": str(artifact_scope["task_id"]), + "task_artifact_id": str(artifact_scope["artifact_ids"]["docs"]), + "relative_path": "docs/a.txt", + "media_type": "text/plain", + "sequence_no": 1, + "char_start": 0, + "char_end_exclusive": 14, + "text": "beta alpha doc", + "source_provenance": { + "sources": ["semantic"], + "lexical_match": None, + "semantic_score": 1.0, + }, + }, + { + "id": str(artifact_scope["chunk_ids"]["notes"]), + "task_id": str(artifact_scope["task_id"]), + "task_artifact_id": str(artifact_scope["artifact_ids"]["notes"]), + "relative_path": "notes/b.md", + "media_type": "text/markdown", + "sequence_no": 1, + "char_start": 0, + "char_end_exclusive": 15, + "text": "alpha beta note", + "source_provenance": { + "sources": ["semantic"], + "lexical_match": None, + "semantic_score": 1.0, + }, + }, + ] + assert payload["context_pack"]["artifact_chunk_summary"] == { + "requested": True, + "lexical_requested": False, + "semantic_requested": True, + "scope": {"kind": "task", "task_id": str(artifact_scope["task_id"])}, + "query": None, + "query_terms": [], + "embedding_config_id": str(config_id), + "query_vector_dimensions": 3, + "limit": 2, + "lexical_limit": 0, + "semantic_limit": 2, + "searched_artifact_count": 3, + "lexical_candidate_count": 0, + "semantic_candidate_count": 3, + "merged_candidate_count": 3, + "deduplicated_count": 0, + "included_count": 2, + "included_lexical_only_count": 0, + "included_semantic_only_count": 2, + "included_dual_source_count": 0, + "excluded_uningested_artifact_count": 1, + "excluded_limit_count": 1, + "matching_rule": None, + "similarity_metric": "cosine_similarity", + "source_precedence": ["lexical", "semantic"], + "lexical_order": [ + "matched_query_term_count_desc", + "first_match_char_start_asc", + "relative_path_asc", + "sequence_no_asc", + "id_asc", + ], + "semantic_order": ["score_desc", "relative_path_asc", "sequence_no_asc", "id_asc"], + "merged_order": [ + "source_precedence_asc", + "lexical_rank_asc", + "semantic_rank_asc", + "relative_path_asc", + "sequence_no_asc", + "id_asc", + ], + } + + trace_id = UUID(payload["trace_id"]) + with user_connection(migrated_database_urls["app"], seeded["user_id"]) as conn: + trace_events = ContinuityStore(conn).list_trace_events(trace_id) + + assert any( + event["payload"]["reason"] == "within_hybrid_artifact_chunk_limit" + and event["payload"]["entity_id"] == str(artifact_scope["chunk_ids"]["docs"]) + and event["payload"]["relative_path"] == "docs/a.txt" + and event["payload"]["score"] == 1.0 + for event in trace_events + if event["kind"] == "context.included" + ) + assert any( + event["payload"]["reason"] == "within_hybrid_artifact_chunk_limit" + and event["payload"]["entity_id"] == str(artifact_scope["chunk_ids"]["notes"]) + and event["payload"]["relative_path"] == "notes/b.md" + for event in trace_events + if event["kind"] == "context.included" + ) + assert any( + event["payload"]["reason"] == "hybrid_artifact_chunk_limit_exceeded" + and event["payload"]["entity_id"] == str(artifact_scope["chunk_ids"]["weak"]) + and event["payload"]["relative_path"] == "notes/c.txt" + and event["payload"]["score"] == 0.0 + for event in trace_events + if event["kind"] == "context.excluded" + ) + assert any( + event["payload"]["reason"] == "hybrid_artifact_not_ingested" + and event["payload"]["entity_id"] == str(artifact_scope["artifact_ids"]["pending"]) + and event["payload"]["relative_path"] == "notes/hidden.txt" + and event["payload"]["ingestion_status"] == "pending" + for event in trace_events + if event["kind"] == "context.excluded" + ) + assert trace_events[-1]["payload"]["artifact_retrieval_requested"] is True + assert trace_events[-1]["payload"]["artifact_retrieval_scope_kind"] == "task" + assert trace_events[-1]["payload"]["artifact_lexical_retrieval_requested"] is False + assert trace_events[-1]["payload"]["artifact_semantic_retrieval_requested"] is True + assert trace_events[-1]["payload"]["artifact_lexical_candidate_count"] == 0 + assert trace_events[-1]["payload"]["artifact_semantic_candidate_count"] == 3 + assert trace_events[-1]["payload"]["artifact_merged_candidate_count"] == 3 + assert trace_events[-1]["payload"]["artifact_deduplicated_count"] == 0 + assert trace_events[-1]["payload"]["included_artifact_chunk_count"] == 2 + assert trace_events[-1]["payload"]["included_dual_source_artifact_chunk_count"] == 0 + assert trace_events[-1]["payload"]["excluded_artifact_chunk_limit_count"] == 1 + assert trace_events[-1]["payload"]["excluded_uningested_artifact_count"] == 1 + + +def test_compile_context_semantic_artifact_scoped_retrieval_returns_only_visible_artifact_chunks( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_traceable_thread(migrated_database_urls["app"]) + artifact_scope = seed_compile_artifact_scope( + migrated_database_urls["app"], + user_id=seeded["user_id"], + thread_id=seeded["thread_id"], + ) + config_id = seed_embedding_config_for_user( + migrated_database_urls["app"], + user_id=seeded["user_id"], + ) + seed_task_artifact_chunk_embedding_for_user( + migrated_database_urls["app"], + user_id=seeded["user_id"], + task_artifact_chunk_id=artifact_scope["chunk_ids"]["notes"], + embedding_config_id=config_id, + vector=[1.0, 0.0, 0.0], + ) + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + status_code, payload = invoke_compile_context( + { + "user_id": str(seeded["user_id"]), + "thread_id": str(seeded["thread_id"]), + "semantic_artifact_retrieval": { + "kind": "artifact", + "task_artifact_id": str(artifact_scope["artifact_ids"]["notes"]), + "embedding_config_id": str(config_id), + "query_vector": [1.0, 0.0, 0.0], + "limit": 2, + }, + } + ) + + assert status_code == 200 + assert payload["context_pack"]["artifact_chunks"] == [ + { + "id": str(artifact_scope["chunk_ids"]["notes"]), + "task_id": str(artifact_scope["task_id"]), + "task_artifact_id": str(artifact_scope["artifact_ids"]["notes"]), + "relative_path": "notes/b.md", + "media_type": "text/markdown", + "sequence_no": 1, + "char_start": 0, + "char_end_exclusive": 15, + "text": "alpha beta note", + "source_provenance": { + "sources": ["semantic"], + "lexical_match": None, + "semantic_score": 1.0, + }, + } + ] + assert payload["context_pack"]["artifact_chunk_summary"] == { + "requested": True, + "lexical_requested": False, + "semantic_requested": True, + "scope": { + "kind": "artifact", + "task_id": str(artifact_scope["task_id"]), + "task_artifact_id": str(artifact_scope["artifact_ids"]["notes"]), + }, + "query": None, + "query_terms": [], + "embedding_config_id": str(config_id), + "query_vector_dimensions": 3, + "limit": 2, + "lexical_limit": 0, + "semantic_limit": 2, + "searched_artifact_count": 1, + "lexical_candidate_count": 0, + "semantic_candidate_count": 1, + "merged_candidate_count": 1, + "deduplicated_count": 0, + "included_count": 1, + "included_lexical_only_count": 0, + "included_semantic_only_count": 1, + "included_dual_source_count": 0, + "excluded_uningested_artifact_count": 0, + "excluded_limit_count": 0, + "matching_rule": None, + "similarity_metric": "cosine_similarity", + "source_precedence": ["lexical", "semantic"], + "lexical_order": [ + "matched_query_term_count_desc", + "first_match_char_start_asc", + "relative_path_asc", + "sequence_no_asc", + "id_asc", + ], + "semantic_order": ["score_desc", "relative_path_asc", "sequence_no_asc", "id_asc"], + "merged_order": [ + "source_precedence_asc", + "lexical_rank_asc", + "semantic_rank_asc", + "relative_path_asc", + "sequence_no_asc", + "id_asc", + ], + } + + trace_id = UUID(payload["trace_id"]) + with user_connection(migrated_database_urls["app"], seeded["user_id"]) as conn: + trace_events = ContinuityStore(conn).list_trace_events(trace_id) + + assert any( + event["payload"]["reason"] == "within_hybrid_artifact_chunk_limit" + and event["payload"]["entity_id"] == str(artifact_scope["chunk_ids"]["notes"]) + and event["payload"]["scope_kind"] == "artifact" + and event["payload"]["task_artifact_id"] == str(artifact_scope["artifact_ids"]["notes"]) + for event in trace_events + if event["kind"] == "context.included" + ) + assert trace_events[-1]["payload"]["artifact_retrieval_requested"] is True + assert trace_events[-1]["payload"]["artifact_retrieval_scope_kind"] == "artifact" + assert trace_events[-1]["payload"]["artifact_lexical_retrieval_requested"] is False + assert trace_events[-1]["payload"]["artifact_semantic_retrieval_requested"] is True + assert trace_events[-1]["payload"]["artifact_lexical_candidate_count"] == 0 + assert trace_events[-1]["payload"]["artifact_semantic_candidate_count"] == 1 + assert trace_events[-1]["payload"]["artifact_merged_candidate_count"] == 1 + assert trace_events[-1]["payload"]["artifact_deduplicated_count"] == 0 + assert trace_events[-1]["payload"]["included_artifact_chunk_count"] == 1 + assert trace_events[-1]["payload"]["included_dual_source_artifact_chunk_count"] == 0 + assert trace_events[-1]["payload"]["excluded_artifact_chunk_limit_count"] == 0 + assert trace_events[-1]["payload"]["excluded_uningested_artifact_count"] == 0 + + +def test_compile_context_semantic_artifact_retrieval_validation_and_isolation( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_traceable_thread(migrated_database_urls["app"]) + intruder = seed_traceable_thread( + migrated_database_urls["app"], + email="intruder@example.com", + display_name="Intruder", + ) + owner_artifact_scope = seed_compile_artifact_scope( + migrated_database_urls["app"], + user_id=owner["user_id"], + thread_id=owner["thread_id"], + ) + owner_config_id = seed_embedding_config_for_user( + migrated_database_urls["app"], + user_id=owner["user_id"], + ) + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + invalid_shape_status, invalid_shape_payload = invoke_compile_context( + { + "user_id": str(owner["user_id"]), + "thread_id": str(owner["thread_id"]), + "semantic_artifact_retrieval": { + "kind": "task", + "task_artifact_id": str(owner_artifact_scope["artifact_ids"]["docs"]), + "embedding_config_id": str(owner_config_id), + "query_vector": [1.0, 0.0, 0.0], + }, + } + ) + missing_status, missing_payload = invoke_compile_context( + { + "user_id": str(owner["user_id"]), + "thread_id": str(owner["thread_id"]), + "semantic_artifact_retrieval": { + "kind": "task", + "task_id": str(owner_artifact_scope["task_id"]), + "embedding_config_id": str(uuid4()), + "query_vector": [1.0, 0.0, 0.0], + "limit": 2, + }, + } + ) + mismatch_status, mismatch_payload = invoke_compile_context( + { + "user_id": str(owner["user_id"]), + "thread_id": str(owner["thread_id"]), + "semantic_artifact_retrieval": { + "kind": "task", + "task_id": str(owner_artifact_scope["task_id"]), + "embedding_config_id": str(owner_config_id), + "query_vector": [1.0, 0.0], + "limit": 2, + }, + } + ) + isolated_task_status, isolated_task_payload = invoke_compile_context( + { + "user_id": str(intruder["user_id"]), + "thread_id": str(intruder["thread_id"]), + "semantic_artifact_retrieval": { + "kind": "task", + "task_id": str(owner_artifact_scope["task_id"]), + "embedding_config_id": str(owner_config_id), + "query_vector": [1.0, 0.0, 0.0], + "limit": 2, + }, + } + ) + isolated_artifact_status, isolated_artifact_payload = invoke_compile_context( + { + "user_id": str(intruder["user_id"]), + "thread_id": str(intruder["thread_id"]), + "semantic_artifact_retrieval": { + "kind": "artifact", + "task_artifact_id": str(owner_artifact_scope["artifact_ids"]["docs"]), + "embedding_config_id": str(owner_config_id), + "query_vector": [1.0, 0.0, 0.0], + "limit": 2, + }, + } + ) + + assert invalid_shape_status == 422 + assert "task_id" in json.dumps(invalid_shape_payload) + assert missing_status == 400 + assert missing_payload["detail"].startswith( + "embedding_config_id must reference an existing embedding config owned by the user" + ) + assert mismatch_status == 400 + assert mismatch_payload["detail"] == "query_vector length must match embedding config dimensions (3): 2" + assert isolated_task_status == 404 + assert isolated_task_payload == { + "detail": f"task {owner_artifact_scope['task_id']} was not found" + } + assert isolated_artifact_status == 404 + assert isolated_artifact_payload == { + "detail": ( + "task artifact " + f"{owner_artifact_scope['artifact_ids']['docs']} was not found" + ) + } + + +def test_compile_context_artifact_retrieval_validation_and_isolation( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_traceable_thread(migrated_database_urls["app"]) + intruder = seed_traceable_thread( + migrated_database_urls["app"], + email="intruder@example.com", + display_name="Intruder", + ) + owner_artifact_scope = seed_compile_artifact_scope( + migrated_database_urls["app"], + user_id=owner["user_id"], + thread_id=owner["thread_id"], + ) + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + blank_query_status, blank_query_payload = invoke_compile_context( + { + "user_id": str(owner["user_id"]), + "thread_id": str(owner["thread_id"]), + "artifact_retrieval": { + "kind": "task", + "task_id": str(owner_artifact_scope["task_id"]), + "query": " ", + "limit": 2, + }, + } + ) + invalid_shape_status, invalid_shape_payload = invoke_compile_context( + { + "user_id": str(owner["user_id"]), + "thread_id": str(owner["thread_id"]), + "artifact_retrieval": { + "kind": "task", + "task_artifact_id": str(owner_artifact_scope["artifact_ids"]["docs"]), + "query": "alpha beta", + }, + } + ) + isolated_task_status, isolated_task_payload = invoke_compile_context( + { + "user_id": str(intruder["user_id"]), + "thread_id": str(intruder["thread_id"]), + "artifact_retrieval": { + "kind": "task", + "task_id": str(owner_artifact_scope["task_id"]), + "query": "alpha beta", + "limit": 2, + }, + } + ) + isolated_artifact_status, isolated_artifact_payload = invoke_compile_context( + { + "user_id": str(intruder["user_id"]), + "thread_id": str(intruder["thread_id"]), + "artifact_retrieval": { + "kind": "artifact", + "task_artifact_id": str(owner_artifact_scope["artifact_ids"]["docs"]), + "query": "alpha beta", + "limit": 2, + }, + } + ) + + assert blank_query_status == 400 + assert blank_query_payload == { + "detail": "artifact chunk retrieval query must include at least one word" + } + assert invalid_shape_status == 422 + assert "task_id" in json.dumps(invalid_shape_payload) + assert isolated_task_status == 404 + assert isolated_task_payload == { + "detail": f"task {owner_artifact_scope['task_id']} was not found" + } + assert isolated_artifact_status == 404 + assert isolated_artifact_payload == { + "detail": ( + "task artifact " + f"{owner_artifact_scope['artifact_ids']['docs']} was not found" + ) + } + + +def test_traces_and_trace_events_respect_per_user_isolation(migrated_database_urls, monkeypatch) -> None: + seeded = seed_traceable_thread(migrated_database_urls["app"]) + owner_id = seeded["user_id"] + thread_id = seeded["thread_id"] + owner_event_ids = seeded["event_ids"] + owner_entities = seeded["entities"] + owner_entity_edges = seeded["entity_edges"] + intruder_id = uuid4() + with user_connection(migrated_database_urls["app"], intruder_id) as conn: + store = ContinuityStore(conn) + store.create_user(intruder_id, "intruder@example.com", "Intruder") + intruder_thread = store.create_thread("Intruder thread") + intruder_session = store.create_session(intruder_thread["id"], status="active") + intruder_event = store.append_event( + intruder_thread["id"], + intruder_session["id"], + "message.user", + {"text": "intruder memory"}, + ) + store.create_memory( + memory_key="user.preference.coffee", + value={"likes": "black"}, + status="active", + source_event_ids=[str(intruder_event["id"])], + ) + intruder_memory = store.create_memory( + memory_key="user.preference.tea", + value={"likes": "green"}, + status="active", + source_event_ids=[str(intruder_event["id"])], + ) + store.create_entity( + entity_type="merchant", + name="Intruder Cafe", + source_memory_ids=[str(intruder_memory["id"])], + ) + intruder_project = store.create_entity( + entity_type="project", + name="Intruder Project", + source_memory_ids=[str(intruder_memory["id"])], + ) + store.create_entity_edge( + from_entity_id=intruder_project["id"], + to_entity_id=store.list_entities()[0]["id"], + relationship_type="hidden_from_owner", + valid_from=None, + valid_to=None, + source_memory_ids=[str(intruder_memory["id"])], + ) + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + status_code, payload = invoke_compile_context( + { + "user_id": str(owner_id), + "thread_id": str(thread_id), + } + ) + + assert status_code == 200 + trace_id = UUID(payload["trace_id"]) + assert [memory["source_event_ids"] for memory in payload["context_pack"]["memories"]] == [ + [str(owner_event_ids[0])], + [str(owner_event_ids[1])], + ] + assert [memory["source_provenance"] for memory in payload["context_pack"]["memories"]] == [ + {"sources": ["symbolic"], "semantic_score": None}, + {"sources": ["symbolic"], "semantic_score": None}, + ] + assert [entity["id"] for entity in payload["context_pack"]["entities"]] == [ + str(entity["id"]) for entity in owner_entities + ] + assert [edge["id"] for edge in payload["context_pack"]["entity_edges"]] == [ + str(edge["id"]) for edge in owner_entity_edges + ] + + with user_connection(migrated_database_urls["app"], intruder_id) as conn: + store = ContinuityStore(conn) + with conn.cursor() as cur: + cur.execute("SELECT COUNT(*) AS count FROM traces WHERE id = %s", (trace_id,)) + trace_count = cur.fetchone() + cur.execute("SELECT COUNT(*) AS count FROM trace_events WHERE trace_id = %s", (trace_id,)) + trace_event_count = cur.fetchone() + + assert trace_count["count"] == 0 + assert trace_event_count["count"] == 0 + assert store.list_trace_events(trace_id) == [] + + +def test_compile_context_scopes_memories_to_active_thread_profile( + migrated_database_urls, + monkeypatch, +) -> None: + user_id = uuid4() + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + store.create_user(user_id, "scoped@example.com", "Scoped User") + assistant_thread = store.create_thread("Assistant thread") + coach_thread = store.create_thread("Coach thread", agent_profile_id="coach_default") + assistant_session = store.create_session(assistant_thread["id"], status="active") + coach_session = store.create_session(coach_thread["id"], status="active") + assistant_event = store.append_event( + assistant_thread["id"], + assistant_session["id"], + "message.user", + {"text": "remember assistant preference"}, + ) + coach_event = store.append_event( + coach_thread["id"], + coach_session["id"], + "message.user", + {"text": "remember coaching preference"}, + ) + # Omitted profile attribution must default deterministically to assistant_default. + store.create_memory( + memory_key="user.preference.assistant.profile_scope", + value={"likes": "espresso"}, + status="active", + source_event_ids=[str(assistant_event["id"])], + ) + store.create_memory( + memory_key="user.preference.coach.profile_scope", + value={"likes": "pour over"}, + status="active", + source_event_ids=[str(coach_event["id"])], + agent_profile_id="coach_default", + ) + + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + assistant_status, assistant_payload = invoke_compile_context( + { + "user_id": str(user_id), + "thread_id": str(assistant_thread["id"]), + "max_memories": 10, + } + ) + coach_status, coach_payload = invoke_compile_context( + { + "user_id": str(user_id), + "thread_id": str(coach_thread["id"]), + "max_memories": 10, + } + ) + + assert assistant_status == 200 + assert assistant_payload["metadata"] == {"agent_profile_id": "assistant_default"} + assert [memory["memory_key"] for memory in assistant_payload["context_pack"]["memories"]] == [ + "user.preference.assistant.profile_scope" + ] + + assert coach_status == 200 + assert coach_payload["metadata"] == {"agent_profile_id": "coach_default"} + assert [memory["memory_key"] for memory in coach_payload["context_pack"]["memories"]] == [ + "user.preference.coach.profile_scope" + ] + + +def test_compile_context_scopes_semantic_memory_retrieval_to_active_thread_profile( + migrated_database_urls, + monkeypatch, +) -> None: + user_id = uuid4() + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + store.create_user(user_id, "semantic-scope@example.com", "Semantic Scope") + assistant_thread = store.create_thread("Assistant semantic thread") + coach_thread = store.create_thread("Coach semantic thread", agent_profile_id="coach_default") + assistant_session = store.create_session(assistant_thread["id"], status="active") + coach_session = store.create_session(coach_thread["id"], status="active") + assistant_event = store.append_event( + assistant_thread["id"], + assistant_session["id"], + "message.user", + {"text": "assistant memory evidence"}, + ) + coach_event = store.append_event( + coach_thread["id"], + coach_session["id"], + "message.user", + {"text": "coach memory evidence"}, + ) + assistant_memory = store.create_memory( + memory_key="user.preference.semantic.assistant_scope", + value={"likes": "latte"}, + status="active", + source_event_ids=[str(assistant_event["id"])], + ) + coach_memory = store.create_memory( + memory_key="user.preference.semantic.coach_scope", + value={"likes": "flat white"}, + status="active", + source_event_ids=[str(coach_event["id"])], + agent_profile_id="coach_default", + ) + embedding_config = store.create_embedding_config( + provider="openai", + model="text-embedding-3-large", + version="2026-03-24", + dimensions=3, + status="active", + metadata={"task": "semantic_scope_validation"}, + ) + store.create_memory_embedding( + memory_id=assistant_memory["id"], + embedding_config_id=embedding_config["id"], + dimensions=3, + vector=[1.0, 0.0, 0.0], + ) + store.create_memory_embedding( + memory_id=coach_memory["id"], + embedding_config_id=embedding_config["id"], + dimensions=3, + vector=[1.0, 0.0, 0.0], + ) + + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + assistant_status, assistant_payload = invoke_compile_context( + { + "user_id": str(user_id), + "thread_id": str(assistant_thread["id"]), + "max_memories": 10, + "semantic": { + "embedding_config_id": str(embedding_config["id"]), + "query_vector": [1.0, 0.0, 0.0], + "limit": 10, + }, + } + ) + coach_status, coach_payload = invoke_compile_context( + { + "user_id": str(user_id), + "thread_id": str(coach_thread["id"]), + "max_memories": 10, + "semantic": { + "embedding_config_id": str(embedding_config["id"]), + "query_vector": [1.0, 0.0, 0.0], + "limit": 10, + }, + } + ) + + assert assistant_status == 200 + assert [memory["memory_key"] for memory in assistant_payload["context_pack"]["memories"]] == [ + "user.preference.semantic.assistant_scope" + ] + + assert coach_status == 200 + assert [memory["memory_key"] for memory in coach_payload["context_pack"]["memories"]] == [ + "user.preference.semantic.coach_scope" + ] diff --git a/tests/integration/test_continuity_api.py b/tests/integration/test_continuity_api.py new file mode 100644 index 0000000..4813c77 --- /dev/null +++ b/tests/integration/test_continuity_api.py @@ -0,0 +1,786 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +import json +from typing import Any +from urllib.parse import urlencode +from uuid import UUID, uuid4 + +import anyio +import psycopg + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.db import user_connection +from alicebot_api.store import ContinuityStore + + +def invoke_request( + method: str, + path: str, + *, + query_params: dict[str, str] | None = None, + payload: dict[str, Any] | None = None, +) -> tuple[int, dict[str, Any]]: + messages: list[dict[str, object]] = [] + encoded_body = b"" if payload is None else json.dumps(payload).encode() + request_received = False + + async def receive() -> dict[str, object]: + nonlocal request_received + if request_received: + return {"type": "http.disconnect"} + + request_received = True + return {"type": "http.request", "body": encoded_body, "more_body": False} + + async def send(message: dict[str, object]) -> None: + messages.append(message) + + query_string = urlencode(query_params or {}).encode() + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": query_string, + "headers": [(b"content-type", b"application/json")], + "client": ("testclient", 50000), + "server": ("testserver", 80), + "root_path": "", + } + + anyio.run(main_module.app, scope, receive, send) + + start_message = next(message for message in messages if message["type"] == "http.response.start") + body = b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + return start_message["status"], json.loads(body) + + +def create_user(database_url: str, *, email: str) -> UUID: + user_id = uuid4() + with user_connection(database_url, user_id) as conn: + ContinuityStore(conn).create_user(user_id, email, email.split("@", 1)[0].title()) + return user_id + + +def seed_user_with_continuity(database_url: str, *, email: str) -> dict[str, object]: + user_id = create_user(database_url, email=email) + + with user_connection(database_url, user_id) as conn: + store = ContinuityStore(conn) + first_thread = store.create_thread("Alpha thread") + second_thread = store.create_thread("Beta thread") + first_session = store.create_session(second_thread["id"], status="completed") + second_session = store.create_session(second_thread["id"], status="active") + first_event = store.append_event( + second_thread["id"], + second_session["id"], + "message.user", + {"text": "Hello"}, + ) + second_event = store.append_event( + second_thread["id"], + second_session["id"], + "message.assistant", + {"text": "Hello back"}, + ) + + return { + "user_id": user_id, + "first_thread": first_thread, + "second_thread": second_thread, + "first_session": first_session, + "second_session": second_session, + "first_event": first_event, + "second_event": second_event, + } + + +def set_thread_timestamps( + admin_database_url: str, + *, + thread_id: UUID, + created_at: datetime, + updated_at: datetime, +) -> None: + with psycopg.connect(admin_database_url, autocommit=True) as conn: + with conn.cursor() as cur: + cur.execute( + "UPDATE threads SET created_at = %s, updated_at = %s WHERE id = %s", + (created_at, updated_at, thread_id), + ) + + +def set_session_timestamps( + admin_database_url: str, + *, + session_id: UUID, + started_at: datetime, + ended_at: datetime | None, + created_at: datetime, +) -> None: + with psycopg.connect(admin_database_url, autocommit=True) as conn: + with conn.cursor() as cur: + cur.execute( + "UPDATE sessions SET started_at = %s, ended_at = %s, created_at = %s WHERE id = %s", + (started_at, ended_at, created_at, session_id), + ) + + +def serialize_thread( + *, + thread_id: UUID, + title: str, + created_at: datetime, + updated_at: datetime, + agent_profile_id: str, +) -> dict[str, Any]: + return { + "id": str(thread_id), + "title": title, + "agent_profile_id": agent_profile_id, + "created_at": created_at.isoformat(), + "updated_at": updated_at.isoformat(), + } + + +def serialize_session( + *, + session_id: UUID, + thread_id: UUID, + status: str, + started_at: datetime | None, + ended_at: datetime | None, + created_at: datetime, +) -> dict[str, Any]: + return { + "id": str(session_id), + "thread_id": str(thread_id), + "status": status, + "started_at": None if started_at is None else started_at.isoformat(), + "ended_at": None if ended_at is None else ended_at.isoformat(), + "created_at": created_at.isoformat(), + } + + +def serialize_event(event: dict[str, Any]) -> dict[str, Any]: + return { + "id": str(event["id"]), + "thread_id": str(event["thread_id"]), + "session_id": None if event["session_id"] is None else str(event["session_id"]), + "sequence_no": event["sequence_no"], + "kind": event["kind"], + "payload": event["payload"], + "created_at": event["created_at"].isoformat(), + } + + +def test_thread_continuity_endpoints_create_list_detail_sessions_and_events( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_user_with_continuity(migrated_database_urls["app"], email="owner@example.com") + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + create_status, create_payload = invoke_request( + "POST", + "/v0/threads", + payload={ + "user_id": str(seeded["user_id"]), + "title": "Gamma thread", + "agent_profile_id": "coach_default", + }, + ) + + assert create_status == 201 + assert create_payload["thread"]["title"] == "Gamma thread" + assert create_payload["thread"]["agent_profile_id"] == "coach_default" + + api_thread_id = UUID(create_payload["thread"]["id"]) + shared_created_at = datetime(2026, 3, 17, 9, 0, tzinfo=UTC) + newer_created_at = datetime(2026, 3, 17, 10, 0, tzinfo=UTC) + first_session_start = shared_created_at + first_session_end = shared_created_at + timedelta(minutes=5) + second_session_start = shared_created_at + timedelta(hours=1) + + set_thread_timestamps( + migrated_database_urls["admin"], + thread_id=seeded["first_thread"]["id"], + created_at=shared_created_at, + updated_at=shared_created_at, + ) + set_thread_timestamps( + migrated_database_urls["admin"], + thread_id=seeded["second_thread"]["id"], + created_at=shared_created_at, + updated_at=shared_created_at, + ) + set_thread_timestamps( + migrated_database_urls["admin"], + thread_id=api_thread_id, + created_at=newer_created_at, + updated_at=newer_created_at, + ) + set_session_timestamps( + migrated_database_urls["admin"], + session_id=seeded["first_session"]["id"], + started_at=first_session_start, + ended_at=first_session_end, + created_at=first_session_start, + ) + set_session_timestamps( + migrated_database_urls["admin"], + session_id=seeded["second_session"]["id"], + started_at=second_session_start, + ended_at=None, + created_at=second_session_start, + ) + + with user_connection(migrated_database_urls["app"], seeded["user_id"]) as conn: + stored_thread_ids = [thread["id"] for thread in ContinuityStore(conn).list_threads()] + + assert api_thread_id in stored_thread_ids + + list_status, list_payload = invoke_request( + "GET", + "/v0/threads", + query_params={"user_id": str(seeded["user_id"])}, + ) + detail_status, detail_payload = invoke_request( + "GET", + f"/v0/threads/{seeded['second_thread']['id']}", + query_params={"user_id": str(seeded["user_id"])}, + ) + sessions_status, sessions_payload = invoke_request( + "GET", + f"/v0/threads/{seeded['second_thread']['id']}/sessions", + query_params={"user_id": str(seeded["user_id"])}, + ) + events_status, events_payload = invoke_request( + "GET", + f"/v0/threads/{seeded['second_thread']['id']}/events", + query_params={"user_id": str(seeded["user_id"])}, + ) + tied_threads = sorted( + [seeded["first_thread"], seeded["second_thread"]], + key=lambda thread: (thread["created_at"], thread["id"]), + reverse=True, + ) + + assert list_status == 200 + assert list_payload == { + "items": [ + serialize_thread( + thread_id=api_thread_id, + title="Gamma thread", + created_at=newer_created_at, + updated_at=newer_created_at, + agent_profile_id="coach_default", + ), + serialize_thread( + thread_id=tied_threads[0]["id"], + title=tied_threads[0]["title"], + created_at=shared_created_at, + updated_at=shared_created_at, + agent_profile_id="assistant_default", + ), + serialize_thread( + thread_id=tied_threads[1]["id"], + title=tied_threads[1]["title"], + created_at=shared_created_at, + updated_at=shared_created_at, + agent_profile_id="assistant_default", + ), + ], + "summary": { + "total_count": 3, + "order": ["created_at_desc", "id_desc"], + }, + } + + assert detail_status == 200 + assert detail_payload == { + "thread": serialize_thread( + thread_id=seeded["second_thread"]["id"], + title="Beta thread", + created_at=shared_created_at, + updated_at=shared_created_at, + agent_profile_id="assistant_default", + ) + } + + assert sessions_status == 200 + assert sessions_payload == { + "items": [ + serialize_session( + session_id=seeded["first_session"]["id"], + thread_id=seeded["second_thread"]["id"], + status="completed", + started_at=first_session_start, + ended_at=first_session_end, + created_at=first_session_start, + ), + serialize_session( + session_id=seeded["second_session"]["id"], + thread_id=seeded["second_thread"]["id"], + status="active", + started_at=second_session_start, + ended_at=None, + created_at=second_session_start, + ), + ], + "summary": { + "thread_id": str(seeded["second_thread"]["id"]), + "total_count": 2, + "order": ["started_at_asc", "created_at_asc", "id_asc"], + }, + } + + assert events_status == 200 + assert events_payload == { + "items": [ + serialize_event(seeded["first_event"]), + serialize_event(seeded["second_event"]), + ], + "summary": { + "thread_id": str(seeded["second_thread"]["id"]), + "total_count": 2, + "order": ["sequence_no_asc"], + }, + } + + +def test_thread_creation_defaults_agent_profile_id_when_omitted( + migrated_database_urls, + monkeypatch, +) -> None: + user_id = create_user(migrated_database_urls["app"], email="owner@example.com") + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + status, payload = invoke_request( + "POST", + "/v0/threads", + payload={ + "user_id": str(user_id), + "title": "Default profile thread", + }, + ) + + assert status == 201 + assert payload["thread"]["agent_profile_id"] == "assistant_default" + + thread_id = UUID(payload["thread"]["id"]) + with user_connection(migrated_database_urls["app"], user_id) as conn: + stored_thread = ContinuityStore(conn).get_thread(thread_id) + + assert stored_thread["agent_profile_id"] == "assistant_default" + + +def test_thread_resumption_brief_endpoint_returns_bounded_sections_and_workflow_posture( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_user_with_continuity(migrated_database_urls["app"], email="owner@example.com") + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + with user_connection(migrated_database_urls["app"], seeded["user_id"]) as conn: + store = ContinuityStore(conn) + store.create_memory( + memory_key="user.preference.tea", + value={"likes": "green"}, + status="active", + source_event_ids=[], + memory_type="preference", + ) + store.create_memory( + memory_key="user.preference.coffee", + value={"likes": "oat milk"}, + status="active", + source_event_ids=[], + memory_type="preference", + ) + store.create_memory( + memory_key="user.preference.deleted", + value={"likes": "espresso"}, + status="deleted", + source_event_ids=[], + memory_type="preference", + ) + store.create_open_loop( + memory_id=None, + title="Older open loop", + status="open", + opened_at=datetime(2026, 3, 18, 9, 0, tzinfo=UTC), + due_at=None, + resolved_at=None, + resolution_note=None, + ) + store.create_open_loop( + memory_id=None, + title="Latest open loop", + status="open", + opened_at=datetime(2026, 3, 18, 10, 0, tzinfo=UTC), + due_at=None, + resolved_at=None, + resolution_note=None, + ) + store.create_open_loop( + memory_id=None, + title="Resolved open loop", + status="resolved", + opened_at=datetime(2026, 3, 18, 11, 0, tzinfo=UTC), + due_at=None, + resolved_at=datetime(2026, 3, 18, 11, 5, tzinfo=UTC), + resolution_note="resolved", + ) + tool = store.create_tool( + tool_key="proxy.echo", + name="Proxy Echo", + description="Deterministic proxy handler.", + version="1.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["proxy"], + action_hints=["tool.run"], + scope_hints=["workspace"], + domain_hints=[], + risk_hints=[], + metadata={"transport": "proxy"}, + ) + task = store.create_task( + thread_id=seeded["second_thread"]["id"], + tool_id=tool["id"], + status="approved", + request={ + "thread_id": str(seeded["second_thread"]["id"]), + "tool_id": str(tool["id"]), + "action": "tool.run", + "scope": "workspace", + "domain_hint": None, + "risk_hint": None, + "attributes": {"mode": "resumption"}, + }, + tool={ + "id": str(tool["id"]), + "tool_key": "proxy.echo", + "name": "Proxy Echo", + "description": "Deterministic proxy handler.", + "version": "1.0.0", + "metadata_version": "tool_metadata_v0", + "active": True, + "tags": ["proxy"], + "action_hints": ["tool.run"], + "scope_hints": ["workspace"], + "domain_hints": [], + "risk_hints": [], + "metadata": {"transport": "proxy"}, + "created_at": tool["created_at"].isoformat(), + }, + latest_approval_id=None, + latest_execution_id=None, + ) + first_step_trace = store.create_trace( + user_id=seeded["user_id"], + thread_id=seeded["second_thread"]["id"], + kind="task.step.sequence", + compiler_version="task_step_sequence_v0", + status="completed", + limits={"max_steps": 1}, + ) + second_step_trace = store.create_trace( + user_id=seeded["user_id"], + thread_id=seeded["second_thread"]["id"], + kind="task.step.transition", + compiler_version="task_step_transition_v0", + status="completed", + limits={"max_steps": 1}, + ) + store.create_task_step( + task_id=task["id"], + sequence_no=1, + kind="governed_request", + status="approved", + request={ + "thread_id": str(seeded["second_thread"]["id"]), + "tool_id": str(tool["id"]), + "action": "tool.run", + "scope": "workspace", + "domain_hint": None, + "risk_hint": None, + "attributes": {"step": 1}, + }, + outcome={ + "routing_decision": "ready", + "approval_id": None, + "approval_status": None, + "execution_id": None, + "execution_status": None, + "blocked_reason": None, + }, + trace_id=first_step_trace["id"], + trace_kind="task.step.sequence", + ) + latest_step = store.create_task_step( + task_id=task["id"], + sequence_no=2, + kind="governed_request", + status="executed", + request={ + "thread_id": str(seeded["second_thread"]["id"]), + "tool_id": str(tool["id"]), + "action": "tool.run", + "scope": "workspace", + "domain_hint": None, + "risk_hint": None, + "attributes": {"step": 2}, + }, + outcome={ + "routing_decision": "ready", + "approval_id": None, + "approval_status": None, + "execution_id": "execution-1", + "execution_status": "completed", + "blocked_reason": None, + }, + trace_id=second_step_trace["id"], + trace_kind="task.step.transition", + ) + + status, payload = invoke_request( + "GET", + f"/v0/threads/{seeded['second_thread']['id']}/resumption-brief", + query_params={ + "user_id": str(seeded["user_id"]), + "max_events": "1", + "max_open_loops": "1", + "max_memories": "1", + }, + ) + + assert status == 200 + assert payload["brief"]["assembly_version"] == "resumption_brief_v0" + assert payload["brief"]["thread"]["id"] == str(seeded["second_thread"]["id"]) + assert payload["brief"]["conversation"]["summary"] == { + "limit": 1, + "returned_count": 1, + "total_count": 2, + "order": ["sequence_no_asc"], + "kinds": ["message.user", "message.assistant"], + } + assert [item["sequence_no"] for item in payload["brief"]["conversation"]["items"]] == [2] + assert payload["brief"]["open_loops"]["summary"] == { + "limit": 1, + "returned_count": 1, + "total_count": 2, + "order": ["opened_at_desc", "created_at_desc", "id_desc"], + } + assert [item["title"] for item in payload["brief"]["open_loops"]["items"]] == ["Latest open loop"] + assert payload["brief"]["memory_highlights"]["summary"] == { + "limit": 1, + "returned_count": 1, + "total_count": 2, + "order": ["updated_at_asc", "created_at_asc", "id_asc"], + } + assert [item["memory_key"] for item in payload["brief"]["memory_highlights"]["items"]] == [ + "user.preference.coffee" + ] + assert payload["brief"]["workflow"]["task"]["id"] == str(task["id"]) + assert payload["brief"]["workflow"]["latest_task_step"]["id"] == str(latest_step["id"]) + assert payload["brief"]["workflow"]["latest_task_step"]["sequence_no"] == 2 + assert payload["brief"]["workflow"]["summary"] == { + "present": True, + "task_order": ["created_at_asc", "id_asc"], + "task_step_order": ["sequence_no_asc", "created_at_asc", "id_asc"], + } + assert payload["brief"]["sources"] == [ + "threads", + "events", + "open_loops", + "memories", + "tasks", + "task_steps", + ] + + +def test_thread_continuity_endpoints_enforce_user_isolation_and_not_found( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_user_with_continuity(migrated_database_urls["app"], email="owner@example.com") + intruder_id = create_user(migrated_database_urls["app"], email="intruder@example.com") + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + list_status, list_payload = invoke_request( + "GET", + "/v0/threads", + query_params={"user_id": str(intruder_id)}, + ) + detail_status, detail_payload = invoke_request( + "GET", + f"/v0/threads/{owner['second_thread']['id']}", + query_params={"user_id": str(intruder_id)}, + ) + sessions_status, sessions_payload = invoke_request( + "GET", + f"/v0/threads/{owner['second_thread']['id']}/sessions", + query_params={"user_id": str(intruder_id)}, + ) + events_status, events_payload = invoke_request( + "GET", + f"/v0/threads/{owner['second_thread']['id']}/events", + query_params={"user_id": str(intruder_id)}, + ) + brief_status, brief_payload = invoke_request( + "GET", + f"/v0/threads/{owner['second_thread']['id']}/resumption-brief", + query_params={"user_id": str(intruder_id)}, + ) + missing_thread_id = uuid4() + missing_brief_status, missing_brief_payload = invoke_request( + "GET", + f"/v0/threads/{missing_thread_id}/resumption-brief", + query_params={"user_id": str(owner['user_id'])}, + ) + + assert list_status == 200 + assert list_payload == { + "items": [], + "summary": { + "total_count": 0, + "order": ["created_at_desc", "id_desc"], + }, + } + assert detail_status == 404 + assert detail_payload == {"detail": f"thread {owner['second_thread']['id']} was not found"} + assert sessions_status == 404 + assert sessions_payload == {"detail": f"thread {owner['second_thread']['id']} was not found"} + assert events_status == 404 + assert events_payload == {"detail": f"thread {owner['second_thread']['id']} was not found"} + assert brief_status == 404 + assert brief_payload == {"detail": f"thread {owner['second_thread']['id']} was not found"} + assert missing_brief_status == 404 + assert missing_brief_payload == {"detail": f"thread {missing_thread_id} was not found"} + + +def test_thread_creation_rejects_invalid_agent_profile_id_with_deterministic_422( + migrated_database_urls, + monkeypatch, +) -> None: + user_id = create_user(migrated_database_urls["app"], email="owner@example.com") + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + status, payload = invoke_request( + "POST", + "/v0/threads", + payload={ + "user_id": str(user_id), + "title": "Invalid profile thread", + "agent_profile_id": "not_a_profile", + }, + ) + + assert status == 422 + assert payload == { + "detail": { + "code": "invalid_agent_profile_id", + "message": "agent_profile_id must be one of: assistant_default, coach_default", + "allowed_agent_profile_ids": ["assistant_default", "coach_default"], + } + } + + +def test_agent_profiles_endpoint_returns_deterministic_registry_payload( + migrated_database_urls, + monkeypatch, +) -> None: + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + status, payload = invoke_request("GET", "/v0/agent-profiles") + + assert status == 200 + assert payload == { + "items": [ + { + "id": "assistant_default", + "name": "Assistant Default", + "description": "General-purpose assistant profile for baseline conversations.", + "model_provider": "openai_responses", + "model_name": "gpt-5-mini", + }, + { + "id": "coach_default", + "name": "Coach Default", + "description": "Coaching-oriented profile focused on guidance and accountability.", + "model_provider": "openai_responses", + "model_name": "gpt-5", + }, + ], + "summary": {"total_count": 2, "order": ["id_asc"]}, + } + + +def test_context_compile_includes_active_agent_profile_metadata( + migrated_database_urls, + monkeypatch, +) -> None: + user_id = create_user(migrated_database_urls["app"], email="owner@example.com") + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + thread = store.create_thread("Profile metadata thread", agent_profile_id="coach_default") + session = store.create_session(thread["id"], status="active") + store.append_event( + thread["id"], + session["id"], + "message.user", + {"text": "Compile metadata check"}, + ) + + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + status, payload = invoke_request( + "POST", + "/v0/context/compile", + payload={ + "user_id": str(user_id), + "thread_id": str(thread["id"]), + }, + ) + + assert status == 200 + assert payload["metadata"] == {"agent_profile_id": "coach_default"} diff --git a/tests/integration/test_continuity_capture_api.py b/tests/integration/test_continuity_capture_api.py new file mode 100644 index 0000000..6e7dee8 --- /dev/null +++ b/tests/integration/test_continuity_capture_api.py @@ -0,0 +1,238 @@ +from __future__ import annotations + +import json +from typing import Any +from urllib.parse import urlencode +from uuid import UUID, uuid4 + +import anyio + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.db import user_connection +from alicebot_api.store import ContinuityStore + + +def invoke_request( + method: str, + path: str, + *, + query_params: dict[str, str] | None = None, + payload: dict[str, Any] | None = None, +) -> tuple[int, dict[str, Any]]: + messages: list[dict[str, object]] = [] + encoded_body = b"" if payload is None else json.dumps(payload).encode() + request_received = False + + async def receive() -> dict[str, object]: + nonlocal request_received + if request_received: + return {"type": "http.disconnect"} + + request_received = True + return {"type": "http.request", "body": encoded_body, "more_body": False} + + async def send(message: dict[str, object]) -> None: + messages.append(message) + + query_string = urlencode(query_params or {}).encode() + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": query_string, + "headers": [(b"content-type", b"application/json")], + "client": ("testclient", 50000), + "server": ("testserver", 80), + "root_path": "", + } + + anyio.run(main_module.app, scope, receive, send) + + start_message = next(message for message in messages if message["type"] == "http.response.start") + body = b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + return start_message["status"], json.loads(body) + + +def seed_user(database_url: str, *, email: str) -> UUID: + user_id = uuid4() + with user_connection(database_url, user_id) as conn: + ContinuityStore(conn).create_user(user_id, email, email.split("@", 1)[0].title()) + return user_id + + +def test_continuity_capture_create_list_and_detail_support_deterministic_signal_mapping( + migrated_database_urls, + monkeypatch, +) -> None: + user_id = seed_user(migrated_database_urls["app"], email="owner@example.com") + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + create_status, create_payload = invoke_request( + "POST", + "/v0/continuity/captures", + payload={ + "user_id": str(user_id), + "raw_content": "Finalize launch checklist", + "explicit_signal": "task", + }, + ) + + assert create_status == 201 + capture_id = create_payload["capture"]["capture_event"]["id"] + assert create_payload["capture"]["capture_event"] == { + "id": capture_id, + "raw_content": "Finalize launch checklist", + "explicit_signal": "task", + "admission_posture": "DERIVED", + "admission_reason": "explicit_signal_task", + "created_at": create_payload["capture"]["capture_event"]["created_at"], + } + assert create_payload["capture"]["derived_object"]["object_type"] == "NextAction" + assert create_payload["capture"]["derived_object"]["body"] == { + "action_text": "Finalize launch checklist", + "raw_content": "Finalize launch checklist", + "explicit_signal": "task", + } + assert create_payload["capture"]["derived_object"]["provenance"]["capture_event_id"] == capture_id + + list_status, list_payload = invoke_request( + "GET", + "/v0/continuity/captures", + query_params={ + "user_id": str(user_id), + "limit": "20", + }, + ) + assert list_status == 200 + assert list_payload["summary"] == { + "limit": 20, + "returned_count": 1, + "total_count": 1, + "derived_count": 1, + "triage_count": 0, + "order": ["created_at_desc", "id_desc"], + } + assert list_payload["items"][0]["capture_event"]["id"] == capture_id + + detail_status, detail_payload = invoke_request( + "GET", + f"/v0/continuity/captures/{capture_id}", + query_params={"user_id": str(user_id)}, + ) + assert detail_status == 200 + assert detail_payload["capture"]["capture_event"]["id"] == capture_id + assert detail_payload["capture"]["derived_object"]["object_type"] == "NextAction" + + +def test_continuity_capture_ambiguous_input_is_preserved_with_triage_posture( + migrated_database_urls, + monkeypatch, +) -> None: + user_id = seed_user(migrated_database_urls["app"], email="owner2@example.com") + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + create_status, create_payload = invoke_request( + "POST", + "/v0/continuity/captures", + payload={ + "user_id": str(user_id), + "raw_content": "Maybe revisit this next month", + }, + ) + + assert create_status == 201 + assert create_payload["capture"]["capture_event"]["admission_posture"] == "TRIAGE" + assert create_payload["capture"]["capture_event"]["admission_reason"] == "ambiguous_capture_requires_triage" + assert create_payload["capture"]["derived_object"] is None + + capture_id = create_payload["capture"]["capture_event"]["id"] + detail_status, detail_payload = invoke_request( + "GET", + f"/v0/continuity/captures/{capture_id}", + query_params={"user_id": str(user_id)}, + ) + assert detail_status == 200 + assert detail_payload["capture"]["derived_object"] is None + + +def test_continuity_capture_rejects_invalid_signal_and_enforces_user_scope( + migrated_database_urls, + monkeypatch, +) -> None: + owner_id = seed_user(migrated_database_urls["app"], email="owner3@example.com") + intruder_id = seed_user(migrated_database_urls["app"], email="intruder@example.com") + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + invalid_status, invalid_payload = invoke_request( + "POST", + "/v0/continuity/captures", + payload={ + "user_id": str(owner_id), + "raw_content": "Call the supplier", + "explicit_signal": "invalid_signal", + }, + ) + assert invalid_status == 400 + assert invalid_payload["detail"].startswith("explicit_signal must be one of") + + create_status, create_payload = invoke_request( + "POST", + "/v0/continuity/captures", + payload={ + "user_id": str(owner_id), + "raw_content": "Decision: keep intake conservative", + }, + ) + assert create_status == 201 + + intruder_detail_status, intruder_detail_payload = invoke_request( + "GET", + f"/v0/continuity/captures/{create_payload['capture']['capture_event']['id']}", + query_params={"user_id": str(intruder_id)}, + ) + assert intruder_detail_status == 404 + assert intruder_detail_payload == { + "detail": ( + f"continuity capture event {create_payload['capture']['capture_event']['id']} " + "was not found" + ) + } + + intruder_list_status, intruder_list_payload = invoke_request( + "GET", + "/v0/continuity/captures", + query_params={"user_id": str(intruder_id), "limit": "20"}, + ) + assert intruder_list_status == 200 + assert intruder_list_payload == { + "items": [], + "summary": { + "limit": 20, + "returned_count": 0, + "total_count": 0, + "derived_count": 0, + "triage_count": 0, + "order": ["created_at_desc", "id_desc"], + }, + } diff --git a/tests/integration/test_continuity_daily_weekly_review_api.py b/tests/integration/test_continuity_daily_weekly_review_api.py new file mode 100644 index 0000000..9686a80 --- /dev/null +++ b/tests/integration/test_continuity_daily_weekly_review_api.py @@ -0,0 +1,398 @@ +from __future__ import annotations + +from datetime import UTC, datetime +import json +from typing import Any +from urllib.parse import urlencode +from uuid import UUID, uuid4 + +import anyio +import psycopg + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.db import user_connection +from alicebot_api.store import ContinuityStore + + +def invoke_request( + method: str, + path: str, + *, + query_params: dict[str, str] | None = None, + payload: dict[str, Any] | None = None, +) -> tuple[int, dict[str, Any]]: + messages: list[dict[str, object]] = [] + encoded_body = b"" if payload is None else json.dumps(payload).encode() + request_received = False + + async def receive() -> dict[str, object]: + nonlocal request_received + if request_received: + return {"type": "http.disconnect"} + + request_received = True + return {"type": "http.request", "body": encoded_body, "more_body": False} + + async def send(message: dict[str, object]) -> None: + messages.append(message) + + query_string = urlencode(query_params or {}).encode() + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": query_string, + "headers": [(b"content-type", b"application/json")], + "client": ("testclient", 50000), + "server": ("testserver", 80), + "root_path": "", + } + + anyio.run(main_module.app, scope, receive, send) + + start_message = next(message for message in messages if message["type"] == "http.response.start") + body = b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + return start_message["status"], json.loads(body) + + +def seed_user(database_url: str, *, email: str) -> UUID: + user_id = uuid4() + with user_connection(database_url, user_id) as conn: + ContinuityStore(conn).create_user(user_id, email, email.split("@", 1)[0].title()) + return user_id + + +def set_continuity_timestamps( + admin_database_url: str, + *, + continuity_object_id: UUID, + created_at: datetime, +) -> None: + with psycopg.connect(admin_database_url, autocommit=True) as conn: + with conn.cursor() as cur: + cur.execute( + "UPDATE continuity_objects SET created_at = %s, updated_at = %s WHERE id = %s", + (created_at, created_at, continuity_object_id), + ) + + +def test_daily_and_weekly_review_endpoints_are_deterministic( + migrated_database_urls, + monkeypatch, +) -> None: + user_id = seed_user(migrated_database_urls["app"], email="daily-weekly@example.com") + thread_id = UUID("cccccccc-cccc-4ccc-8ccc-cccccccccccc") + + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + + waiting_capture = store.create_continuity_capture_event( + raw_content="Waiting For: Vendor quote", + explicit_signal="waiting_for", + admission_posture="DERIVED", + admission_reason="explicit_signal_waiting_for", + ) + waiting_object = store.create_continuity_object( + capture_event_id=waiting_capture["id"], + object_type="WaitingFor", + status="active", + title="Waiting For: Vendor quote", + body={"waiting_for_text": "Vendor quote"}, + provenance={"thread_id": str(thread_id)}, + confidence=1.0, + ) + + blocker_capture = store.create_continuity_capture_event( + raw_content="Blocker: Missing API key", + explicit_signal="blocker", + admission_posture="DERIVED", + admission_reason="explicit_signal_blocker", + ) + blocker_object = store.create_continuity_object( + capture_event_id=blocker_capture["id"], + object_type="Blocker", + status="active", + title="Blocker: Missing API key", + body={"blocking_reason": "Missing API key"}, + provenance={"thread_id": str(thread_id)}, + confidence=1.0, + ) + + stale_capture = store.create_continuity_capture_event( + raw_content="Waiting For: Stale finance reply", + explicit_signal="waiting_for", + admission_posture="DERIVED", + admission_reason="explicit_signal_waiting_for", + ) + stale_object = store.create_continuity_object( + capture_event_id=stale_capture["id"], + object_type="WaitingFor", + status="stale", + title="Waiting For: Stale finance reply", + body={"waiting_for_text": "Stale finance reply"}, + provenance={"thread_id": str(thread_id)}, + confidence=1.0, + ) + + next_capture = store.create_continuity_capture_event( + raw_content="Next Action: Send follow-up", + explicit_signal="next_action", + admission_posture="DERIVED", + admission_reason="explicit_signal_next_action", + ) + next_object = store.create_continuity_object( + capture_event_id=next_capture["id"], + object_type="NextAction", + status="active", + title="Next Action: Send follow-up", + body={"action_text": "Send follow-up"}, + provenance={"thread_id": str(thread_id)}, + confidence=1.0, + ) + + store.create_continuity_correction_event( + continuity_object_id=waiting_object["id"], + action="confirm", + reason="weekly-check-1", + before_snapshot={"status": "active"}, + after_snapshot={"status": "active"}, + payload={"source": "weekly_review_seed"}, + ) + store.create_continuity_correction_event( + continuity_object_id=waiting_object["id"], + action="edit", + reason="weekly-check-2", + before_snapshot={"status": "active"}, + after_snapshot={"status": "active"}, + payload={"source": "weekly_review_seed"}, + ) + + set_continuity_timestamps( + migrated_database_urls["admin"], + continuity_object_id=waiting_object["id"], + created_at=datetime(2026, 3, 30, 8, 0, tzinfo=UTC), + ) + set_continuity_timestamps( + migrated_database_urls["admin"], + continuity_object_id=blocker_object["id"], + created_at=datetime(2026, 3, 30, 8, 5, tzinfo=UTC), + ) + set_continuity_timestamps( + migrated_database_urls["admin"], + continuity_object_id=stale_object["id"], + created_at=datetime(2026, 3, 30, 8, 10, tzinfo=UTC), + ) + set_continuity_timestamps( + migrated_database_urls["admin"], + continuity_object_id=next_object["id"], + created_at=datetime(2026, 3, 30, 8, 15, tzinfo=UTC), + ) + + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + first_daily_status, first_daily = invoke_request( + "GET", + "/v0/continuity/daily-brief", + query_params={ + "user_id": str(user_id), + "thread_id": str(thread_id), + "limit": "3", + }, + ) + second_daily_status, second_daily = invoke_request( + "GET", + "/v0/continuity/daily-brief", + query_params={ + "user_id": str(user_id), + "thread_id": str(thread_id), + "limit": "3", + }, + ) + + assert first_daily_status == 200 + assert second_daily_status == 200 + assert first_daily == second_daily + assert [item["title"] for item in first_daily["brief"]["waiting_for_highlights"]["items"]] == [ + "Waiting For: Vendor quote", + ] + assert [item["title"] for item in first_daily["brief"]["blocker_highlights"]["items"]] == [ + "Blocker: Missing API key", + ] + assert [item["title"] for item in first_daily["brief"]["stale_items"]["items"]] == [ + "Waiting For: Stale finance reply", + ] + assert first_daily["brief"]["next_suggested_action"]["item"]["title"] == "Next Action: Send follow-up" + + first_weekly_status, first_weekly = invoke_request( + "GET", + "/v0/continuity/weekly-review", + query_params={ + "user_id": str(user_id), + "thread_id": str(thread_id), + "limit": "5", + }, + ) + second_weekly_status, second_weekly = invoke_request( + "GET", + "/v0/continuity/weekly-review", + query_params={ + "user_id": str(user_id), + "thread_id": str(thread_id), + "limit": "5", + }, + ) + + assert first_weekly_status == 200 + assert second_weekly_status == 200 + assert first_weekly == second_weekly + assert first_weekly["review"]["rollup"] == { + "total_count": 4, + "waiting_for_count": 1, + "blocker_count": 1, + "stale_count": 1, + "correction_recurrence_count": 1, + "freshness_drift_count": 1, + "next_action_count": 1, + "posture_order": ["waiting_for", "blocker", "stale", "next_action"], + } + + +def test_daily_and_weekly_review_endpoints_emit_explicit_empty_states( + migrated_database_urls, + monkeypatch, +) -> None: + user_id = seed_user(migrated_database_urls["app"], email="daily-weekly-empty@example.com") + thread_id = UUID("dddddddd-dddd-4ddd-8ddd-dddddddddddd") + + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + note_capture = store.create_continuity_capture_event( + raw_content="Note: context only", + explicit_signal="note", + admission_posture="DERIVED", + admission_reason="explicit_signal_note", + ) + note_object = store.create_continuity_object( + capture_event_id=note_capture["id"], + object_type="Note", + status="active", + title="Note: context only", + body={"body": "context only"}, + provenance={"thread_id": str(thread_id)}, + confidence=1.0, + ) + + set_continuity_timestamps( + migrated_database_urls["admin"], + continuity_object_id=note_object["id"], + created_at=datetime(2026, 3, 30, 9, 0, tzinfo=UTC), + ) + + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + daily_status, daily_payload = invoke_request( + "GET", + "/v0/continuity/daily-brief", + query_params={ + "user_id": str(user_id), + "thread_id": str(thread_id), + "limit": "3", + }, + ) + weekly_status, weekly_payload = invoke_request( + "GET", + "/v0/continuity/weekly-review", + query_params={ + "user_id": str(user_id), + "thread_id": str(thread_id), + "limit": "3", + }, + ) + + assert daily_status == 200 + assert daily_payload["brief"]["waiting_for_highlights"]["empty_state"] == { + "is_empty": True, + "message": "No waiting-for highlights for today in the requested scope.", + } + assert daily_payload["brief"]["blocker_highlights"]["empty_state"] == { + "is_empty": True, + "message": "No blocker highlights for today in the requested scope.", + } + assert daily_payload["brief"]["stale_items"]["empty_state"] == { + "is_empty": True, + "message": "No stale items for today in the requested scope.", + } + assert daily_payload["brief"]["next_suggested_action"] == { + "item": None, + "empty_state": { + "is_empty": True, + "message": "No next suggested action in the requested scope.", + }, + } + + assert weekly_status == 200 + assert weekly_payload["review"]["rollup"]["total_count"] == 0 + assert weekly_payload["review"]["rollup"]["correction_recurrence_count"] == 0 + assert weekly_payload["review"]["rollup"]["freshness_drift_count"] == 0 + assert weekly_payload["review"]["waiting_for"]["empty_state"] == { + "is_empty": True, + "message": "No waiting-for items in the requested scope.", + } + assert weekly_payload["review"]["blocker"]["empty_state"] == { + "is_empty": True, + "message": "No blocker items in the requested scope.", + } + assert weekly_payload["review"]["stale"]["empty_state"] == { + "is_empty": True, + "message": "No stale items in the requested scope.", + } + assert weekly_payload["review"]["next_action"]["empty_state"] == { + "is_empty": True, + "message": "No next-action items in the requested scope.", + } + + +def test_daily_and_weekly_review_endpoints_reject_mixed_naive_and_offset_aware_time_window( + migrated_database_urls, + monkeypatch, +) -> None: + user_id = seed_user(migrated_database_urls["app"], email="daily-weekly-window-validation@example.com") + + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + for path in ("/v0/continuity/daily-brief", "/v0/continuity/weekly-review"): + status, payload = invoke_request( + "GET", + path, + query_params={ + "user_id": str(user_id), + "since": "2026-03-30T10:00:00Z", + "until": "2026-03-30T10:01:00", + }, + ) + assert status == 400 + assert ( + payload["detail"] + == "since and until must both include timezone offsets or both omit timezone offsets" + ) diff --git a/tests/integration/test_continuity_open_loops_api.py b/tests/integration/test_continuity_open_loops_api.py new file mode 100644 index 0000000..6d13e95 --- /dev/null +++ b/tests/integration/test_continuity_open_loops_api.py @@ -0,0 +1,368 @@ +from __future__ import annotations + +from datetime import UTC, datetime +import json +from typing import Any +from urllib.parse import urlencode +from uuid import UUID, uuid4 + +import anyio +import psycopg + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.db import user_connection +from alicebot_api.store import ContinuityStore + + +def invoke_request( + method: str, + path: str, + *, + query_params: dict[str, str] | None = None, + payload: dict[str, Any] | None = None, +) -> tuple[int, dict[str, Any]]: + messages: list[dict[str, object]] = [] + encoded_body = b"" if payload is None else json.dumps(payload).encode() + request_received = False + + async def receive() -> dict[str, object]: + nonlocal request_received + if request_received: + return {"type": "http.disconnect"} + + request_received = True + return {"type": "http.request", "body": encoded_body, "more_body": False} + + async def send(message: dict[str, object]) -> None: + messages.append(message) + + query_string = urlencode(query_params or {}).encode() + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": query_string, + "headers": [(b"content-type", b"application/json")], + "client": ("testclient", 50000), + "server": ("testserver", 80), + "root_path": "", + } + + anyio.run(main_module.app, scope, receive, send) + + start_message = next(message for message in messages if message["type"] == "http.response.start") + body = b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + return start_message["status"], json.loads(body) + + +def seed_user(database_url: str, *, email: str) -> UUID: + user_id = uuid4() + with user_connection(database_url, user_id) as conn: + ContinuityStore(conn).create_user(user_id, email, email.split("@", 1)[0].title()) + return user_id + + +def set_continuity_timestamps( + admin_database_url: str, + *, + continuity_object_id: UUID, + created_at: datetime, +) -> None: + with psycopg.connect(admin_database_url, autocommit=True) as conn: + with conn.cursor() as cur: + cur.execute( + "UPDATE continuity_objects SET created_at = %s, updated_at = %s WHERE id = %s", + (created_at, created_at, continuity_object_id), + ) + + +def test_continuity_open_loop_dashboard_groups_posture_and_order( + migrated_database_urls, + monkeypatch, +) -> None: + user_id = seed_user(migrated_database_urls["app"], email="dashboard@example.com") + thread_id = UUID("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") + + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + + waiting_capture = store.create_continuity_capture_event( + raw_content="Waiting For: Vendor quote", + explicit_signal="waiting_for", + admission_posture="DERIVED", + admission_reason="explicit_signal_waiting_for", + ) + waiting_object = store.create_continuity_object( + capture_event_id=waiting_capture["id"], + object_type="WaitingFor", + status="active", + title="Waiting For: Vendor quote", + body={"waiting_for_text": "Vendor quote"}, + provenance={"thread_id": str(thread_id)}, + confidence=1.0, + ) + + blocker_capture = store.create_continuity_capture_event( + raw_content="Blocker: Missing API key", + explicit_signal="blocker", + admission_posture="DERIVED", + admission_reason="explicit_signal_blocker", + ) + blocker_object = store.create_continuity_object( + capture_event_id=blocker_capture["id"], + object_type="Blocker", + status="active", + title="Blocker: Missing API key", + body={"blocking_reason": "Missing API key"}, + provenance={"thread_id": str(thread_id)}, + confidence=1.0, + ) + + next_capture = store.create_continuity_capture_event( + raw_content="Next Action: Send follow-up", + explicit_signal="next_action", + admission_posture="DERIVED", + admission_reason="explicit_signal_next_action", + ) + next_object = store.create_continuity_object( + capture_event_id=next_capture["id"], + object_type="NextAction", + status="active", + title="Next Action: Send follow-up", + body={"action_text": "Send follow-up"}, + provenance={"thread_id": str(thread_id)}, + confidence=1.0, + ) + + stale_capture = store.create_continuity_capture_event( + raw_content="Waiting For: Stale finance reply", + explicit_signal="waiting_for", + admission_posture="DERIVED", + admission_reason="explicit_signal_waiting_for", + ) + stale_object = store.create_continuity_object( + capture_event_id=stale_capture["id"], + object_type="WaitingFor", + status="stale", + title="Waiting For: Stale finance reply", + body={"waiting_for_text": "Stale finance reply"}, + provenance={"thread_id": str(thread_id)}, + confidence=1.0, + ) + + set_continuity_timestamps( + migrated_database_urls["admin"], + continuity_object_id=waiting_object["id"], + created_at=datetime(2026, 3, 30, 10, 0, tzinfo=UTC), + ) + set_continuity_timestamps( + migrated_database_urls["admin"], + continuity_object_id=blocker_object["id"], + created_at=datetime(2026, 3, 30, 10, 5, tzinfo=UTC), + ) + set_continuity_timestamps( + migrated_database_urls["admin"], + continuity_object_id=next_object["id"], + created_at=datetime(2026, 3, 30, 10, 10, tzinfo=UTC), + ) + set_continuity_timestamps( + migrated_database_urls["admin"], + continuity_object_id=stale_object["id"], + created_at=datetime(2026, 3, 30, 10, 15, tzinfo=UTC), + ) + + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + status, payload = invoke_request( + "GET", + "/v0/continuity/open-loops", + query_params={ + "user_id": str(user_id), + "thread_id": str(thread_id), + "limit": "20", + }, + ) + + assert status == 200 + dashboard = payload["dashboard"] + assert dashboard["summary"] == { + "limit": 20, + "total_count": 4, + "posture_order": ["waiting_for", "blocker", "stale", "next_action"], + "item_order": ["created_at_desc", "id_desc"], + } + assert [item["title"] for item in dashboard["waiting_for"]["items"]] == [ + "Waiting For: Vendor quote", + ] + assert [item["title"] for item in dashboard["blocker"]["items"]] == [ + "Blocker: Missing API key", + ] + assert [item["title"] for item in dashboard["stale"]["items"]] == [ + "Waiting For: Stale finance reply", + ] + assert [item["title"] for item in dashboard["next_action"]["items"]] == [ + "Next Action: Send follow-up", + ] + + +def test_open_loop_review_actions_update_resumption_immediately( + migrated_database_urls, + monkeypatch, +) -> None: + user_id = seed_user(migrated_database_urls["app"], email="review-actions@example.com") + thread_id = UUID("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb") + + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + + waiting_capture = store.create_continuity_capture_event( + raw_content="Waiting For: Vendor quote", + explicit_signal="waiting_for", + admission_posture="DERIVED", + admission_reason="explicit_signal_waiting_for", + ) + waiting_object = store.create_continuity_object( + capture_event_id=waiting_capture["id"], + object_type="WaitingFor", + status="active", + title="Waiting For: Vendor quote", + body={"waiting_for_text": "Vendor quote"}, + provenance={"thread_id": str(thread_id)}, + confidence=1.0, + ) + + set_continuity_timestamps( + migrated_database_urls["admin"], + continuity_object_id=waiting_object["id"], + created_at=datetime(2026, 3, 30, 12, 0, tzinfo=UTC), + ) + + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + done_status, done_payload = invoke_request( + "POST", + f"/v0/continuity/open-loops/{waiting_object['id']}/review-action", + payload={ + "user_id": str(user_id), + "action": "done", + "note": "Closed after follow-up", + }, + ) + assert done_status == 200 + assert done_payload["review_action"] == "done" + assert done_payload["lifecycle_outcome"] == "completed" + assert done_payload["continuity_object"]["status"] == "completed" + + resumption_after_done_status, resumption_after_done = invoke_request( + "GET", + "/v0/continuity/resumption-brief", + query_params={ + "user_id": str(user_id), + "thread_id": str(thread_id), + "max_open_loops": "5", + "max_recent_changes": "5", + }, + ) + assert resumption_after_done_status == 200 + assert resumption_after_done["brief"]["open_loops"]["items"] == [] + + still_blocked_status, still_blocked_payload = invoke_request( + "POST", + f"/v0/continuity/open-loops/{waiting_object['id']}/review-action", + payload={ + "user_id": str(user_id), + "action": "still_blocked", + }, + ) + assert still_blocked_status == 200 + assert still_blocked_payload["lifecycle_outcome"] == "active" + assert still_blocked_payload["continuity_object"]["status"] == "active" + + resumption_after_still_blocked_status, resumption_after_still_blocked = invoke_request( + "GET", + "/v0/continuity/resumption-brief", + query_params={ + "user_id": str(user_id), + "thread_id": str(thread_id), + "max_open_loops": "5", + "max_recent_changes": "5", + }, + ) + assert resumption_after_still_blocked_status == 200 + assert [item["id"] for item in resumption_after_still_blocked["brief"]["open_loops"]["items"]] == [ + str(waiting_object["id"]), + ] + + deferred_status, deferred_payload = invoke_request( + "POST", + f"/v0/continuity/open-loops/{waiting_object['id']}/review-action", + payload={ + "user_id": str(user_id), + "action": "deferred", + }, + ) + assert deferred_status == 200 + assert deferred_payload["lifecycle_outcome"] == "stale" + assert deferred_payload["continuity_object"]["status"] == "stale" + + resumption_after_deferred_status, resumption_after_deferred = invoke_request( + "GET", + "/v0/continuity/resumption-brief", + query_params={ + "user_id": str(user_id), + "thread_id": str(thread_id), + "max_open_loops": "5", + "max_recent_changes": "5", + }, + ) + assert resumption_after_deferred_status == 200 + assert resumption_after_deferred["brief"]["open_loops"]["items"] == [] + assert [item["status"] for item in resumption_after_deferred["brief"]["recent_changes"]["items"]][:1] == [ + "stale", + ] + + +def test_open_loop_dashboard_rejects_mixed_naive_and_offset_aware_time_window( + migrated_database_urls, + monkeypatch, +) -> None: + user_id = seed_user(migrated_database_urls["app"], email="open-loop-window-validation@example.com") + + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + status, payload = invoke_request( + "GET", + "/v0/continuity/open-loops", + query_params={ + "user_id": str(user_id), + "since": "2026-03-30T10:00:00Z", + "until": "2026-03-30T10:01:00", + }, + ) + + assert status == 400 + assert ( + payload["detail"] + == "since and until must both include timezone offsets or both omit timezone offsets" + ) diff --git a/tests/integration/test_continuity_recall_api.py b/tests/integration/test_continuity_recall_api.py new file mode 100644 index 0000000..481124c --- /dev/null +++ b/tests/integration/test_continuity_recall_api.py @@ -0,0 +1,522 @@ +from __future__ import annotations + +from datetime import UTC, datetime +import json +from typing import Any +from urllib.parse import urlencode +from uuid import UUID, uuid4 + +import anyio +import psycopg + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.db import user_connection +from alicebot_api.store import ContinuityStore + + +def invoke_request( + method: str, + path: str, + *, + query_params: dict[str, str] | None = None, + payload: dict[str, Any] | None = None, +) -> tuple[int, dict[str, Any]]: + messages: list[dict[str, object]] = [] + encoded_body = b"" if payload is None else json.dumps(payload).encode() + request_received = False + + async def receive() -> dict[str, object]: + nonlocal request_received + if request_received: + return {"type": "http.disconnect"} + + request_received = True + return {"type": "http.request", "body": encoded_body, "more_body": False} + + async def send(message: dict[str, object]) -> None: + messages.append(message) + + query_string = urlencode(query_params or {}).encode() + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": query_string, + "headers": [(b"content-type", b"application/json")], + "client": ("testclient", 50000), + "server": ("testserver", 80), + "root_path": "", + } + + anyio.run(main_module.app, scope, receive, send) + + start_message = next(message for message in messages if message["type"] == "http.response.start") + body = b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + return start_message["status"], json.loads(body) + + +def seed_user(database_url: str, *, email: str) -> UUID: + user_id = uuid4() + with user_connection(database_url, user_id) as conn: + ContinuityStore(conn).create_user(user_id, email, email.split("@", 1)[0].title()) + return user_id + + +def set_continuity_timestamps( + admin_database_url: str, + *, + continuity_object_id: UUID, + created_at: datetime, +) -> None: + with psycopg.connect(admin_database_url, autocommit=True) as conn: + with conn.cursor() as cur: + cur.execute( + "UPDATE continuity_objects SET created_at = %s, updated_at = %s WHERE id = %s", + (created_at, created_at, continuity_object_id), + ) + + +def set_continuity_lifecycle_flags( + admin_database_url: str, + *, + continuity_object_id: UUID, + is_searchable: bool | None = None, + is_promotable: bool | None = None, +) -> None: + assignments: list[str] = [] + values: list[object] = [] + if is_searchable is not None: + assignments.append("is_searchable = %s") + values.append(is_searchable) + if is_promotable is not None: + assignments.append("is_promotable = %s") + values.append(is_promotable) + if not assignments: + return + + values.append(continuity_object_id) + with psycopg.connect(admin_database_url, autocommit=True) as conn: + with conn.cursor() as cur: + cur.execute( + f"UPDATE continuity_objects SET {', '.join(assignments)} WHERE id = %s", + tuple(values), + ) + + +def test_continuity_recall_api_returns_provenance_backed_scoped_results( + migrated_database_urls, + monkeypatch, +) -> None: + user_id = seed_user(migrated_database_urls["app"], email="owner@example.com") + intruder_id = seed_user(migrated_database_urls["app"], email="intruder@example.com") + + thread_id = UUID("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") + task_id = UUID("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb") + + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + + capture_primary = store.create_continuity_capture_event( + raw_content="Decision: Keep rollout phased", + explicit_signal="decision", + admission_posture="DERIVED", + admission_reason="explicit_signal_decision", + ) + primary_object = store.create_continuity_object( + capture_event_id=capture_primary["id"], + object_type="Decision", + status="active", + title="Decision: Keep rollout phased", + body={"decision_text": "Keep rollout phased"}, + provenance={ + "thread_id": str(thread_id), + "task_id": str(task_id), + "project": "Project Phoenix", + "person": "Alex", + "confirmation_status": "confirmed", + "source_event_ids": ["event-1"], + }, + confidence=0.95, + ) + + capture_other = store.create_continuity_capture_event( + raw_content="Decision: unrelated", + explicit_signal="decision", + admission_posture="DERIVED", + admission_reason="explicit_signal_decision", + ) + other_object = store.create_continuity_object( + capture_event_id=capture_other["id"], + object_type="Decision", + status="active", + title="Decision: unrelated", + body={"decision_text": "unrelated"}, + provenance={ + "thread_id": str(uuid4()), + "task_id": str(uuid4()), + "project": "Project Atlas", + "person": "Taylor", + "confirmation_status": "unconfirmed", + "source_event_ids": ["event-2"], + }, + confidence=0.9, + ) + + set_continuity_timestamps( + migrated_database_urls["admin"], + continuity_object_id=primary_object["id"], + created_at=datetime(2026, 3, 29, 10, 0, tzinfo=UTC), + ) + set_continuity_timestamps( + migrated_database_urls["admin"], + continuity_object_id=other_object["id"], + created_at=datetime(2026, 3, 29, 9, 0, tzinfo=UTC), + ) + + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + status, payload = invoke_request( + "GET", + "/v0/continuity/recall", + query_params={ + "user_id": str(user_id), + "thread_id": str(thread_id), + "task_id": str(task_id), + "project": "Project Phoenix", + "person": "Alex", + "query": "rollout", + "since": "2026-03-29T09:30:00+00:00", + "until": "2026-03-29T11:00:00+00:00", + "limit": "20", + }, + ) + + assert status == 200 + assert payload["summary"] == { + "query": "rollout", + "filters": { + "thread_id": str(thread_id), + "task_id": str(task_id), + "project": "Project Phoenix", + "person": "Alex", + "since": "2026-03-29T09:30:00+00:00", + "until": "2026-03-29T11:00:00+00:00", + }, + "limit": 20, + "returned_count": 1, + "total_count": 1, + "order": ["relevance_desc", "created_at_desc", "id_desc"], + } + assert payload["items"][0]["title"] == "Decision: Keep rollout phased" + assert payload["items"][0]["confirmation_status"] == "confirmed" + assert payload["items"][0]["admission_posture"] == "DERIVED" + assert payload["items"][0]["provenance_references"] == [ + {"source_kind": "continuity_capture_event", "source_id": payload["items"][0]["capture_event_id"]}, + {"source_kind": "source_event", "source_id": "event-1"}, + {"source_kind": "task", "source_id": str(task_id)}, + {"source_kind": "thread", "source_id": str(thread_id)}, + ] + assert payload["items"][0]["ordering"]["freshness_posture"] == "fresh" + assert payload["items"][0]["ordering"]["provenance_posture"] == "strong" + assert payload["items"][0]["ordering"]["supersession_posture"] == "current" + + intruder_status, intruder_payload = invoke_request( + "GET", + "/v0/continuity/recall", + query_params={"user_id": str(intruder_id), "limit": "20"}, + ) + assert intruder_status == 200 + assert intruder_payload == { + "items": [], + "summary": { + "query": None, + "filters": {"since": None, "until": None}, + "limit": 20, + "returned_count": 0, + "total_count": 0, + "order": ["relevance_desc", "created_at_desc", "id_desc"], + }, + } + + +def test_continuity_recall_api_rejects_invalid_time_window( + migrated_database_urls, + monkeypatch, +) -> None: + user_id = seed_user(migrated_database_urls["app"], email="owner2@example.com") + + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + status, payload = invoke_request( + "GET", + "/v0/continuity/recall", + query_params={ + "user_id": str(user_id), + "since": "2026-03-29T11:00:00+00:00", + "until": "2026-03-29T10:00:00+00:00", + "limit": "20", + }, + ) + + assert status == 400 + assert payload == {"detail": "until must be greater than or equal to since"} + + +def test_continuity_recall_api_prefers_confirmed_fresh_active_truth_over_superseded_chain( + migrated_database_urls, + monkeypatch, +) -> None: + user_id = seed_user(migrated_database_urls["app"], email="freshness@example.com") + + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + + current_capture = store.create_continuity_capture_event( + raw_content="Decision: API timeout is 30s", + explicit_signal="decision", + admission_posture="DERIVED", + admission_reason="explicit_signal_decision", + ) + current_object = store.create_continuity_object( + capture_event_id=current_capture["id"], + object_type="Decision", + status="active", + title="Decision: API timeout is 30s", + body={"decision_text": "api timeout is 30 seconds"}, + provenance={"confirmation_status": "confirmed", "source_event_ids": ["event-current"]}, + confidence=0.62, + last_confirmed_at=datetime(2026, 3, 29, 10, 30, tzinfo=UTC), + ) + + stale_capture = store.create_continuity_capture_event( + raw_content="Decision: API timeout was 45s", + explicit_signal="decision", + admission_posture="DERIVED", + admission_reason="explicit_signal_decision", + ) + stale_object = store.create_continuity_object( + capture_event_id=stale_capture["id"], + object_type="Decision", + status="stale", + title="Decision: API timeout was 45s", + body={"decision_text": "api timeout is 45 seconds"}, + provenance={"confirmation_status": "confirmed"}, + confidence=0.99, + last_confirmed_at=datetime(2026, 3, 20, 9, 30, tzinfo=UTC), + ) + + superseded_capture = store.create_continuity_capture_event( + raw_content="Decision: API timeout was 60s", + explicit_signal="decision", + admission_posture="DERIVED", + admission_reason="explicit_signal_decision", + ) + superseded_object = store.create_continuity_object( + capture_event_id=superseded_capture["id"], + object_type="Decision", + status="superseded", + title="Decision: API timeout was 60s", + body={"decision_text": "api timeout is 60 seconds"}, + provenance={"confirmation_status": "confirmed"}, + confidence=1.0, + last_confirmed_at=datetime(2026, 3, 10, 8, 0, tzinfo=UTC), + superseded_by_object_id=current_object["id"], + ) + + set_continuity_timestamps( + migrated_database_urls["admin"], + continuity_object_id=current_object["id"], + created_at=datetime(2026, 3, 29, 10, 0, tzinfo=UTC), + ) + set_continuity_timestamps( + migrated_database_urls["admin"], + continuity_object_id=stale_object["id"], + created_at=datetime(2026, 3, 20, 9, 0, tzinfo=UTC), + ) + set_continuity_timestamps( + migrated_database_urls["admin"], + continuity_object_id=superseded_object["id"], + created_at=datetime(2026, 3, 10, 8, 0, tzinfo=UTC), + ) + + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + status, payload = invoke_request( + "GET", + "/v0/continuity/recall", + query_params={ + "user_id": str(user_id), + "query": "api timeout", + "limit": "20", + }, + ) + + assert status == 200 + assert [item["title"] for item in payload["items"]] == [ + "Decision: API timeout is 30s", + "Decision: API timeout was 45s", + "Decision: API timeout was 60s", + ] + assert payload["items"][0]["ordering"]["freshness_posture"] == "fresh" + assert payload["items"][0]["ordering"]["supersession_posture"] == "current" + assert payload["items"][-1]["ordering"]["supersession_posture"] == "superseded" + + +def test_continuity_recall_api_excludes_preserved_but_non_searchable_objects( + migrated_database_urls, + monkeypatch, +) -> None: + user_id = seed_user(migrated_database_urls["app"], email="nonsearchable@example.com") + + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + hidden_capture = store.create_continuity_capture_event( + raw_content="Note: internal scratchpad", + explicit_signal="note", + admission_posture="DERIVED", + admission_reason="explicit_signal_note", + ) + hidden_object = store.create_continuity_object( + capture_event_id=hidden_capture["id"], + object_type="Note", + status="active", + title="Note: internal scratchpad", + body={"body": "internal scratchpad"}, + provenance={}, + confidence=1.0, + ) + visible_capture = store.create_continuity_capture_event( + raw_content="Decision: public outcome", + explicit_signal="decision", + admission_posture="DERIVED", + admission_reason="explicit_signal_decision", + ) + visible_object = store.create_continuity_object( + capture_event_id=visible_capture["id"], + object_type="Decision", + status="active", + title="Decision: public outcome", + body={"decision_text": "public outcome"}, + provenance={}, + confidence=1.0, + ) + + set_continuity_lifecycle_flags( + migrated_database_urls["admin"], + continuity_object_id=hidden_object["id"], + is_searchable=False, + ) + set_continuity_timestamps( + migrated_database_urls["admin"], + continuity_object_id=hidden_object["id"], + created_at=datetime(2026, 3, 29, 10, 0, tzinfo=UTC), + ) + set_continuity_timestamps( + migrated_database_urls["admin"], + continuity_object_id=visible_object["id"], + created_at=datetime(2026, 3, 29, 10, 5, tzinfo=UTC), + ) + + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + status, payload = invoke_request( + "GET", + "/v0/continuity/recall", + query_params={ + "user_id": str(user_id), + "limit": "20", + }, + ) + + assert status == 200 + assert [item["title"] for item in payload["items"]] == ["Decision: public outcome"] + + +def test_continuity_lifecycle_debug_endpoints_expose_flags( + migrated_database_urls, + monkeypatch, +) -> None: + user_id = seed_user(migrated_database_urls["app"], email="lifecycle-debug@example.com") + + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + capture = store.create_continuity_capture_event( + raw_content="Remember: searchable but not promotable", + explicit_signal="remember_this", + admission_posture="DERIVED", + admission_reason="explicit_signal_remember_this", + ) + continuity_object = store.create_continuity_object( + capture_event_id=capture["id"], + object_type="MemoryFact", + status="active", + title="Memory Fact: searchable but not promotable", + body={"fact_text": "searchable but not promotable"}, + provenance={}, + confidence=0.9, + ) + + set_continuity_lifecycle_flags( + migrated_database_urls["admin"], + continuity_object_id=continuity_object["id"], + is_promotable=False, + ) + + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + list_status, list_payload = invoke_request( + "GET", + "/v0/admin/debug/continuity/lifecycle", + query_params={"user_id": str(user_id), "limit": "20"}, + ) + detail_status, detail_payload = invoke_request( + "GET", + f"/v0/admin/debug/continuity/lifecycle/{continuity_object['id']}", + query_params={"user_id": str(user_id)}, + ) + + assert list_status == 200 + assert list_payload["summary"]["counts"]["preserved_count"] == 1 + assert list_payload["summary"]["counts"]["searchable_count"] == 1 + assert list_payload["summary"]["counts"]["promotable_count"] == 0 + assert list_payload["items"][0]["lifecycle"] == { + "is_preserved": True, + "preservation_status": "preserved", + "is_searchable": True, + "searchability_status": "searchable", + "is_promotable": False, + "promotion_status": "not_promotable", + } + + assert detail_status == 200 + assert detail_payload["continuity_object"]["id"] == str(continuity_object["id"]) + assert detail_payload["continuity_object"]["lifecycle"]["is_promotable"] is False diff --git a/tests/integration/test_continuity_resumption_api.py b/tests/integration/test_continuity_resumption_api.py new file mode 100644 index 0000000..9c06bc8 --- /dev/null +++ b/tests/integration/test_continuity_resumption_api.py @@ -0,0 +1,502 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +import json +from typing import Any +from urllib.parse import urlencode +from uuid import UUID, uuid4 + +import anyio +import psycopg + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.db import user_connection +from alicebot_api.store import ContinuityStore + + +def invoke_request( + method: str, + path: str, + *, + query_params: dict[str, str] | None = None, + payload: dict[str, Any] | None = None, +) -> tuple[int, dict[str, Any]]: + messages: list[dict[str, object]] = [] + encoded_body = b"" if payload is None else json.dumps(payload).encode() + request_received = False + + async def receive() -> dict[str, object]: + nonlocal request_received + if request_received: + return {"type": "http.disconnect"} + + request_received = True + return {"type": "http.request", "body": encoded_body, "more_body": False} + + async def send(message: dict[str, object]) -> None: + messages.append(message) + + query_string = urlencode(query_params or {}).encode() + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": query_string, + "headers": [(b"content-type", b"application/json")], + "client": ("testclient", 50000), + "server": ("testserver", 80), + "root_path": "", + } + + anyio.run(main_module.app, scope, receive, send) + + start_message = next(message for message in messages if message["type"] == "http.response.start") + body = b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + return start_message["status"], json.loads(body) + + +def seed_user(database_url: str, *, email: str) -> UUID: + user_id = uuid4() + with user_connection(database_url, user_id) as conn: + ContinuityStore(conn).create_user(user_id, email, email.split("@", 1)[0].title()) + return user_id + + +def set_continuity_timestamps( + admin_database_url: str, + *, + continuity_object_id: UUID, + created_at: datetime, +) -> None: + with psycopg.connect(admin_database_url, autocommit=True) as conn: + with conn.cursor() as cur: + cur.execute( + "UPDATE continuity_objects SET created_at = %s, updated_at = %s WHERE id = %s", + (created_at, created_at, continuity_object_id), + ) + + +def set_continuity_lifecycle_flags( + admin_database_url: str, + *, + continuity_object_id: UUID, + is_promotable: bool, +) -> None: + with psycopg.connect(admin_database_url, autocommit=True) as conn: + with conn.cursor() as cur: + cur.execute( + "UPDATE continuity_objects SET is_promotable = %s WHERE id = %s", + (is_promotable, continuity_object_id), + ) + + +def test_continuity_resumption_api_returns_required_sections( + migrated_database_urls, + monkeypatch, +) -> None: + user_id = seed_user(migrated_database_urls["app"], email="owner@example.com") + thread_id = UUID("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") + + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + + decision_capture = store.create_continuity_capture_event( + raw_content="Decision: Freeze API contract", + explicit_signal="decision", + admission_posture="DERIVED", + admission_reason="explicit_signal_decision", + ) + decision_object = store.create_continuity_object( + capture_event_id=decision_capture["id"], + object_type="Decision", + status="active", + title="Decision: Freeze API contract", + body={"decision_text": "Freeze API contract"}, + provenance={"thread_id": str(thread_id)}, + confidence=1.0, + ) + + waiting_capture = store.create_continuity_capture_event( + raw_content="Waiting For: Vendor quote", + explicit_signal="waiting_for", + admission_posture="DERIVED", + admission_reason="explicit_signal_waiting_for", + ) + waiting_object = store.create_continuity_object( + capture_event_id=waiting_capture["id"], + object_type="WaitingFor", + status="active", + title="Waiting For: Vendor quote", + body={"waiting_for_text": "Vendor quote"}, + provenance={"thread_id": str(thread_id)}, + confidence=1.0, + ) + + next_capture = store.create_continuity_capture_event( + raw_content="Next Action: Send approval email", + explicit_signal="next_action", + admission_posture="DERIVED", + admission_reason="explicit_signal_next_action", + ) + next_object = store.create_continuity_object( + capture_event_id=next_capture["id"], + object_type="NextAction", + status="active", + title="Next Action: Send approval email", + body={"action_text": "Send approval email"}, + provenance={"thread_id": str(thread_id)}, + confidence=1.0, + ) + + latest_decision_capture = store.create_continuity_capture_event( + raw_content="Decision: Keep rollout phased", + explicit_signal="decision", + admission_posture="DERIVED", + admission_reason="explicit_signal_decision", + ) + latest_decision_object = store.create_continuity_object( + capture_event_id=latest_decision_capture["id"], + object_type="Decision", + status="active", + title="Decision: Keep rollout phased", + body={"decision_text": "Keep rollout phased"}, + provenance={"thread_id": str(thread_id)}, + confidence=1.0, + ) + + set_continuity_timestamps( + migrated_database_urls["admin"], + continuity_object_id=decision_object["id"], + created_at=datetime(2026, 3, 29, 9, 0, tzinfo=UTC), + ) + set_continuity_timestamps( + migrated_database_urls["admin"], + continuity_object_id=waiting_object["id"], + created_at=datetime(2026, 3, 29, 10, 0, tzinfo=UTC), + ) + set_continuity_timestamps( + migrated_database_urls["admin"], + continuity_object_id=next_object["id"], + created_at=datetime(2026, 3, 29, 10, 5, tzinfo=UTC), + ) + set_continuity_timestamps( + migrated_database_urls["admin"], + continuity_object_id=latest_decision_object["id"], + created_at=datetime(2026, 3, 29, 10, 10, tzinfo=UTC), + ) + + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + status, payload = invoke_request( + "GET", + "/v0/continuity/resumption-brief", + query_params={ + "user_id": str(user_id), + "thread_id": str(thread_id), + "max_recent_changes": "3", + "max_open_loops": "2", + }, + ) + + assert status == 200 + brief = payload["brief"] + assert brief["assembly_version"] == "continuity_resumption_brief_v0" + assert brief["last_decision"]["item"]["title"] == "Decision: Keep rollout phased" + assert brief["open_loops"]["summary"] == { + "limit": 2, + "returned_count": 1, + "total_count": 1, + "order": ["created_at_desc", "id_desc"], + } + assert [item["title"] for item in brief["recent_changes"]["items"]] == [ + "Decision: Keep rollout phased", + "Next Action: Send approval email", + "Waiting For: Vendor quote", + ] + assert brief["next_action"]["item"]["title"] == "Next Action: Send approval email" + + +def test_continuity_resumption_api_returns_explicit_empty_states( + migrated_database_urls, + monkeypatch, +) -> None: + user_id = seed_user(migrated_database_urls["app"], email="owner2@example.com") + task_id = UUID("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb") + + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + capture = store.create_continuity_capture_event( + raw_content="Note: context only", + explicit_signal="note", + admission_posture="DERIVED", + admission_reason="explicit_signal_note", + ) + continuity_object = store.create_continuity_object( + capture_event_id=capture["id"], + object_type="Note", + status="active", + title="Note: context only", + body={"body": "context only"}, + provenance={"task_id": str(task_id)}, + confidence=1.0, + ) + + set_continuity_timestamps( + migrated_database_urls["admin"], + continuity_object_id=continuity_object["id"], + created_at=datetime(2026, 3, 29, 9, 0, tzinfo=UTC), + ) + + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + status, payload = invoke_request( + "GET", + "/v0/continuity/resumption-brief", + query_params={ + "user_id": str(user_id), + "task_id": str(task_id), + "max_recent_changes": "2", + "max_open_loops": "2", + }, + ) + + assert status == 200 + assert payload["brief"]["last_decision"] == { + "item": None, + "empty_state": { + "is_empty": True, + "message": "No decision found in the requested scope.", + }, + } + assert payload["brief"]["open_loops"]["empty_state"] == { + "is_empty": True, + "message": "No open loops found in the requested scope.", + } + assert payload["brief"]["next_action"] == { + "item": None, + "empty_state": { + "is_empty": True, + "message": "No next action found in the requested scope.", + }, + } + + +def test_continuity_resumption_api_selects_latest_sections_beyond_recall_limit( + migrated_database_urls, + monkeypatch, +) -> None: + user_id = seed_user(migrated_database_urls["app"], email="owner3@example.com") + thread_id = UUID("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") + base_time = datetime(2026, 3, 29, 8, 0, tzinfo=UTC) + + historical_object_ids: list[UUID] = [] + + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + + for index in range(110): + capture = store.create_continuity_capture_event( + raw_content=f"Decision: historical {index}", + explicit_signal="decision", + admission_posture="DERIVED", + admission_reason="explicit_signal_decision", + ) + continuity_object = store.create_continuity_object( + capture_event_id=capture["id"], + object_type="Decision", + status="active", + title=f"Decision: historical {index}", + body={"decision_text": f"historical {index}"}, + provenance={"thread_id": str(thread_id)}, + confidence=1.0, + ) + historical_object_ids.append(continuity_object["id"]) + + latest_decision_capture = store.create_continuity_capture_event( + raw_content="Decision: newest low confidence", + explicit_signal="decision", + admission_posture="DERIVED", + admission_reason="explicit_signal_decision", + ) + latest_decision_object = store.create_continuity_object( + capture_event_id=latest_decision_capture["id"], + object_type="Decision", + status="active", + title="Decision: newest low confidence", + body={"decision_text": "newest low confidence"}, + provenance={"thread_id": str(thread_id)}, + confidence=0.01, + ) + + latest_next_action_capture = store.create_continuity_capture_event( + raw_content="Next Action: newest low confidence", + explicit_signal="next_action", + admission_posture="DERIVED", + admission_reason="explicit_signal_next_action", + ) + latest_next_action_object = store.create_continuity_object( + capture_event_id=latest_next_action_capture["id"], + object_type="NextAction", + status="active", + title="Next Action: newest low confidence", + body={"action_text": "newest low confidence"}, + provenance={"thread_id": str(thread_id)}, + confidence=0.01, + ) + + for index, continuity_object_id in enumerate(historical_object_ids): + set_continuity_timestamps( + migrated_database_urls["admin"], + continuity_object_id=continuity_object_id, + created_at=base_time + timedelta(minutes=index), + ) + + set_continuity_timestamps( + migrated_database_urls["admin"], + continuity_object_id=latest_decision_object["id"], + created_at=base_time + timedelta(minutes=200), + ) + set_continuity_timestamps( + migrated_database_urls["admin"], + continuity_object_id=latest_next_action_object["id"], + created_at=base_time + timedelta(minutes=201), + ) + + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + status, payload = invoke_request( + "GET", + "/v0/continuity/resumption-brief", + query_params={ + "user_id": str(user_id), + "thread_id": str(thread_id), + "max_recent_changes": "2", + "max_open_loops": "1", + }, + ) + + assert status == 200 + brief = payload["brief"] + assert brief["last_decision"]["item"]["title"] == "Decision: newest low confidence" + assert brief["next_action"]["item"]["title"] == "Next Action: newest low confidence" + assert [item["title"] for item in brief["recent_changes"]["items"]] == [ + "Next Action: newest low confidence", + "Decision: newest low confidence", + ] + + +def test_continuity_resumption_api_uses_promotable_facts_by_default_with_override( + migrated_database_urls, + monkeypatch, +) -> None: + user_id = seed_user(migrated_database_urls["app"], email="promotable@example.com") + thread_id = UUID("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") + + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + fact_capture = store.create_continuity_capture_event( + raw_content="Remember: hidden from brief", + explicit_signal="remember_this", + admission_posture="DERIVED", + admission_reason="explicit_signal_remember_this", + ) + fact_object = store.create_continuity_object( + capture_event_id=fact_capture["id"], + object_type="MemoryFact", + status="active", + title="Memory Fact: hidden from brief", + body={"fact_text": "hidden from brief"}, + provenance={"thread_id": str(thread_id)}, + confidence=0.9, + ) + decision_capture = store.create_continuity_capture_event( + raw_content="Decision: visible in brief", + explicit_signal="decision", + admission_posture="DERIVED", + admission_reason="explicit_signal_decision", + ) + decision_object = store.create_continuity_object( + capture_event_id=decision_capture["id"], + object_type="Decision", + status="active", + title="Decision: visible in brief", + body={"decision_text": "visible in brief"}, + provenance={"thread_id": str(thread_id)}, + confidence=1.0, + ) + + set_continuity_lifecycle_flags( + migrated_database_urls["admin"], + continuity_object_id=fact_object["id"], + is_promotable=False, + ) + set_continuity_timestamps( + migrated_database_urls["admin"], + continuity_object_id=fact_object["id"], + created_at=datetime(2026, 3, 29, 10, 0, tzinfo=UTC), + ) + set_continuity_timestamps( + migrated_database_urls["admin"], + continuity_object_id=decision_object["id"], + created_at=datetime(2026, 3, 29, 10, 5, tzinfo=UTC), + ) + + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + default_status, default_payload = invoke_request( + "GET", + "/v0/continuity/resumption-brief", + query_params={ + "user_id": str(user_id), + "thread_id": str(thread_id), + "max_recent_changes": "5", + "max_open_loops": "2", + }, + ) + override_status, override_payload = invoke_request( + "GET", + "/v0/continuity/resumption-brief", + query_params={ + "user_id": str(user_id), + "thread_id": str(thread_id), + "max_recent_changes": "5", + "max_open_loops": "2", + "include_non_promotable_facts": "true", + }, + ) + + assert default_status == 200 + assert [item["title"] for item in default_payload["brief"]["recent_changes"]["items"]] == [ + "Decision: visible in brief", + ] + + assert override_status == 200 + assert [item["title"] for item in override_payload["brief"]["recent_changes"]["items"]] == [ + "Decision: visible in brief", + "Memory Fact: hidden from brief", + ] diff --git a/tests/integration/test_continuity_review_api.py b/tests/integration/test_continuity_review_api.py new file mode 100644 index 0000000..7268fa5 --- /dev/null +++ b/tests/integration/test_continuity_review_api.py @@ -0,0 +1,325 @@ +from __future__ import annotations + +import json +from typing import Any +from urllib.parse import urlencode +from uuid import UUID, uuid4 + +import anyio + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.db import user_connection +from alicebot_api.store import ContinuityStore + + +def invoke_request( + method: str, + path: str, + *, + query_params: dict[str, str] | None = None, + payload: dict[str, Any] | None = None, +) -> tuple[int, dict[str, Any]]: + messages: list[dict[str, object]] = [] + encoded_body = b"" if payload is None else json.dumps(payload).encode() + request_received = False + + async def receive() -> dict[str, object]: + nonlocal request_received + if request_received: + return {"type": "http.disconnect"} + + request_received = True + return {"type": "http.request", "body": encoded_body, "more_body": False} + + async def send(message: dict[str, object]) -> None: + messages.append(message) + + query_string = urlencode(query_params or {}).encode() + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": query_string, + "headers": [(b"content-type", b"application/json")], + "client": ("testclient", 50000), + "server": ("testserver", 80), + "root_path": "", + } + + anyio.run(main_module.app, scope, receive, send) + + start_message = next(message for message in messages if message["type"] == "http.response.start") + body = b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + return start_message["status"], json.loads(body) + + +def seed_user(database_url: str, *, email: str) -> UUID: + user_id = uuid4() + with user_connection(database_url, user_id) as conn: + ContinuityStore(conn).create_user(user_id, email, email.split("@", 1)[0].title()) + return user_id + + +def test_continuity_review_queue_and_confirm_flow( + migrated_database_urls, + monkeypatch, +) -> None: + user_id = seed_user(migrated_database_urls["app"], email="reviewer@example.com") + + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + capture = store.create_continuity_capture_event( + raw_content="Decision: Keep conservative rollout", + explicit_signal="decision", + admission_posture="DERIVED", + admission_reason="explicit_signal_decision", + ) + continuity_object = store.create_continuity_object( + capture_event_id=capture["id"], + object_type="Decision", + status="active", + title="Decision: Keep conservative rollout", + body={"decision_text": "Keep conservative rollout"}, + provenance={"thread_id": "thread-1"}, + confidence=0.94, + ) + + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + queue_status, queue_payload = invoke_request( + "GET", + "/v0/continuity/review-queue", + query_params={ + "user_id": str(user_id), + "status": "correction_ready", + "limit": "20", + }, + ) + + assert queue_status == 200 + assert queue_payload["summary"] == { + "status": "correction_ready", + "limit": 20, + "returned_count": 1, + "total_count": 1, + "order": ["updated_at_desc", "created_at_desc", "id_desc"], + } + assert queue_payload["items"][0]["id"] == str(continuity_object["id"]) + assert queue_payload["items"][0]["last_confirmed_at"] is None + + confirm_status, confirm_payload = invoke_request( + "POST", + f"/v0/continuity/review-queue/{continuity_object['id']}/corrections", + payload={ + "user_id": str(user_id), + "action": "confirm", + "reason": "Verified in continuity review", + }, + ) + + assert confirm_status == 200 + assert confirm_payload["continuity_object"]["status"] == "active" + assert confirm_payload["continuity_object"]["last_confirmed_at"] is not None + assert confirm_payload["correction_event"]["action"] == "confirm" + + detail_status, detail_payload = invoke_request( + "GET", + f"/v0/continuity/review-queue/{continuity_object['id']}", + query_params={"user_id": str(user_id)}, + ) + assert detail_status == 200 + assert detail_payload["review"]["correction_events"][0]["action"] == "confirm" + + +def test_continuity_review_supersede_updates_recall_and_resumption_immediately( + migrated_database_urls, + monkeypatch, +) -> None: + user_id = seed_user(migrated_database_urls["app"], email="reviewer2@example.com") + thread_id = UUID("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") + + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + capture = store.create_continuity_capture_event( + raw_content="Decision: Legacy plan", + explicit_signal="decision", + admission_posture="DERIVED", + admission_reason="explicit_signal_decision", + ) + continuity_object = store.create_continuity_object( + capture_event_id=capture["id"], + object_type="Decision", + status="active", + title="Decision: Legacy plan", + body={"decision_text": "Legacy plan"}, + provenance={"thread_id": str(thread_id)}, + confidence=0.9, + ) + + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + supersede_status, supersede_payload = invoke_request( + "POST", + f"/v0/continuity/review-queue/{continuity_object['id']}/corrections", + payload={ + "user_id": str(user_id), + "action": "supersede", + "reason": "Contradicted by latest decision", + "replacement_title": "Decision: Updated plan", + "replacement_body": {"decision_text": "Updated plan"}, + "replacement_provenance": {"thread_id": str(thread_id)}, + "replacement_confidence": 0.97, + }, + ) + + assert supersede_status == 200 + assert supersede_payload["continuity_object"]["status"] == "superseded" + assert supersede_payload["replacement_object"]["status"] == "active" + + replacement_id = supersede_payload["replacement_object"]["id"] + + recall_status, recall_payload = invoke_request( + "GET", + "/v0/continuity/recall", + query_params={ + "user_id": str(user_id), + "thread_id": str(thread_id), + "limit": "20", + }, + ) + assert recall_status == 200 + assert recall_payload["items"][0]["id"] == replacement_id + assert {item["status"] for item in recall_payload["items"]} == {"active", "superseded"} + + brief_status, brief_payload = invoke_request( + "GET", + "/v0/continuity/resumption-brief", + query_params={ + "user_id": str(user_id), + "thread_id": str(thread_id), + "max_recent_changes": "5", + "max_open_loops": "5", + }, + ) + assert brief_status == 200 + assert brief_payload["brief"]["last_decision"]["item"]["id"] == replacement_id + + detail_status, detail_payload = invoke_request( + "GET", + f"/v0/continuity/review-queue/{continuity_object['id']}", + query_params={"user_id": str(user_id)}, + ) + assert detail_status == 200 + assert detail_payload["review"]["supersession_chain"]["superseded_by"]["id"] == replacement_id + assert detail_payload["review"]["correction_events"][0]["action"] == "supersede" + + +def test_continuity_review_mark_stale_and_delete_posture( + migrated_database_urls, + monkeypatch, +) -> None: + user_id = seed_user(migrated_database_urls["app"], email="reviewer3@example.com") + + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + + stale_capture = store.create_continuity_capture_event( + raw_content="Decision: Might be stale", + explicit_signal="decision", + admission_posture="DERIVED", + admission_reason="explicit_signal_decision", + ) + stale_object = store.create_continuity_object( + capture_event_id=stale_capture["id"], + object_type="Decision", + status="active", + title="Decision: Might be stale", + body={"decision_text": "Might be stale"}, + provenance={"thread_id": "thread-3"}, + confidence=0.8, + ) + + delete_capture = store.create_continuity_capture_event( + raw_content="Decision: Remove this", + explicit_signal="decision", + admission_posture="DERIVED", + admission_reason="explicit_signal_decision", + ) + delete_object = store.create_continuity_object( + capture_event_id=delete_capture["id"], + object_type="Decision", + status="active", + title="Decision: Remove this", + body={"decision_text": "Remove this"}, + provenance={"thread_id": "thread-3"}, + confidence=0.8, + ) + + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + stale_status, stale_payload = invoke_request( + "POST", + f"/v0/continuity/review-queue/{stale_object['id']}/corrections", + payload={ + "user_id": str(user_id), + "action": "mark_stale", + }, + ) + assert stale_status == 200 + assert stale_payload["continuity_object"]["status"] == "stale" + + delete_status, delete_payload = invoke_request( + "POST", + f"/v0/continuity/review-queue/{delete_object['id']}/corrections", + payload={ + "user_id": str(user_id), + "action": "delete", + "reason": "No longer relevant", + }, + ) + assert delete_status == 200 + assert delete_payload["continuity_object"]["status"] == "deleted" + + stale_queue_status, stale_queue_payload = invoke_request( + "GET", + "/v0/continuity/review-queue", + query_params={ + "user_id": str(user_id), + "status": "stale", + "limit": "20", + }, + ) + assert stale_queue_status == 200 + assert [item["id"] for item in stale_queue_payload["items"]] == [str(stale_object["id"])] + + recall_status, recall_payload = invoke_request( + "GET", + "/v0/continuity/recall", + query_params={ + "user_id": str(user_id), + "limit": "20", + }, + ) + assert recall_status == 200 + assert all(item["id"] != str(delete_object["id"]) for item in recall_payload["items"]) diff --git a/tests/integration/test_continuity_store.py b/tests/integration/test_continuity_store.py new file mode 100644 index 0000000..9561563 --- /dev/null +++ b/tests/integration/test_continuity_store.py @@ -0,0 +1,244 @@ +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor, TimeoutError +from uuid import uuid4 + +import psycopg +from psycopg.rows import dict_row +import pytest + +from alicebot_api.db import set_current_user, user_connection +from alicebot_api.store import ContinuityStore + + +def test_thread_session_and_event_persistence(migrated_database_urls): + user_id = uuid4() + + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + user = store.create_user(user_id, "owner@example.com", "Owner") + thread = store.create_thread("Starter thread") + session = store.create_session(thread["id"]) + first_event = store.append_event( + thread["id"], + session["id"], + "message.user", + {"text": "hello"}, + ) + second_event = store.append_event( + thread["id"], + session["id"], + "message.assistant", + {"text": "hi"}, + ) + events = store.list_thread_events(thread["id"]) + + assert user["id"] == user_id + assert session["thread_id"] == thread["id"] + assert [first_event["sequence_no"], second_event["sequence_no"]] == [1, 2] + assert [event["kind"] for event in events] == ["message.user", "message.assistant"] + assert events[0]["payload"]["text"] == "hello" + + with psycopg.connect(migrated_database_urls["admin"]) as conn: + with pytest.raises(psycopg.Error, match="append-only"): + with conn.cursor() as cur: + cur.execute( + "UPDATE events SET kind = 'message.mutated' WHERE id = %s", + (first_event["id"],), + ) + + +def test_event_deletes_are_rejected_at_database_level(migrated_database_urls): + user_id = uuid4() + + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + store.create_user(user_id, "owner@example.com", "Owner") + thread = store.create_thread("Delete-protected thread") + session = store.create_session(thread["id"]) + event = store.append_event(thread["id"], session["id"], "message.user", {"text": "keep"}) + + with psycopg.connect(migrated_database_urls["admin"]) as conn: + with pytest.raises(psycopg.Error, match="append-only"): + with conn.cursor() as cur: + cur.execute("DELETE FROM events WHERE id = %s", (event["id"],)) + + +def test_continuity_rls_blocks_cross_user_access(migrated_database_urls): + owner_id = uuid4() + intruder_id = uuid4() + + with user_connection(migrated_database_urls["app"], owner_id) as owner_conn: + owner_store = ContinuityStore(owner_conn) + owner_store.create_user(owner_id, "owner@example.com", "Owner") + thread = owner_store.create_thread("Private thread") + session = owner_store.create_session(thread["id"]) + owner_store.append_event(thread["id"], session["id"], "message.user", {"text": "secret"}) + + with user_connection(migrated_database_urls["app"], intruder_id) as intruder_conn: + intruder_store = ContinuityStore(intruder_conn) + intruder_store.create_user(intruder_id, "intruder@example.com", "Intruder") + + with intruder_conn.cursor() as cur: + cur.execute("SELECT COUNT(*) AS count FROM users WHERE id = %s", (owner_id,)) + user_count_row = cur.fetchone() + cur.execute("SELECT COUNT(*) AS count FROM threads WHERE id = %s", (thread["id"],)) + thread_count_row = cur.fetchone() + cur.execute("SELECT COUNT(*) AS count FROM sessions WHERE id = %s", (session["id"],)) + session_count_row = cur.fetchone() + + visible_events = intruder_store.list_thread_events(thread["id"]) + + assert user_count_row["count"] == 0 + assert thread_count_row["count"] == 0 + assert session_count_row["count"] == 0 + assert visible_events == [] + + with pytest.raises(psycopg.Error): + intruder_store.append_event( + thread["id"], + None, + "message.user", + {"text": "tamper"}, + ) + + +def test_runtime_role_is_insert_select_only_for_continuity_tables(migrated_database_urls): + with psycopg.connect(migrated_database_urls["app"]) as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT + has_table_privilege(current_user, 'users', 'SELECT'), + has_table_privilege(current_user, 'users', 'INSERT'), + has_table_privilege(current_user, 'users', 'UPDATE'), + has_table_privilege(current_user, 'threads', 'UPDATE'), + has_table_privilege(current_user, 'sessions', 'UPDATE'), + has_table_privilege(current_user, 'events', 'UPDATE'), + has_table_privilege(current_user, 'events', 'DELETE'), + has_table_privilege(current_user, 'traces', 'SELECT'), + has_table_privilege(current_user, 'traces', 'INSERT'), + has_table_privilege(current_user, 'traces', 'UPDATE'), + has_table_privilege(current_user, 'trace_events', 'SELECT'), + has_table_privilege(current_user, 'trace_events', 'INSERT'), + has_table_privilege(current_user, 'trace_events', 'UPDATE'), + has_table_privilege(current_user, 'trace_events', 'DELETE'), + has_table_privilege(current_user, 'consents', 'SELECT'), + has_table_privilege(current_user, 'consents', 'INSERT'), + has_table_privilege(current_user, 'consents', 'UPDATE'), + has_table_privilege(current_user, 'consents', 'DELETE'), + has_table_privilege(current_user, 'policies', 'SELECT'), + has_table_privilege(current_user, 'policies', 'INSERT'), + has_table_privilege(current_user, 'policies', 'UPDATE'), + has_table_privilege(current_user, 'policies', 'DELETE'), + has_table_privilege(current_user, 'tools', 'SELECT'), + has_table_privilege(current_user, 'tools', 'INSERT'), + has_table_privilege(current_user, 'tools', 'UPDATE'), + has_table_privilege(current_user, 'tools', 'DELETE') + """ + ) + ( + users_select, + users_insert, + users_update, + threads_update, + sessions_update, + events_update, + events_delete, + traces_select, + traces_insert, + traces_update, + trace_events_select, + trace_events_insert, + trace_events_update, + trace_events_delete, + consents_select, + consents_insert, + consents_update, + consents_delete, + policies_select, + policies_insert, + policies_update, + policies_delete, + tools_select, + tools_insert, + tools_update, + tools_delete, + ) = cur.fetchone() + + assert users_select is True + assert users_insert is True + assert users_update is False + assert threads_update is False + assert sessions_update is False + assert events_update is False + assert events_delete is False + assert traces_select is True + assert traces_insert is True + assert traces_update is False + assert trace_events_select is True + assert trace_events_insert is True + assert trace_events_update is False + assert trace_events_delete is False + assert consents_select is True + assert consents_insert is True + assert consents_update is True + assert consents_delete is False + assert policies_select is True + assert policies_insert is True + assert policies_update is False + assert policies_delete is False + assert tools_select is True + assert tools_insert is True + assert tools_update is False + assert tools_delete is False + + +def test_concurrent_event_appends_keep_monotonic_sequence_numbers(migrated_database_urls): + user_id = uuid4() + + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + store.create_user(user_id, "owner@example.com", "Owner") + thread = store.create_thread("Concurrent thread") + session = store.create_session(thread["id"]) + + with ( + psycopg.connect(migrated_database_urls["app"], row_factory=dict_row) as first_conn, + psycopg.connect(migrated_database_urls["app"], row_factory=dict_row) as second_conn, + ): + set_current_user(first_conn, user_id) + set_current_user(second_conn, user_id) + + first_store = ContinuityStore(first_conn) + second_store = ContinuityStore(second_conn) + first_event = first_store.append_event( + thread["id"], + session["id"], + "message.user", + {"text": "first"}, + ) + + with ThreadPoolExecutor(max_workers=1) as executor: + future = executor.submit( + second_store.append_event, + thread["id"], + session["id"], + "message.assistant", + {"text": "second"}, + ) + + with pytest.raises(TimeoutError): + future.result(timeout=0.2) + + first_conn.commit() + second_event = future.result(timeout=5) + + second_conn.commit() + + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + events = store.list_thread_events(thread["id"]) + + assert [first_event["sequence_no"], second_event["sequence_no"]] == [1, 2] + assert [event["sequence_no"] for event in events] == [1, 2] diff --git a/tests/integration/test_embeddings_api.py b/tests/integration/test_embeddings_api.py new file mode 100644 index 0000000..54b316e --- /dev/null +++ b/tests/integration/test_embeddings_api.py @@ -0,0 +1,806 @@ +from __future__ import annotations + +import json +from typing import Any +from urllib.parse import urlencode +from uuid import UUID, uuid4 + +import anyio + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.contracts import MemoryCandidateInput +from alicebot_api.db import user_connection +from alicebot_api.memory import admit_memory_candidate +from alicebot_api.store import ContinuityStore + + +def invoke_request( + method: str, + path: str, + *, + query_params: dict[str, str] | None = None, + payload: dict[str, Any] | None = None, +) -> tuple[int, dict[str, Any]]: + messages: list[dict[str, object]] = [] + encoded_body = b"" if payload is None else json.dumps(payload).encode() + request_received = False + + async def receive() -> dict[str, object]: + nonlocal request_received + if request_received: + return {"type": "http.disconnect"} + + request_received = True + return {"type": "http.request", "body": encoded_body, "more_body": False} + + async def send(message: dict[str, object]) -> None: + messages.append(message) + + query_string = urlencode(query_params or {}).encode() + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": query_string, + "headers": [(b"content-type", b"application/json")], + "client": ("testclient", 50000), + "server": ("testserver", 80), + "root_path": "", + } + + anyio.run(main_module.app, scope, receive, send) + + start_message = next(message for message in messages if message["type"] == "http.response.start") + body = b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + return start_message["status"], json.loads(body) + + +def seed_user_with_memory(database_url: str, *, email: str) -> dict[str, object]: + user_id = uuid4() + + with user_connection(database_url, user_id) as conn: + store = ContinuityStore(conn) + store.create_user(user_id, email, email.split("@", 1)[0].title()) + thread = store.create_thread("Embedding source thread") + session = store.create_session(thread["id"], status="active") + event_id = store.append_event( + thread["id"], + session["id"], + "message.user", + {"text": "likes oat milk"}, + )["id"] + memory = admit_memory_candidate( + store, + user_id=user_id, + candidate=MemoryCandidateInput( + memory_key="user.preference.coffee", + value={"likes": "oat milk"}, + source_event_ids=(event_id,), + ), + ) + + return { + "user_id": user_id, + "memory_id": UUID(memory.memory["id"]), + } + + +def seed_embedding_config( + database_url: str, + *, + user_id: UUID, + provider: str, + model: str, + version: str, + dimensions: int, +) -> UUID: + with user_connection(database_url, user_id) as conn: + created = ContinuityStore(conn).create_embedding_config( + provider=provider, + model=model, + version=version, + dimensions=dimensions, + status="active", + metadata={"task": "memory_retrieval"}, + ) + return created["id"] + + +def seed_memory_with_embedding( + database_url: str, + *, + user_id: UUID, + memory_key: str, + value: dict[str, object], + embedding_config_id: UUID, + vector: list[float], + delete_requested: bool = False, +) -> UUID: + with user_connection(database_url, user_id) as conn: + store = ContinuityStore(conn) + thread = store.create_thread(f"Semantic retrieval thread for {memory_key}") + session = store.create_session(thread["id"], status="active") + event_id = store.append_event( + thread["id"], + session["id"], + "message.user", + {"memory_key": memory_key, "value": value}, + )["id"] + admitted = admit_memory_candidate( + store, + user_id=user_id, + candidate=MemoryCandidateInput( + memory_key=memory_key, + value=value, + source_event_ids=(event_id,), + ), + ) + memory_id = UUID(admitted.memory["id"]) + store.create_memory_embedding( + memory_id=memory_id, + embedding_config_id=embedding_config_id, + dimensions=len(vector), + vector=vector, + ) + if delete_requested: + delete_event_id = store.append_event( + thread["id"], + session["id"], + "message.user", + {"memory_key": memory_key, "delete_requested": True}, + )["id"] + admit_memory_candidate( + store, + user_id=user_id, + candidate=MemoryCandidateInput( + memory_key=memory_key, + value=None, + source_event_ids=(delete_event_id,), + delete_requested=True, + ), + ) + return memory_id + + +def test_embedding_config_endpoints_create_and_list_in_deterministic_order( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_user_with_memory(migrated_database_urls["app"], email="owner@example.com") + seed_embedding_config( + migrated_database_urls["app"], + user_id=seeded["user_id"], + provider="openai", + model="text-embedding-3-small", + version="2026-03-11", + dimensions=1536, + ) + seed_embedding_config( + migrated_database_urls["app"], + user_id=seeded["user_id"], + provider="openai", + model="text-embedding-3-large", + version="2026-03-12", + dimensions=3072, + ) + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + create_status, create_payload = invoke_request( + "POST", + "/v0/embedding-configs", + payload={ + "user_id": str(seeded["user_id"]), + "provider": "openai", + "model": "text-embedding-3-large", + "version": "2026-03-13", + "dimensions": 3, + "status": "active", + "metadata": {"task": "memory_retrieval"}, + }, + ) + list_status, list_payload = invoke_request( + "GET", + "/v0/embedding-configs", + query_params={"user_id": str(seeded["user_id"])}, + ) + + assert create_status == 201 + assert create_payload["embedding_config"]["provider"] == "openai" + assert create_payload["embedding_config"]["version"] == "2026-03-13" + assert list_status == 200 + assert list_payload["summary"] == { + "total_count": 3, + "order": ["created_at_asc", "id_asc"], + } + + with user_connection(migrated_database_urls["app"], seeded["user_id"]) as conn: + expected_configs = ContinuityStore(conn).list_embedding_configs() + + assert [item["id"] for item in list_payload["items"]] == [ + str(config["id"]) for config in expected_configs + ] + + +def test_embedding_config_create_rejects_duplicate_provider_model_version( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_user_with_memory(migrated_database_urls["app"], email="owner@example.com") + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + first_status, first_payload = invoke_request( + "POST", + "/v0/embedding-configs", + payload={ + "user_id": str(seeded["user_id"]), + "provider": "openai", + "model": "text-embedding-3-large", + "version": "2026-03-12", + "dimensions": 3, + "status": "active", + "metadata": {"task": "memory_retrieval"}, + }, + ) + second_status, second_payload = invoke_request( + "POST", + "/v0/embedding-configs", + payload={ + "user_id": str(seeded["user_id"]), + "provider": "openai", + "model": "text-embedding-3-large", + "version": "2026-03-12", + "dimensions": 3, + "status": "active", + "metadata": {"task": "memory_retrieval"}, + }, + ) + + assert first_status == 201 + assert first_payload["embedding_config"]["version"] == "2026-03-12" + assert second_status == 400 + assert second_payload == { + "detail": ( + "embedding config already exists for provider/model/version under the user scope: " + "openai/text-embedding-3-large/2026-03-12" + ) + } + + +def test_memory_embedding_endpoints_persist_and_read_embeddings( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_user_with_memory(migrated_database_urls["app"], email="owner@example.com") + first_config_id = seed_embedding_config( + migrated_database_urls["app"], + user_id=seeded["user_id"], + provider="openai", + model="text-embedding-3-small", + version="2026-03-11", + dimensions=3, + ) + second_config_id = seed_embedding_config( + migrated_database_urls["app"], + user_id=seeded["user_id"], + provider="openai", + model="text-embedding-3-large", + version="2026-03-12", + dimensions=3, + ) + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + first_write_status, first_write_payload = invoke_request( + "POST", + "/v0/memory-embeddings", + payload={ + "user_id": str(seeded["user_id"]), + "memory_id": str(seeded["memory_id"]), + "embedding_config_id": str(first_config_id), + "vector": [0.1, 0.2, 0.3], + }, + ) + second_write_status, second_write_payload = invoke_request( + "POST", + "/v0/memory-embeddings", + payload={ + "user_id": str(seeded["user_id"]), + "memory_id": str(seeded["memory_id"]), + "embedding_config_id": str(second_config_id), + "vector": [0.4, 0.5, 0.6], + }, + ) + update_status, update_payload = invoke_request( + "POST", + "/v0/memory-embeddings", + payload={ + "user_id": str(seeded["user_id"]), + "memory_id": str(seeded["memory_id"]), + "embedding_config_id": str(first_config_id), + "vector": [0.9, 0.8, 0.7], + }, + ) + list_status, list_payload = invoke_request( + "GET", + f"/v0/memories/{seeded['memory_id']}/embeddings", + query_params={"user_id": str(seeded["user_id"])}, + ) + detail_status, detail_payload = invoke_request( + "GET", + f"/v0/memory-embeddings/{first_write_payload['embedding']['id']}", + query_params={"user_id": str(seeded["user_id"])}, + ) + + assert first_write_status == 201 + assert first_write_payload["write_mode"] == "created" + assert second_write_status == 201 + assert second_write_payload["write_mode"] == "created" + assert update_status == 201 + assert update_payload["write_mode"] == "updated" + assert update_payload["embedding"]["id"] == first_write_payload["embedding"]["id"] + assert update_payload["embedding"]["vector"] == [0.9, 0.8, 0.7] + assert list_status == 200 + assert list_payload["summary"] == { + "memory_id": str(seeded["memory_id"]), + "total_count": 2, + "order": ["created_at_asc", "id_asc"], + } + assert detail_status == 200 + assert detail_payload["embedding"]["id"] == first_write_payload["embedding"]["id"] + assert detail_payload["embedding"]["vector"] == [0.9, 0.8, 0.7] + + with user_connection(migrated_database_urls["app"], seeded["user_id"]) as conn: + stored = ContinuityStore(conn).list_memory_embeddings_for_memory(seeded["memory_id"]) + + assert [item["id"] for item in list_payload["items"]] == [ + str(embedding["id"]) for embedding in stored + ] + assert len(stored) == 2 + assert stored[0]["embedding_config_id"] == first_config_id + assert stored[0]["vector"] == [0.9, 0.8, 0.7] + assert stored[1]["embedding_config_id"] == second_config_id + + +def test_memory_embedding_writes_reject_invalid_references_dimension_mismatches_and_cross_user_refs( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_user_with_memory(migrated_database_urls["app"], email="owner@example.com") + intruder = seed_user_with_memory(migrated_database_urls["app"], email="intruder@example.com") + owner_config_id = seed_embedding_config( + migrated_database_urls["app"], + user_id=owner["user_id"], + provider="openai", + model="text-embedding-3-large", + version="2026-03-12", + dimensions=3, + ) + intruder_config_id = seed_embedding_config( + migrated_database_urls["app"], + user_id=intruder["user_id"], + provider="openai", + model="text-embedding-3-large", + version="2026-03-12", + dimensions=3, + ) + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + missing_config_status, missing_config_payload = invoke_request( + "POST", + "/v0/memory-embeddings", + payload={ + "user_id": str(owner["user_id"]), + "memory_id": str(owner["memory_id"]), + "embedding_config_id": str(uuid4()), + "vector": [0.1, 0.2, 0.3], + }, + ) + missing_memory_status, missing_memory_payload = invoke_request( + "POST", + "/v0/memory-embeddings", + payload={ + "user_id": str(owner["user_id"]), + "memory_id": str(uuid4()), + "embedding_config_id": str(owner_config_id), + "vector": [0.1, 0.2, 0.3], + }, + ) + mismatch_status, mismatch_payload = invoke_request( + "POST", + "/v0/memory-embeddings", + payload={ + "user_id": str(owner["user_id"]), + "memory_id": str(owner["memory_id"]), + "embedding_config_id": str(owner_config_id), + "vector": [0.1, 0.2], + }, + ) + cross_user_status, cross_user_payload = invoke_request( + "POST", + "/v0/memory-embeddings", + payload={ + "user_id": str(intruder["user_id"]), + "memory_id": str(owner["memory_id"]), + "embedding_config_id": str(intruder_config_id), + "vector": [0.1, 0.2, 0.3], + }, + ) + cross_user_config_status, cross_user_config_payload = invoke_request( + "POST", + "/v0/memory-embeddings", + payload={ + "user_id": str(intruder["user_id"]), + "memory_id": str(intruder["memory_id"]), + "embedding_config_id": str(owner_config_id), + "vector": [0.1, 0.2, 0.3], + }, + ) + + assert missing_config_status == 400 + assert missing_config_payload["detail"].startswith( + "embedding_config_id must reference an existing embedding config owned by the user" + ) + assert missing_memory_status == 400 + assert missing_memory_payload["detail"].startswith( + "memory_id must reference an existing memory owned by the user" + ) + assert mismatch_status == 400 + assert mismatch_payload["detail"] == "vector length must match embedding config dimensions (3): 2" + assert cross_user_status == 400 + assert cross_user_payload["detail"] == ( + f"memory_id must reference an existing memory owned by the user: {owner['memory_id']}" + ) + assert cross_user_config_status == 400 + assert cross_user_config_payload["detail"] == ( + "embedding_config_id must reference an existing embedding config owned by the user: " + f"{owner_config_id}" + ) + + +def test_embedding_reads_respect_per_user_isolation( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_user_with_memory(migrated_database_urls["app"], email="owner@example.com") + intruder = seed_user_with_memory(migrated_database_urls["app"], email="intruder@example.com") + owner_config_id = seed_embedding_config( + migrated_database_urls["app"], + user_id=owner["user_id"], + provider="openai", + model="text-embedding-3-large", + version="2026-03-12", + dimensions=3, + ) + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + write_status, write_payload = invoke_request( + "POST", + "/v0/memory-embeddings", + payload={ + "user_id": str(owner["user_id"]), + "memory_id": str(owner["memory_id"]), + "embedding_config_id": str(owner_config_id), + "vector": [0.1, 0.2, 0.3], + }, + ) + config_list_status, config_list_payload = invoke_request( + "GET", + "/v0/embedding-configs", + query_params={"user_id": str(intruder["user_id"])}, + ) + list_status, list_payload = invoke_request( + "GET", + f"/v0/memories/{owner['memory_id']}/embeddings", + query_params={"user_id": str(intruder["user_id"])}, + ) + detail_status, detail_payload = invoke_request( + "GET", + f"/v0/memory-embeddings/{write_payload['embedding']['id']}", + query_params={"user_id": str(intruder["user_id"])}, + ) + + assert write_status == 201 + assert config_list_status == 200 + assert config_list_payload == { + "items": [], + "summary": { + "total_count": 0, + "order": ["created_at_asc", "id_asc"], + }, + } + assert list_status == 404 + assert list_payload == {"detail": f"memory {owner['memory_id']} was not found"} + assert detail_status == 404 + assert detail_payload == { + "detail": f"memory embedding {write_payload['embedding']['id']} was not found" + } + + +def test_semantic_memory_retrieval_returns_deterministic_results_and_excludes_deleted_memories( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_user_with_memory(migrated_database_urls["app"], email="owner@example.com") + config_id = seed_embedding_config( + migrated_database_urls["app"], + user_id=seeded["user_id"], + provider="openai", + model="text-embedding-3-large", + version="2026-03-12", + dimensions=3, + ) + first_memory_id = seed_memory_with_embedding( + migrated_database_urls["app"], + user_id=seeded["user_id"], + memory_key="user.preference.breakfast", + value={"likes": "porridge"}, + embedding_config_id=config_id, + vector=[1.0, 0.0, 0.0], + ) + deleted_memory_id = seed_memory_with_embedding( + migrated_database_urls["app"], + user_id=seeded["user_id"], + memory_key="user.preference.deleted", + value={"likes": "hidden"}, + embedding_config_id=config_id, + vector=[1.0, 0.0, 0.0], + delete_requested=True, + ) + second_memory_id = seed_memory_with_embedding( + migrated_database_urls["app"], + user_id=seeded["user_id"], + memory_key="user.preference.lunch", + value={"likes": "ramen"}, + embedding_config_id=config_id, + vector=[1.0, 0.0, 0.0], + ) + third_memory_id = seed_memory_with_embedding( + migrated_database_urls["app"], + user_id=seeded["user_id"], + memory_key="user.preference.music", + value={"likes": "jazz"}, + embedding_config_id=config_id, + vector=[0.0, 1.0, 0.0], + ) + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + status, payload = invoke_request( + "POST", + "/v0/memories/semantic-retrieval", + payload={ + "user_id": str(seeded["user_id"]), + "embedding_config_id": str(config_id), + "query_vector": [1.0, 0.0, 0.0], + "limit": 10, + }, + ) + + assert status == 200 + assert payload["summary"] == { + "embedding_config_id": str(config_id), + "limit": 10, + "returned_count": 3, + "similarity_metric": "cosine_similarity", + "order": ["score_desc", "created_at_asc", "id_asc"], + } + assert [item["memory_id"] for item in payload["items"]] == [ + str(first_memory_id), + str(second_memory_id), + str(third_memory_id), + ] + assert str(deleted_memory_id) not in {item["memory_id"] for item in payload["items"]} + assert payload["items"][0]["score"] == payload["items"][1]["score"] + assert payload["items"][0]["score"] > payload["items"][2]["score"] + assert set(payload["items"][0]) == { + "memory_id", + "memory_key", + "value", + "source_event_ids", + "memory_type", + "confidence", + "salience", + "confirmation_status", + "trust_class", + "promotion_eligibility", + "evidence_count", + "independent_source_count", + "extracted_by_model", + "trust_reason", + "valid_from", + "valid_to", + "last_confirmed_at", + "created_at", + "updated_at", + "score", + } + + +def test_semantic_memory_retrieval_rejects_invalid_config_dimension_mismatch_and_cross_user_access( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_user_with_memory(migrated_database_urls["app"], email="owner@example.com") + intruder = seed_user_with_memory(migrated_database_urls["app"], email="intruder@example.com") + owner_config_id = seed_embedding_config( + migrated_database_urls["app"], + user_id=owner["user_id"], + provider="openai", + model="text-embedding-3-large", + version="2026-03-12", + dimensions=3, + ) + intruder_config_id = seed_embedding_config( + migrated_database_urls["app"], + user_id=intruder["user_id"], + provider="openai", + model="text-embedding-3-large", + version="2026-03-12", + dimensions=3, + ) + seed_memory_with_embedding( + migrated_database_urls["app"], + user_id=owner["user_id"], + memory_key="user.preference.owner", + value={"likes": "oat milk"}, + embedding_config_id=owner_config_id, + vector=[1.0, 0.0, 0.0], + ) + seed_memory_with_embedding( + migrated_database_urls["app"], + user_id=intruder["user_id"], + memory_key="user.preference.intruder", + value={"likes": "almond milk"}, + embedding_config_id=intruder_config_id, + vector=[1.0, 0.0, 0.0], + ) + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + missing_status, missing_payload = invoke_request( + "POST", + "/v0/memories/semantic-retrieval", + payload={ + "user_id": str(owner["user_id"]), + "embedding_config_id": str(uuid4()), + "query_vector": [1.0, 0.0, 0.0], + "limit": 5, + }, + ) + mismatch_status, mismatch_payload = invoke_request( + "POST", + "/v0/memories/semantic-retrieval", + payload={ + "user_id": str(owner["user_id"]), + "embedding_config_id": str(owner_config_id), + "query_vector": [1.0, 0.0], + "limit": 5, + }, + ) + cross_user_status, cross_user_payload = invoke_request( + "POST", + "/v0/memories/semantic-retrieval", + payload={ + "user_id": str(intruder["user_id"]), + "embedding_config_id": str(owner_config_id), + "query_vector": [1.0, 0.0, 0.0], + "limit": 5, + }, + ) + + assert missing_status == 400 + assert missing_payload["detail"].startswith( + "embedding_config_id must reference an existing embedding config owned by the user" + ) + assert mismatch_status == 400 + assert mismatch_payload["detail"] == "query_vector length must match embedding config dimensions (3): 2" + assert cross_user_status == 400 + assert cross_user_payload["detail"] == ( + "embedding_config_id must reference an existing embedding config owned by the user: " + f"{owner_config_id}" + ) + + +def test_semantic_memory_retrieval_scopes_results_per_user( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_user_with_memory(migrated_database_urls["app"], email="owner@example.com") + intruder = seed_user_with_memory(migrated_database_urls["app"], email="intruder@example.com") + owner_config_id = seed_embedding_config( + migrated_database_urls["app"], + user_id=owner["user_id"], + provider="openai", + model="text-embedding-3-large", + version="2026-03-12", + dimensions=3, + ) + intruder_config_id = seed_embedding_config( + migrated_database_urls["app"], + user_id=intruder["user_id"], + provider="openai", + model="text-embedding-3-large", + version="2026-03-12", + dimensions=3, + ) + owner_memory_id = seed_memory_with_embedding( + migrated_database_urls["app"], + user_id=owner["user_id"], + memory_key="user.preference.owner.semantic", + value={"likes": "espresso"}, + embedding_config_id=owner_config_id, + vector=[1.0, 0.0, 0.0], + ) + intruder_memory_id = seed_memory_with_embedding( + migrated_database_urls["app"], + user_id=intruder["user_id"], + memory_key="user.preference.intruder.semantic", + value={"likes": "matcha"}, + embedding_config_id=intruder_config_id, + vector=[1.0, 0.0, 0.0], + ) + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + owner_status, owner_payload = invoke_request( + "POST", + "/v0/memories/semantic-retrieval", + payload={ + "user_id": str(owner["user_id"]), + "embedding_config_id": str(owner_config_id), + "query_vector": [1.0, 0.0, 0.0], + "limit": 5, + }, + ) + intruder_status, intruder_payload = invoke_request( + "POST", + "/v0/memories/semantic-retrieval", + payload={ + "user_id": str(intruder["user_id"]), + "embedding_config_id": str(intruder_config_id), + "query_vector": [1.0, 0.0, 0.0], + "limit": 5, + }, + ) + + assert owner_status == 200 + assert [item["memory_id"] for item in owner_payload["items"]] == [str(owner_memory_id)] + assert intruder_status == 200 + assert [item["memory_id"] for item in intruder_payload["items"]] == [str(intruder_memory_id)] diff --git a/tests/integration/test_entities_api.py b/tests/integration/test_entities_api.py new file mode 100644 index 0000000..a6fbe53 --- /dev/null +++ b/tests/integration/test_entities_api.py @@ -0,0 +1,309 @@ +from __future__ import annotations + +import json +from typing import Any +from urllib.parse import urlencode +from uuid import UUID, uuid4 + +import anyio + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.contracts import MemoryCandidateInput +from alicebot_api.db import user_connection +from alicebot_api.memory import admit_memory_candidate +from alicebot_api.store import ContinuityStore + + +def invoke_request( + method: str, + path: str, + *, + query_params: dict[str, str] | None = None, + payload: dict[str, Any] | None = None, +) -> tuple[int, dict[str, Any]]: + messages: list[dict[str, object]] = [] + encoded_body = b"" if payload is None else json.dumps(payload).encode() + request_received = False + + async def receive() -> dict[str, object]: + nonlocal request_received + if request_received: + return {"type": "http.disconnect"} + + request_received = True + return {"type": "http.request", "body": encoded_body, "more_body": False} + + async def send(message: dict[str, object]) -> None: + messages.append(message) + + query_string = urlencode(query_params or {}).encode() + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": query_string, + "headers": [(b"content-type", b"application/json")], + "client": ("testclient", 50000), + "server": ("testserver", 80), + "root_path": "", + } + + anyio.run(main_module.app, scope, receive, send) + + start_message = next(message for message in messages if message["type"] == "http.response.start") + body = b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + return start_message["status"], json.loads(body) + + +def seed_user_with_source_memories(database_url: str, *, email: str) -> dict[str, object]: + user_id = uuid4() + + with user_connection(database_url, user_id) as conn: + store = ContinuityStore(conn) + store.create_user(user_id, email, email.split("@", 1)[0].title()) + thread = store.create_thread("Entity source thread") + session = store.create_session(thread["id"], status="active") + event_ids = [ + store.append_event(thread["id"], session["id"], "message.user", {"text": "works on AliceBot"})["id"], + store.append_event(thread["id"], session["id"], "message.user", {"text": "drinks oat milk"})["id"], + store.append_event(thread["id"], session["id"], "message.user", {"text": "shops at cafe"})["id"], + ] + + first_memory = admit_memory_candidate( + store, + user_id=user_id, + candidate=MemoryCandidateInput( + memory_key="user.project.current", + value={"name": "AliceBot"}, + source_event_ids=(event_ids[0],), + ), + ) + second_memory = admit_memory_candidate( + store, + user_id=user_id, + candidate=MemoryCandidateInput( + memory_key="user.preference.coffee", + value={"likes": "oat milk"}, + source_event_ids=(event_ids[1],), + ), + ) + third_memory = admit_memory_candidate( + store, + user_id=user_id, + candidate=MemoryCandidateInput( + memory_key="user.preference.merchant", + value={"name": "Neighborhood Cafe"}, + source_event_ids=(event_ids[2],), + ), + ) + + return { + "user_id": user_id, + "memory_ids": [ + UUID(first_memory.memory["id"]), + UUID(second_memory.memory["id"]), + UUID(third_memory.memory["id"]), + ], + } + + +def test_create_entity_endpoint_persists_entity_backed_by_user_owned_source_memories( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_user_with_source_memories(migrated_database_urls["app"], email="owner@example.com") + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + status_code, payload = invoke_request( + "POST", + "/v0/entities", + payload={ + "user_id": str(seeded["user_id"]), + "entity_type": "project", + "name": "AliceBot", + "source_memory_ids": [str(seeded["memory_ids"][0]), str(seeded["memory_ids"][1])], + }, + ) + + assert status_code == 201 + assert payload["entity"]["entity_type"] == "project" + assert payload["entity"]["name"] == "AliceBot" + assert payload["entity"]["source_memory_ids"] == [ + str(seeded["memory_ids"][0]), + str(seeded["memory_ids"][1]), + ] + + with user_connection(migrated_database_urls["app"], seeded["user_id"]) as conn: + stored_entities = ContinuityStore(conn).list_entities() + + assert len(stored_entities) == 1 + assert stored_entities[0]["id"] == UUID(payload["entity"]["id"]) + assert stored_entities[0]["entity_type"] == "project" + assert stored_entities[0]["name"] == "AliceBot" + assert stored_entities[0]["source_memory_ids"] == [ + str(seeded["memory_ids"][0]), + str(seeded["memory_ids"][1]), + ] + + +def test_entity_endpoints_list_and_get_entities_in_deterministic_user_scoped_order( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_user_with_source_memories(migrated_database_urls["app"], email="owner@example.com") + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + with user_connection(migrated_database_urls["app"], seeded["user_id"]) as conn: + store = ContinuityStore(conn) + created_entities = [ + store.create_entity( + entity_type="person", + name="Alex", + source_memory_ids=[str(seeded["memory_ids"][0])], + ), + store.create_entity( + entity_type="merchant", + name="Neighborhood Cafe", + source_memory_ids=[str(seeded["memory_ids"][2])], + ), + store.create_entity( + entity_type="project", + name="AliceBot", + source_memory_ids=[str(seeded["memory_ids"][0]), str(seeded["memory_ids"][1])], + ), + ] + + list_status, list_payload = invoke_request( + "GET", + "/v0/entities", + query_params={"user_id": str(seeded["user_id"])}, + ) + + expected_entities = sorted(created_entities, key=lambda entity: (entity["created_at"], entity["id"])) + + assert list_status == 200 + assert [item["id"] for item in list_payload["items"]] == [str(entity["id"]) for entity in expected_entities] + assert list_payload["summary"] == { + "total_count": 3, + "order": ["created_at_asc", "id_asc"], + } + + target_entity = expected_entities[1] + detail_status, detail_payload = invoke_request( + "GET", + f"/v0/entities/{target_entity['id']}", + query_params={"user_id": str(seeded["user_id"])}, + ) + + assert detail_status == 200 + assert detail_payload == { + "entity": { + "id": str(target_entity["id"]), + "entity_type": target_entity["entity_type"], + "name": target_entity["name"], + "source_memory_ids": target_entity["source_memory_ids"], + "created_at": target_entity["created_at"].isoformat(), + } + } + + +def test_entity_endpoints_enforce_per_user_isolation_and_not_found_behavior( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_user_with_source_memories(migrated_database_urls["app"], email="owner@example.com") + intruder = seed_user_with_source_memories(migrated_database_urls["app"], email="intruder@example.com") + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + entity = ContinuityStore(conn).create_entity( + entity_type="project", + name="AliceBot", + source_memory_ids=[str(owner["memory_ids"][0])], + ) + + list_status, list_payload = invoke_request( + "GET", + "/v0/entities", + query_params={"user_id": str(intruder["user_id"])}, + ) + detail_status, detail_payload = invoke_request( + "GET", + f"/v0/entities/{entity['id']}", + query_params={"user_id": str(intruder["user_id"])}, + ) + create_status, create_payload = invoke_request( + "POST", + "/v0/entities", + payload={ + "user_id": str(intruder["user_id"]), + "entity_type": "project", + "name": "Hidden Project", + "source_memory_ids": [str(owner["memory_ids"][0])], + }, + ) + + assert list_status == 200 + assert list_payload == { + "items": [], + "summary": { + "total_count": 0, + "order": ["created_at_asc", "id_asc"], + }, + } + assert detail_status == 404 + assert detail_payload == { + "detail": f"entity {entity['id']} was not found", + } + assert create_status == 400 + assert create_payload["detail"].startswith( + "source_memory_ids must all reference existing memories owned by the user" + ) + + +def test_create_entity_endpoint_rejects_missing_source_memory_ids(migrated_database_urls, monkeypatch) -> None: + seeded = seed_user_with_source_memories(migrated_database_urls["app"], email="owner@example.com") + missing_memory_id = uuid4() + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + status_code, payload = invoke_request( + "POST", + "/v0/entities", + payload={ + "user_id": str(seeded["user_id"]), + "entity_type": "routine", + "name": "Morning Coffee", + "source_memory_ids": [str(missing_memory_id)], + }, + ) + + assert status_code == 400 + assert payload == { + "detail": "source_memory_ids must all reference existing memories owned by the user: " + f"{missing_memory_id}" + } diff --git a/tests/integration/test_entity_edges_api.py b/tests/integration/test_entity_edges_api.py new file mode 100644 index 0000000..0f8c60c --- /dev/null +++ b/tests/integration/test_entity_edges_api.py @@ -0,0 +1,376 @@ +from __future__ import annotations + +import json +from typing import Any +from urllib.parse import urlencode +from uuid import UUID, uuid4 + +import anyio + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.contracts import MemoryCandidateInput +from alicebot_api.db import user_connection +from alicebot_api.memory import admit_memory_candidate +from alicebot_api.store import ContinuityStore + + +def invoke_request( + method: str, + path: str, + *, + query_params: dict[str, str] | None = None, + payload: dict[str, Any] | None = None, +) -> tuple[int, dict[str, Any]]: + messages: list[dict[str, object]] = [] + encoded_body = b"" if payload is None else json.dumps(payload).encode() + request_received = False + + async def receive() -> dict[str, object]: + nonlocal request_received + if request_received: + return {"type": "http.disconnect"} + + request_received = True + return {"type": "http.request", "body": encoded_body, "more_body": False} + + async def send(message: dict[str, object]) -> None: + messages.append(message) + + query_string = urlencode(query_params or {}).encode() + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": query_string, + "headers": [(b"content-type", b"application/json")], + "client": ("testclient", 50000), + "server": ("testserver", 80), + "root_path": "", + } + + anyio.run(main_module.app, scope, receive, send) + + start_message = next(message for message in messages if message["type"] == "http.response.start") + body = b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + return start_message["status"], json.loads(body) + + +def seed_user_with_source_memories(database_url: str, *, email: str) -> dict[str, object]: + user_id = uuid4() + + with user_connection(database_url, user_id) as conn: + store = ContinuityStore(conn) + store.create_user(user_id, email, email.split("@", 1)[0].title()) + thread = store.create_thread("Entity edge source thread") + session = store.create_session(thread["id"], status="active") + event_ids = [ + store.append_event(thread["id"], session["id"], "message.user", {"text": "works on AliceBot"})["id"], + store.append_event( + thread["id"], session["id"], "message.user", {"text": "works with Neighborhood Cafe"} + )["id"], + store.append_event(thread["id"], session["id"], "message.user", {"text": "coffee preference"})["id"], + ] + + first_memory = admit_memory_candidate( + store, + user_id=user_id, + candidate=MemoryCandidateInput( + memory_key="user.project.current", + value={"name": "AliceBot"}, + source_event_ids=(event_ids[0],), + ), + ) + second_memory = admit_memory_candidate( + store, + user_id=user_id, + candidate=MemoryCandidateInput( + memory_key="user.preference.merchant", + value={"name": "Neighborhood Cafe"}, + source_event_ids=(event_ids[1],), + ), + ) + third_memory = admit_memory_candidate( + store, + user_id=user_id, + candidate=MemoryCandidateInput( + memory_key="user.preference.coffee", + value={"likes": "oat milk"}, + source_event_ids=(event_ids[2],), + ), + ) + + return { + "user_id": user_id, + "memory_ids": [ + UUID(first_memory.memory["id"]), + UUID(second_memory.memory["id"]), + UUID(third_memory.memory["id"]), + ], + } + + +def seed_entities( + database_url: str, + *, + user_id: UUID, + memory_ids: list[UUID], +) -> dict[str, UUID]: + with user_connection(database_url, user_id) as conn: + store = ContinuityStore(conn) + person = store.create_entity( + entity_type="person", + name="Alex", + source_memory_ids=[str(memory_ids[2])], + ) + merchant = store.create_entity( + entity_type="merchant", + name="Neighborhood Cafe", + source_memory_ids=[str(memory_ids[1])], + ) + project = store.create_entity( + entity_type="project", + name="AliceBot", + source_memory_ids=[str(memory_ids[0])], + ) + + return { + "person": person["id"], + "merchant": merchant["id"], + "project": project["id"], + } + + +def test_create_entity_edge_endpoint_persists_user_scoped_edge_with_temporal_metadata( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_user_with_source_memories(migrated_database_urls["app"], email="owner@example.com") + entities = seed_entities( + migrated_database_urls["app"], + user_id=seeded["user_id"], + memory_ids=seeded["memory_ids"], + ) + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + status_code, payload = invoke_request( + "POST", + "/v0/entity-edges", + payload={ + "user_id": str(seeded["user_id"]), + "from_entity_id": str(entities["person"]), + "to_entity_id": str(entities["project"]), + "relationship_type": "works_on", + "valid_from": "2026-03-12T10:00:00+00:00", + "valid_to": "2026-03-12T12:00:00+00:00", + "source_memory_ids": [str(seeded["memory_ids"][0]), str(seeded["memory_ids"][2])], + }, + ) + + assert status_code == 201 + assert payload["edge"]["from_entity_id"] == str(entities["person"]) + assert payload["edge"]["to_entity_id"] == str(entities["project"]) + assert payload["edge"]["relationship_type"] == "works_on" + assert payload["edge"]["valid_from"] == "2026-03-12T10:00:00+00:00" + assert payload["edge"]["valid_to"] == "2026-03-12T12:00:00+00:00" + assert payload["edge"]["source_memory_ids"] == [ + str(seeded["memory_ids"][0]), + str(seeded["memory_ids"][2]), + ] + + with user_connection(migrated_database_urls["app"], seeded["user_id"]) as conn: + stored_edges = ContinuityStore(conn).list_entity_edges_for_entity(entities["person"]) + + assert len(stored_edges) == 1 + assert stored_edges[0]["id"] == UUID(payload["edge"]["id"]) + assert stored_edges[0]["relationship_type"] == "works_on" + assert stored_edges[0]["source_memory_ids"] == [ + str(seeded["memory_ids"][0]), + str(seeded["memory_ids"][2]), + ] + + +def test_entity_edge_list_endpoint_returns_incident_edges_in_deterministic_order( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_user_with_source_memories(migrated_database_urls["app"], email="owner@example.com") + entities = seed_entities( + migrated_database_urls["app"], + user_id=seeded["user_id"], + memory_ids=seeded["memory_ids"], + ) + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + with user_connection(migrated_database_urls["app"], seeded["user_id"]) as conn: + store = ContinuityStore(conn) + created_edges = [ + store.create_entity_edge( + from_entity_id=entities["person"], + to_entity_id=entities["project"], + relationship_type="works_on", + valid_from=None, + valid_to=None, + source_memory_ids=[str(seeded["memory_ids"][0])], + ), + store.create_entity_edge( + from_entity_id=entities["merchant"], + to_entity_id=entities["project"], + relationship_type="supplies", + valid_from=None, + valid_to=None, + source_memory_ids=[str(seeded["memory_ids"][1])], + ), + store.create_entity_edge( + from_entity_id=entities["project"], + to_entity_id=entities["merchant"], + relationship_type="references", + valid_from=None, + valid_to=None, + source_memory_ids=[str(seeded["memory_ids"][2])], + ), + ] + + status_code, payload = invoke_request( + "GET", + f"/v0/entities/{entities['project']}/edges", + query_params={"user_id": str(seeded["user_id"])}, + ) + + expected_edges = sorted(created_edges, key=lambda edge: (edge["created_at"], edge["id"])) + + assert status_code == 200 + assert [item["id"] for item in payload["items"]] == [str(edge["id"]) for edge in expected_edges] + assert payload["summary"] == { + "entity_id": str(entities["project"]), + "total_count": 3, + "order": ["created_at_asc", "id_asc"], + } + + +def test_entity_edge_endpoints_enforce_per_user_isolation_and_reference_validation( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_user_with_source_memories(migrated_database_urls["app"], email="owner@example.com") + owner_entities = seed_entities( + migrated_database_urls["app"], + user_id=owner["user_id"], + memory_ids=owner["memory_ids"], + ) + intruder = seed_user_with_source_memories(migrated_database_urls["app"], email="intruder@example.com") + intruder_entities = seed_entities( + migrated_database_urls["app"], + user_id=intruder["user_id"], + memory_ids=intruder["memory_ids"], + ) + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + ContinuityStore(conn).create_entity_edge( + from_entity_id=owner_entities["person"], + to_entity_id=owner_entities["project"], + relationship_type="works_on", + valid_from=None, + valid_to=None, + source_memory_ids=[str(owner["memory_ids"][0])], + ) + + list_status, list_payload = invoke_request( + "GET", + f"/v0/entities/{owner_entities['project']}/edges", + query_params={"user_id": str(intruder['user_id'])}, + ) + entity_status, entity_payload = invoke_request( + "POST", + "/v0/entity-edges", + payload={ + "user_id": str(intruder["user_id"]), + "from_entity_id": str(owner_entities["person"]), + "to_entity_id": str(intruder_entities["project"]), + "relationship_type": "works_on", + "source_memory_ids": [str(intruder["memory_ids"][0])], + }, + ) + memory_status, memory_payload = invoke_request( + "POST", + "/v0/entity-edges", + payload={ + "user_id": str(intruder["user_id"]), + "from_entity_id": str(intruder_entities["person"]), + "to_entity_id": str(intruder_entities["project"]), + "relationship_type": "works_on", + "source_memory_ids": [str(owner["memory_ids"][0])], + }, + ) + + assert list_status == 404 + assert list_payload == { + "detail": f"entity {owner_entities['project']} was not found", + } + assert entity_status == 400 + assert entity_payload == { + "detail": "from_entity_id must reference an existing entity owned by the user: " + f"{owner_entities['person']}" + } + assert memory_status == 400 + assert memory_payload == { + "detail": "source_memory_ids must all reference existing memories owned by the user: " + f"{owner['memory_ids'][0]}" + } + + +def test_create_entity_edge_endpoint_rejects_invalid_temporal_range( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_user_with_source_memories(migrated_database_urls["app"], email="owner@example.com") + entities = seed_entities( + migrated_database_urls["app"], + user_id=seeded["user_id"], + memory_ids=seeded["memory_ids"], + ) + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + status_code, payload = invoke_request( + "POST", + "/v0/entity-edges", + payload={ + "user_id": str(seeded["user_id"]), + "from_entity_id": str(entities["person"]), + "to_entity_id": str(entities["project"]), + "relationship_type": "works_on", + "valid_from": "2026-03-12T12:00:00+00:00", + "valid_to": "2026-03-12T10:00:00+00:00", + "source_memory_ids": [str(seeded["memory_ids"][0])], + }, + ) + + assert status_code == 400 + assert payload == { + "detail": "valid_to must be greater than or equal to valid_from", + } diff --git a/tests/integration/test_execution_budgets_api.py b/tests/integration/test_execution_budgets_api.py new file mode 100644 index 0000000..da5eb0f --- /dev/null +++ b/tests/integration/test_execution_budgets_api.py @@ -0,0 +1,498 @@ +from __future__ import annotations + +import json +from typing import Any +from urllib.parse import urlencode +from uuid import UUID, uuid4 + +import anyio +import psycopg +import pytest + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.db import user_connection +from alicebot_api.store import ContinuityStore + + +def invoke_request( + method: str, + path: str, + *, + query_params: dict[str, str] | None = None, + payload: dict[str, Any] | None = None, +) -> tuple[int, dict[str, Any]]: + messages: list[dict[str, object]] = [] + encoded_body = b"" if payload is None else json.dumps(payload).encode() + request_received = False + + async def receive() -> dict[str, object]: + nonlocal request_received + if request_received: + return {"type": "http.disconnect"} + + request_received = True + return {"type": "http.request", "body": encoded_body, "more_body": False} + + async def send(message: dict[str, object]) -> None: + messages.append(message) + + query_string = urlencode(query_params or {}).encode() + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": query_string, + "headers": [(b"content-type", b"application/json")], + "client": ("testclient", 50000), + "server": ("testserver", 80), + "root_path": "", + } + + anyio.run(main_module.app, scope, receive, send) + + start_message = next(message for message in messages if message["type"] == "http.response.start") + body = b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + return start_message["status"], json.loads(body) + + +def seed_user(database_url: str, *, email: str) -> dict[str, UUID]: + user_id = uuid4() + + with user_connection(database_url, user_id) as conn: + store = ContinuityStore(conn) + store.create_user(user_id, email, email.split("@", 1)[0].title()) + thread = store.create_thread("Budget lifecycle thread") + + return { + "user_id": user_id, + "thread_id": thread["id"], + } + + +def create_budget( + *, + user_id: UUID, + agent_profile_id: str | None = None, + tool_key: str | None, + domain_hint: str | None, + max_completed_executions: int, + rolling_window_seconds: int | None = None, +) -> tuple[int, dict[str, Any]]: + payload: dict[str, Any] = { + "user_id": str(user_id), + "max_completed_executions": max_completed_executions, + } + if agent_profile_id is not None: + payload["agent_profile_id"] = agent_profile_id + if tool_key is not None: + payload["tool_key"] = tool_key + if domain_hint is not None: + payload["domain_hint"] = domain_hint + if rolling_window_seconds is not None: + payload["rolling_window_seconds"] = rolling_window_seconds + return invoke_request("POST", "/v0/execution-budgets", payload=payload) + + +def test_execution_budget_endpoints_create_list_and_get_in_deterministic_order( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_user(migrated_database_urls["app"], email="owner@example.com") + intruder = seed_user(migrated_database_urls["app"], email="intruder@example.com") + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + + second_status, second_payload = create_budget( + user_id=owner["user_id"], + tool_key="proxy.echo", + domain_hint=None, + max_completed_executions=2, + rolling_window_seconds=3600, + ) + first_status, first_payload = create_budget( + user_id=owner["user_id"], + tool_key=None, + domain_hint="docs", + max_completed_executions=1, + ) + + list_status, list_payload = invoke_request( + "GET", + "/v0/execution-budgets", + query_params={"user_id": str(owner["user_id"])}, + ) + detail_status, detail_payload = invoke_request( + "GET", + f"/v0/execution-budgets/{second_payload['execution_budget']['id']}", + query_params={"user_id": str(owner['user_id'])}, + ) + isolated_list_status, isolated_list_payload = invoke_request( + "GET", + "/v0/execution-budgets", + query_params={"user_id": str(intruder["user_id"])}, + ) + + assert first_status == 201 + assert second_status == 201 + assert second_payload["execution_budget"]["agent_profile_id"] is None + assert second_payload["execution_budget"]["status"] == "active" + assert second_payload["execution_budget"]["deactivated_at"] is None + assert second_payload["execution_budget"]["rolling_window_seconds"] == 3600 + assert list_status == 200 + assert [item["id"] for item in list_payload["items"]] == [ + second_payload["execution_budget"]["id"], + first_payload["execution_budget"]["id"], + ] + assert list_payload["summary"] == { + "total_count": 2, + "order": ["created_at_asc", "id_asc"], + } + assert detail_status == 200 + assert detail_payload == {"execution_budget": second_payload["execution_budget"]} + assert isolated_list_status == 200 + assert isolated_list_payload == { + "items": [], + "summary": {"total_count": 0, "order": ["created_at_asc", "id_asc"]}, + } + + isolated_detail_status, isolated_detail_payload = invoke_request( + "GET", + f"/v0/execution-budgets/{first_payload['execution_budget']['id']}", + query_params={"user_id": str(intruder['user_id'])}, + ) + + assert isolated_detail_status == 404 + assert isolated_detail_payload == { + "detail": f"execution budget {first_payload['execution_budget']['id']} was not found" + } + + +def test_create_execution_budget_endpoint_requires_at_least_one_selector( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_user(migrated_database_urls["app"], email="owner@example.com") + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + + status_code, payload = invoke_request( + "POST", + "/v0/execution-budgets", + payload={ + "user_id": str(owner["user_id"]), + "max_completed_executions": 1, + }, + ) + + assert status_code == 400 + assert payload == { + "detail": "execution budget requires at least one selector: tool_key or domain_hint" + } + + +def test_create_execution_budget_endpoint_rejects_duplicate_active_scope( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_user(migrated_database_urls["app"], email="owner@example.com") + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + + first_status, _ = create_budget( + user_id=owner["user_id"], + tool_key="proxy.echo", + domain_hint="docs", + max_completed_executions=1, + ) + second_status, second_payload = create_budget( + user_id=owner["user_id"], + tool_key="proxy.echo", + domain_hint="docs", + max_completed_executions=2, + ) + + assert first_status == 201 + assert second_status == 400 + assert second_payload == { + "detail": ( + "active execution budget already exists for selector scope " + "agent_profile_id=None, tool_key='proxy.echo', domain_hint='docs'" + ) + } + + +def test_create_execution_budget_endpoint_rejects_unknown_agent_profile_id( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_user(migrated_database_urls["app"], email="owner@example.com") + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + + status_code, payload = create_budget( + user_id=owner["user_id"], + agent_profile_id="profile_missing", + tool_key="proxy.echo", + domain_hint=None, + max_completed_executions=1, + ) + + assert status_code == 400 + assert payload == { + "detail": "agent_profile_id must reference an existing profile in the registry" + } + + +def test_create_execution_budget_endpoint_allows_same_selector_across_profile_scopes( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_user(migrated_database_urls["app"], email="owner@example.com") + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + + scoped_status, scoped_payload = create_budget( + user_id=owner["user_id"], + agent_profile_id="assistant_default", + tool_key="proxy.echo", + domain_hint="docs", + max_completed_executions=1, + ) + global_status, global_payload = create_budget( + user_id=owner["user_id"], + agent_profile_id=None, + tool_key="proxy.echo", + domain_hint="docs", + max_completed_executions=2, + ) + list_status, list_payload = invoke_request( + "GET", + "/v0/execution-budgets", + query_params={"user_id": str(owner["user_id"])}, + ) + + assert scoped_status == 201 + assert global_status == 201 + assert scoped_payload["execution_budget"]["agent_profile_id"] == "assistant_default" + assert global_payload["execution_budget"]["agent_profile_id"] is None + assert list_status == 200 + assert [item["id"] for item in list_payload["items"]] == [ + scoped_payload["execution_budget"]["id"], + global_payload["execution_budget"]["id"], + ] + + +def test_deactivate_execution_budget_endpoint_updates_reads_and_emits_trace( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_user(migrated_database_urls["app"], email="owner@example.com") + intruder = seed_user(migrated_database_urls["app"], email="intruder@example.com") + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + + create_status, create_payload = create_budget( + user_id=owner["user_id"], + tool_key="proxy.echo", + domain_hint=None, + max_completed_executions=1, + ) + assert create_status == 201 + + deactivate_status, deactivate_payload = invoke_request( + "POST", + f"/v0/execution-budgets/{create_payload['execution_budget']['id']}/deactivate", + payload={ + "user_id": str(owner["user_id"]), + "thread_id": str(owner["thread_id"]), + }, + ) + + list_status, list_payload = invoke_request( + "GET", + "/v0/execution-budgets", + query_params={"user_id": str(owner["user_id"])}, + ) + detail_status, detail_payload = invoke_request( + "GET", + f"/v0/execution-budgets/{create_payload['execution_budget']['id']}", + query_params={"user_id": str(owner["user_id"])}, + ) + isolated_status, isolated_payload = invoke_request( + "POST", + f"/v0/execution-budgets/{create_payload['execution_budget']['id']}/deactivate", + payload={ + "user_id": str(intruder["user_id"]), + "thread_id": str(intruder["thread_id"]), + }, + ) + + assert deactivate_status == 200 + assert deactivate_payload["execution_budget"]["status"] == "inactive" + assert deactivate_payload["execution_budget"]["deactivated_at"] is not None + assert deactivate_payload["trace"]["trace_event_count"] == 3 + assert list_status == 200 + assert list_payload["items"][0] == deactivate_payload["execution_budget"] + assert detail_status == 200 + assert detail_payload == {"execution_budget": deactivate_payload["execution_budget"]} + assert isolated_status == 404 + assert isolated_payload == { + "detail": f"execution budget {create_payload['execution_budget']['id']} was not found" + } + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + store = ContinuityStore(conn) + trace = store.get_trace(UUID(deactivate_payload["trace"]["trace_id"])) + trace_events = store.list_trace_events(UUID(deactivate_payload["trace"]["trace_id"])) + + assert trace["kind"] == "execution_budget.lifecycle" + assert trace["compiler_version"] == "execution_budget_lifecycle_v0" + assert trace["limits"]["requested_action"] == "deactivate" + assert [event["kind"] for event in trace_events] == [ + "execution_budget.lifecycle.request", + "execution_budget.lifecycle.state", + "execution_budget.lifecycle.summary", + ] + assert trace_events[1]["payload"]["current_status"] == "inactive" + assert trace_events[2]["payload"]["outcome"] == "deactivated" + + +def test_supersede_execution_budget_endpoint_replaces_active_budget_and_emits_trace( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_user(migrated_database_urls["app"], email="owner@example.com") + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + + create_status, create_payload = create_budget( + user_id=owner["user_id"], + tool_key="proxy.echo", + domain_hint="docs", + max_completed_executions=1, + rolling_window_seconds=1800, + ) + assert create_status == 201 + + supersede_status, supersede_payload = invoke_request( + "POST", + f"/v0/execution-budgets/{create_payload['execution_budget']['id']}/supersede", + payload={ + "user_id": str(owner["user_id"]), + "thread_id": str(owner["thread_id"]), + "max_completed_executions": 3, + }, + ) + + list_status, list_payload = invoke_request( + "GET", + "/v0/execution-budgets", + query_params={"user_id": str(owner["user_id"])}, + ) + original_detail_status, original_detail_payload = invoke_request( + "GET", + f"/v0/execution-budgets/{create_payload['execution_budget']['id']}", + query_params={"user_id": str(owner["user_id"])}, + ) + replacement_detail_status, replacement_detail_payload = invoke_request( + "GET", + f"/v0/execution-budgets/{supersede_payload['replacement_budget']['id']}", + query_params={"user_id": str(owner['user_id'])}, + ) + + assert supersede_status == 200 + assert supersede_payload["superseded_budget"]["status"] == "superseded" + assert supersede_payload["replacement_budget"]["status"] == "active" + assert supersede_payload["replacement_budget"]["rolling_window_seconds"] == 1800 + assert supersede_payload["replacement_budget"]["supersedes_budget_id"] == create_payload["execution_budget"]["id"] + assert supersede_payload["superseded_budget"]["superseded_by_budget_id"] == supersede_payload["replacement_budget"]["id"] + assert list_status == 200 + assert [item["status"] for item in list_payload["items"]] == ["superseded", "active"] + assert original_detail_status == 200 + assert original_detail_payload == {"execution_budget": supersede_payload["superseded_budget"]} + assert replacement_detail_status == 200 + assert replacement_detail_payload == {"execution_budget": supersede_payload["replacement_budget"]} + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + store = ContinuityStore(conn) + trace = store.get_trace(UUID(supersede_payload["trace"]["trace_id"])) + trace_events = store.list_trace_events(UUID(supersede_payload["trace"]["trace_id"])) + + assert trace["limits"]["requested_action"] == "supersede" + assert trace["limits"]["outcome"] == "superseded" + assert trace_events[1]["payload"]["replacement_budget_id"] == supersede_payload["replacement_budget"]["id"] + assert trace_events[2]["payload"]["active_budget_id"] == supersede_payload["replacement_budget"]["id"] + + +def test_execution_budget_lifecycle_rejects_invalid_transition_deterministically( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_user(migrated_database_urls["app"], email="owner@example.com") + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + + create_status, create_payload = create_budget( + user_id=owner["user_id"], + tool_key="proxy.echo", + domain_hint=None, + max_completed_executions=1, + ) + assert create_status == 201 + + first_status, _ = invoke_request( + "POST", + f"/v0/execution-budgets/{create_payload['execution_budget']['id']}/deactivate", + payload={ + "user_id": str(owner["user_id"]), + "thread_id": str(owner["thread_id"]), + }, + ) + second_status, second_payload = invoke_request( + "POST", + f"/v0/execution-budgets/{create_payload['execution_budget']['id']}/deactivate", + payload={ + "user_id": str(owner["user_id"]), + "thread_id": str(owner["thread_id"]), + }, + ) + + assert first_status == 200 + assert second_status == 409 + assert second_payload == { + "detail": f"execution budget {create_payload['execution_budget']['id']} is inactive and cannot be deactivated" + } + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + store = ContinuityStore(conn) + trace_rows = store.conn.execute( + "SELECT id FROM traces WHERE kind = %s ORDER BY created_at ASC, id ASC", + ("execution_budget.lifecycle",), + ).fetchall() + rejected_trace_events = store.list_trace_events(trace_rows[-1]["id"]) + + assert rejected_trace_events[1]["payload"]["rejection_reason"] == second_payload["detail"] + assert rejected_trace_events[2]["payload"]["outcome"] == "rejected" + + +def test_execution_budget_active_scope_uniqueness_is_enforced_in_database( + migrated_database_urls, +) -> None: + owner = seed_user(migrated_database_urls["app"], email="owner@example.com") + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + store = ContinuityStore(conn) + store.create_execution_budget( + tool_key="proxy.echo", + domain_hint="docs", + max_completed_executions=1, + ) + + with pytest.raises(psycopg.IntegrityError): + with conn.transaction(): + store.create_execution_budget( + tool_key="proxy.echo", + domain_hint="docs", + max_completed_executions=2, + ) diff --git a/tests/integration/test_explicit_commitments_api.py b/tests/integration/test_explicit_commitments_api.py new file mode 100644 index 0000000..0d059b7 --- /dev/null +++ b/tests/integration/test_explicit_commitments_api.py @@ -0,0 +1,317 @@ +from __future__ import annotations + +import json +from typing import Any +from urllib.parse import urlencode +from uuid import UUID, uuid4 + +import anyio + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.db import user_connection +from alicebot_api.explicit_commitments import _build_memory_key +from alicebot_api.store import ContinuityStore + + +def invoke_request( + method: str, + path: str, + *, + query_params: dict[str, str] | None = None, + payload: dict[str, Any] | None = None, +) -> tuple[int, dict[str, Any]]: + messages: list[dict[str, object]] = [] + encoded_body = b"" if payload is None else json.dumps(payload).encode() + request_received = False + + async def receive() -> dict[str, object]: + nonlocal request_received + if request_received: + return {"type": "http.disconnect"} + + request_received = True + return {"type": "http.request", "body": encoded_body, "more_body": False} + + async def send(message: dict[str, object]) -> None: + messages.append(message) + + query_string = urlencode(query_params or {}).encode() + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": query_string, + "headers": [(b"content-type", b"application/json")], + "client": ("testclient", 50000), + "server": ("testserver", 80), + "root_path": "", + } + + anyio.run(main_module.app, scope, receive, send) + + start_message = next(message for message in messages if message["type"] == "http.response.start") + body = b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + return start_message["status"], json.loads(body) + + +def seed_explicit_commitment_events(database_url: str) -> dict[str, UUID]: + user_id = uuid4() + + with user_connection(database_url, user_id) as conn: + store = ContinuityStore(conn) + store.create_user(user_id, "owner@example.com", "Owner") + thread = store.create_thread("Explicit commitment extraction") + session = store.create_session(thread["id"], status="active") + commitment_event = store.append_event( + thread["id"], + session["id"], + "message.user", + {"text": "Remind me to submit tax forms."}, + )["id"] + unsupported_event = store.append_event( + thread["id"], + session["id"], + "message.user", + {"text": "I had coffee yesterday."}, + )["id"] + clause_event = store.append_event( + thread["id"], + session["id"], + "message.user", + {"text": "Remember to if we can reschedule."}, + )["id"] + assistant_event = store.append_event( + thread["id"], + session["id"], + "message.assistant", + {"text": "Remind me to submit tax forms."}, + )["id"] + + return { + "user_id": user_id, + "commitment_event_id": commitment_event, + "unsupported_event_id": unsupported_event, + "clause_event_id": clause_event, + "assistant_event_id": assistant_event, + } + + +def test_extract_explicit_commitments_endpoint_persists_memory_open_loop_and_remains_idempotent_on_repeat( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_explicit_commitment_events(migrated_database_urls["app"]) + memory_key = _build_memory_key("submit tax forms") + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + first_status, first_payload = invoke_request( + "POST", + "/v0/open-loops/extract-explicit-commitments", + payload={ + "user_id": str(seeded["user_id"]), + "source_event_id": str(seeded["commitment_event_id"]), + }, + ) + repeat_status, repeat_payload = invoke_request( + "POST", + "/v0/open-loops/extract-explicit-commitments", + payload={ + "user_id": str(seeded["user_id"]), + "source_event_id": str(seeded["commitment_event_id"]), + }, + ) + + assert first_status == 200 + assert first_payload["candidates"] == [ + { + "memory_key": memory_key, + "value": { + "kind": "explicit_commitment", + "text": "submit tax forms", + }, + "source_event_ids": [str(seeded["commitment_event_id"])], + "delete_requested": False, + "pattern": "remind_me_to", + "commitment_text": "submit tax forms", + "open_loop_title": "Remember to submit tax forms", + } + ] + assert first_payload["admissions"][0]["decision"] == "ADD" + assert first_payload["admissions"][0]["open_loop"]["decision"] == "CREATED" + assert first_payload["summary"] == { + "source_event_id": str(seeded["commitment_event_id"]), + "source_event_kind": "message.user", + "candidate_count": 1, + "admission_count": 1, + "persisted_change_count": 1, + "noop_count": 0, + "open_loop_created_count": 1, + "open_loop_noop_count": 0, + } + + assert repeat_status == 200 + assert repeat_payload["admissions"][0]["decision"] == "NOOP" + assert repeat_payload["admissions"][0]["open_loop"]["decision"] == "NOOP_ACTIVE_EXISTS" + assert repeat_payload["summary"] == { + "source_event_id": str(seeded["commitment_event_id"]), + "source_event_kind": "message.user", + "candidate_count": 1, + "admission_count": 1, + "persisted_change_count": 0, + "noop_count": 1, + "open_loop_created_count": 0, + "open_loop_noop_count": 1, + } + + memories_status, memories_payload = invoke_request( + "GET", + "/v0/memories", + query_params={ + "user_id": str(seeded["user_id"]), + "status": "active", + "limit": "20", + }, + ) + open_loops_status, open_loops_payload = invoke_request( + "GET", + "/v0/open-loops", + query_params={ + "user_id": str(seeded["user_id"]), + "status": "open", + "limit": "20", + }, + ) + + assert memories_status == 200 + assert open_loops_status == 200 + assert [item["memory_key"] for item in memories_payload["items"]] == [memory_key] + assert len(open_loops_payload["items"]) == 1 + assert open_loops_payload["items"][0]["memory_id"] == memories_payload["items"][0]["id"] + + with user_connection(migrated_database_urls["app"], seeded["user_id"]) as conn: + store = ContinuityStore(conn) + memories = store.list_memories() + open_loops = store.list_open_loops(status="open") + + assert [memory["memory_key"] for memory in memories] == [memory_key] + assert len(open_loops) == 1 + + +def test_extract_explicit_commitments_endpoint_returns_no_candidates_for_unsupported_or_clause_text( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_explicit_commitment_events(migrated_database_urls["app"]) + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + unsupported_status, unsupported_payload = invoke_request( + "POST", + "/v0/open-loops/extract-explicit-commitments", + payload={ + "user_id": str(seeded["user_id"]), + "source_event_id": str(seeded["unsupported_event_id"]), + }, + ) + clause_status, clause_payload = invoke_request( + "POST", + "/v0/open-loops/extract-explicit-commitments", + payload={ + "user_id": str(seeded["user_id"]), + "source_event_id": str(seeded["clause_event_id"]), + }, + ) + + assert unsupported_status == 200 + assert unsupported_payload == { + "candidates": [], + "admissions": [], + "summary": { + "source_event_id": str(seeded["unsupported_event_id"]), + "source_event_kind": "message.user", + "candidate_count": 0, + "admission_count": 0, + "persisted_change_count": 0, + "noop_count": 0, + "open_loop_created_count": 0, + "open_loop_noop_count": 0, + }, + } + assert clause_status == 200 + assert clause_payload == { + "candidates": [], + "admissions": [], + "summary": { + "source_event_id": str(seeded["clause_event_id"]), + "source_event_kind": "message.user", + "candidate_count": 0, + "admission_count": 0, + "persisted_change_count": 0, + "noop_count": 0, + "open_loop_created_count": 0, + "open_loop_noop_count": 0, + }, + } + + +def test_extract_explicit_commitments_endpoint_rejects_invalid_source_event_and_user_scope( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_explicit_commitment_events(migrated_database_urls["app"]) + intruder_id = uuid4() + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + with user_connection(migrated_database_urls["app"], intruder_id) as conn: + ContinuityStore(conn).create_user(intruder_id, "intruder@example.com", "Intruder") + + assistant_status, assistant_payload = invoke_request( + "POST", + "/v0/open-loops/extract-explicit-commitments", + payload={ + "user_id": str(seeded["user_id"]), + "source_event_id": str(seeded["assistant_event_id"]), + }, + ) + intruder_status, intruder_payload = invoke_request( + "POST", + "/v0/open-loops/extract-explicit-commitments", + payload={ + "user_id": str(intruder_id), + "source_event_id": str(seeded["commitment_event_id"]), + }, + ) + + assert assistant_status == 400 + assert assistant_payload == { + "detail": "source_event_id must reference an existing message.user event owned by the user" + } + assert intruder_status == 400 + assert intruder_payload == { + "detail": "source_event_id must reference an existing message.user event owned by the user" + } + + with user_connection(migrated_database_urls["app"], intruder_id) as conn: + store = ContinuityStore(conn) + assert store.list_memories() == [] + assert store.list_open_loops(status="open") == [] diff --git a/tests/integration/test_explicit_preferences_api.py b/tests/integration/test_explicit_preferences_api.py new file mode 100644 index 0000000..77e55d8 --- /dev/null +++ b/tests/integration/test_explicit_preferences_api.py @@ -0,0 +1,400 @@ +from __future__ import annotations + +import json +from uuid import UUID, uuid4 + +import anyio + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.db import user_connection +from alicebot_api.explicit_preferences import _build_memory_key +from alicebot_api.store import ContinuityStore + + +def invoke_extract_explicit_preferences(payload: dict[str, str]) -> tuple[int, dict[str, object]]: + messages: list[dict[str, object]] = [] + encoded_body = json.dumps(payload).encode() + request_received = False + + async def receive() -> dict[str, object]: + nonlocal request_received + if request_received: + return {"type": "http.disconnect"} + + request_received = True + return {"type": "http.request", "body": encoded_body, "more_body": False} + + async def send(message: dict[str, object]) -> None: + messages.append(message) + + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": "POST", + "scheme": "http", + "path": "/v0/memories/extract-explicit-preferences", + "raw_path": b"/v0/memories/extract-explicit-preferences", + "query_string": b"", + "headers": [(b"content-type", b"application/json")], + "client": ("testclient", 50000), + "server": ("testserver", 80), + "root_path": "", + } + + anyio.run(main_module.app, scope, receive, send) + + start_message = next(message for message in messages if message["type"] == "http.response.start") + body = b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + return start_message["status"], json.loads(body) + + +def seed_explicit_preference_events(database_url: str) -> dict[str, UUID]: + user_id = uuid4() + + with user_connection(database_url, user_id) as conn: + store = ContinuityStore(conn) + store.create_user(user_id, "owner@example.com", "Owner") + thread = store.create_thread("Explicit preference extraction") + session = store.create_session(thread["id"], status="active") + like_event = store.append_event( + thread["id"], + session["id"], + "message.user", + {"text": "I like black coffee."}, + )["id"] + dislike_event = store.append_event( + thread["id"], + session["id"], + "message.user", + {"text": "I don't like black coffee."}, + )["id"] + unsupported_event = store.append_event( + thread["id"], + session["id"], + "message.user", + {"text": "I had coffee yesterday."}, + )["id"] + clause_event = store.append_event( + thread["id"], + session["id"], + "message.user", + {"text": "I prefer that we meet tomorrow."}, + )["id"] + cpp_event = store.append_event( + thread["id"], + session["id"], + "message.user", + {"text": "I like C++."}, + )["id"] + csharp_event = store.append_event( + thread["id"], + session["id"], + "message.user", + {"text": "I like C#."}, + )["id"] + assistant_event = store.append_event( + thread["id"], + session["id"], + "message.assistant", + {"text": "I like black coffee."}, + )["id"] + + return { + "user_id": user_id, + "like_event_id": like_event, + "dislike_event_id": dislike_event, + "unsupported_event_id": unsupported_event, + "clause_event_id": clause_event, + "cpp_event_id": cpp_event, + "csharp_event_id": csharp_event, + "assistant_event_id": assistant_event, + } + + +def test_extract_explicit_preferences_endpoint_admits_supported_candidates_and_persists_revisions( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_explicit_preference_events(migrated_database_urls["app"]) + memory_key = _build_memory_key("black coffee") + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + add_status, add_payload = invoke_extract_explicit_preferences( + { + "user_id": str(seeded["user_id"]), + "source_event_id": str(seeded["like_event_id"]), + } + ) + update_status, update_payload = invoke_extract_explicit_preferences( + { + "user_id": str(seeded["user_id"]), + "source_event_id": str(seeded["dislike_event_id"]), + } + ) + + assert add_status == 200 + assert add_payload["candidates"] == [ + { + "memory_key": memory_key, + "value": { + "kind": "explicit_preference", + "preference": "like", + "text": "black coffee", + }, + "source_event_ids": [str(seeded["like_event_id"])], + "delete_requested": False, + "pattern": "i_like", + "subject_text": "black coffee", + } + ] + assert add_payload["admissions"][0]["decision"] == "ADD" + assert add_payload["summary"] == { + "source_event_id": str(seeded["like_event_id"]), + "source_event_kind": "message.user", + "candidate_count": 1, + "admission_count": 1, + "persisted_change_count": 1, + "noop_count": 0, + } + + assert update_status == 200 + assert update_payload["candidates"] == [ + { + "memory_key": memory_key, + "value": { + "kind": "explicit_preference", + "preference": "dislike", + "text": "black coffee", + }, + "source_event_ids": [str(seeded["dislike_event_id"])], + "delete_requested": False, + "pattern": "i_dont_like", + "subject_text": "black coffee", + } + ] + assert update_payload["admissions"][0]["decision"] == "UPDATE" + assert update_payload["summary"] == { + "source_event_id": str(seeded["dislike_event_id"]), + "source_event_kind": "message.user", + "candidate_count": 1, + "admission_count": 1, + "persisted_change_count": 1, + "noop_count": 0, + } + + memory_id = UUID(str(update_payload["admissions"][0]["memory"]["id"])) + with user_connection(migrated_database_urls["app"], seeded["user_id"]) as conn: + store = ContinuityStore(conn) + memories = store.list_memories() + revisions = store.list_memory_revisions(memory_id) + + assert len(memories) == 1 + assert memories[0]["id"] == memory_id + assert memories[0]["memory_key"] == memory_key + assert memories[0]["value"] == { + "kind": "explicit_preference", + "preference": "dislike", + "text": "black coffee", + } + assert [revision["action"] for revision in revisions] == ["ADD", "UPDATE"] + assert revisions[0]["candidate"] == { + "memory_key": memory_key, + "agent_profile_id": "assistant_default", + "value": { + "kind": "explicit_preference", + "preference": "like", + "text": "black coffee", + }, + "source_event_ids": [str(seeded["like_event_id"])], + "delete_requested": False, + } + assert revisions[1]["candidate"] == { + "memory_key": memory_key, + "agent_profile_id": "assistant_default", + "value": { + "kind": "explicit_preference", + "preference": "dislike", + "text": "black coffee", + }, + "source_event_ids": [str(seeded["dislike_event_id"])], + "delete_requested": False, + } + + +def test_extract_explicit_preferences_endpoint_returns_no_candidates_for_unsupported_text( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_explicit_preference_events(migrated_database_urls["app"]) + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + status_code, payload = invoke_extract_explicit_preferences( + { + "user_id": str(seeded["user_id"]), + "source_event_id": str(seeded["unsupported_event_id"]), + } + ) + + assert status_code == 200 + assert payload == { + "candidates": [], + "admissions": [], + "summary": { + "source_event_id": str(seeded["unsupported_event_id"]), + "source_event_kind": "message.user", + "candidate_count": 0, + "admission_count": 0, + "persisted_change_count": 0, + "noop_count": 0, + }, + } + + with user_connection(migrated_database_urls["app"], seeded["user_id"]) as conn: + store = ContinuityStore(conn) + assert store.list_memories() == [] + + +def test_extract_explicit_preferences_endpoint_rejects_clause_style_tail( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_explicit_preference_events(migrated_database_urls["app"]) + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + status_code, payload = invoke_extract_explicit_preferences( + { + "user_id": str(seeded["user_id"]), + "source_event_id": str(seeded["clause_event_id"]), + } + ) + + assert status_code == 200 + assert payload == { + "candidates": [], + "admissions": [], + "summary": { + "source_event_id": str(seeded["clause_event_id"]), + "source_event_kind": "message.user", + "candidate_count": 0, + "admission_count": 0, + "persisted_change_count": 0, + "noop_count": 0, + }, + } + + with user_connection(migrated_database_urls["app"], seeded["user_id"]) as conn: + store = ContinuityStore(conn) + assert store.list_memories() == [] + + +def test_extract_explicit_preferences_endpoint_keeps_symbol_subjects_in_distinct_memories( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_explicit_preference_events(migrated_database_urls["app"]) + cpp_key = _build_memory_key("C++") + csharp_key = _build_memory_key("C#") + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + cpp_status, cpp_payload = invoke_extract_explicit_preferences( + { + "user_id": str(seeded["user_id"]), + "source_event_id": str(seeded["cpp_event_id"]), + } + ) + csharp_status, csharp_payload = invoke_extract_explicit_preferences( + { + "user_id": str(seeded["user_id"]), + "source_event_id": str(seeded["csharp_event_id"]), + } + ) + + assert cpp_status == 200 + assert cpp_payload["candidates"][0]["memory_key"] == cpp_key + assert cpp_payload["admissions"][0]["decision"] == "ADD" + assert csharp_status == 200 + assert csharp_payload["candidates"][0]["memory_key"] == csharp_key + assert csharp_payload["admissions"][0]["decision"] == "ADD" + assert cpp_key != csharp_key + + with user_connection(migrated_database_urls["app"], seeded["user_id"]) as conn: + store = ContinuityStore(conn) + memories = sorted(store.list_memories(), key=lambda memory: memory["memory_key"]) + + assert [memory["memory_key"] for memory in memories] == sorted([cpp_key, csharp_key]) + assert {memory["memory_key"]: memory["value"] for memory in memories} == { + cpp_key: { + "kind": "explicit_preference", + "preference": "like", + "text": "C++", + }, + csharp_key: { + "kind": "explicit_preference", + "preference": "like", + "text": "C#", + }, + } + + +def test_extract_explicit_preferences_endpoint_validates_source_event_and_user_scope( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_explicit_preference_events(migrated_database_urls["app"]) + intruder_id = uuid4() + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + with user_connection(migrated_database_urls["app"], intruder_id) as conn: + ContinuityStore(conn).create_user(intruder_id, "intruder@example.com", "Intruder") + + assistant_status, assistant_payload = invoke_extract_explicit_preferences( + { + "user_id": str(seeded["user_id"]), + "source_event_id": str(seeded["assistant_event_id"]), + } + ) + intruder_status, intruder_payload = invoke_extract_explicit_preferences( + { + "user_id": str(intruder_id), + "source_event_id": str(seeded["like_event_id"]), + } + ) + + assert assistant_status == 400 + assert assistant_payload == { + "detail": "source_event_id must reference an existing message.user event owned by the user" + } + assert intruder_status == 400 + assert intruder_payload == { + "detail": "source_event_id must reference an existing message.user event owned by the user" + } + + with user_connection(migrated_database_urls["app"], intruder_id) as conn: + store = ContinuityStore(conn) + assert store.list_memories() == [] diff --git a/tests/integration/test_explicit_signal_capture_api.py b/tests/integration/test_explicit_signal_capture_api.py new file mode 100644 index 0000000..c104bc3 --- /dev/null +++ b/tests/integration/test_explicit_signal_capture_api.py @@ -0,0 +1,313 @@ +from __future__ import annotations + +import json +from typing import Any +from urllib.parse import urlencode +from uuid import UUID, uuid4 + +import anyio + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.db import user_connection +from alicebot_api.explicit_commitments import _build_memory_key as build_commitment_memory_key +from alicebot_api.explicit_preferences import _build_memory_key as build_preference_memory_key +from alicebot_api.store import ContinuityStore + + +def invoke_request( + method: str, + path: str, + *, + query_params: dict[str, str] | None = None, + payload: dict[str, Any] | None = None, +) -> tuple[int, dict[str, Any]]: + messages: list[dict[str, object]] = [] + encoded_body = b"" if payload is None else json.dumps(payload).encode() + request_received = False + + async def receive() -> dict[str, object]: + nonlocal request_received + if request_received: + return {"type": "http.disconnect"} + + request_received = True + return {"type": "http.request", "body": encoded_body, "more_body": False} + + async def send(message: dict[str, object]) -> None: + messages.append(message) + + query_string = urlencode(query_params or {}).encode() + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": query_string, + "headers": [(b"content-type", b"application/json")], + "client": ("testclient", 50000), + "server": ("testserver", 80), + "root_path": "", + } + + anyio.run(main_module.app, scope, receive, send) + + start_message = next(message for message in messages if message["type"] == "http.response.start") + body = b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + return start_message["status"], json.loads(body) + + +def seed_explicit_signal_capture_events(database_url: str) -> dict[str, UUID]: + user_id = uuid4() + + with user_connection(database_url, user_id) as conn: + store = ContinuityStore(conn) + store.create_user(user_id, "owner@example.com", "Owner") + thread = store.create_thread("Unified explicit signal capture") + session = store.create_session(thread["id"], status="active") + preference_event = store.append_event( + thread["id"], + session["id"], + "message.user", + {"text": "I like black coffee."}, + )["id"] + commitment_event = store.append_event( + thread["id"], + session["id"], + "message.user", + {"text": "Remind me to submit tax forms."}, + )["id"] + assistant_event = store.append_event( + thread["id"], + session["id"], + "message.assistant", + {"text": "I like black coffee."}, + )["id"] + + return { + "user_id": user_id, + "preference_event_id": preference_event, + "commitment_event_id": commitment_event, + "assistant_event_id": assistant_event, + } + + +def test_capture_explicit_signals_endpoint_returns_unified_sections_and_aggregate_summary( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_explicit_signal_capture_events(migrated_database_urls["app"]) + preference_memory_key = build_preference_memory_key("black coffee") + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + status_code, payload = invoke_request( + "POST", + "/v0/memories/capture-explicit-signals", + payload={ + "user_id": str(seeded["user_id"]), + "source_event_id": str(seeded["preference_event_id"]), + }, + ) + + assert status_code == 200 + assert payload["preferences"]["candidates"] == [ + { + "memory_key": preference_memory_key, + "value": { + "kind": "explicit_preference", + "preference": "like", + "text": "black coffee", + }, + "source_event_ids": [str(seeded["preference_event_id"])], + "delete_requested": False, + "pattern": "i_like", + "subject_text": "black coffee", + } + ] + assert payload["preferences"]["admissions"][0]["decision"] == "ADD" + assert payload["preferences"]["summary"] == { + "source_event_id": str(seeded["preference_event_id"]), + "source_event_kind": "message.user", + "candidate_count": 1, + "admission_count": 1, + "persisted_change_count": 1, + "noop_count": 0, + } + assert payload["commitments"] == { + "candidates": [], + "admissions": [], + "summary": { + "source_event_id": str(seeded["preference_event_id"]), + "source_event_kind": "message.user", + "candidate_count": 0, + "admission_count": 0, + "persisted_change_count": 0, + "noop_count": 0, + "open_loop_created_count": 0, + "open_loop_noop_count": 0, + }, + } + assert payload["summary"] == { + "source_event_id": str(seeded["preference_event_id"]), + "source_event_kind": "message.user", + "candidate_count": 1, + "admission_count": 1, + "persisted_change_count": 1, + "noop_count": 0, + "open_loop_created_count": 0, + "open_loop_noop_count": 0, + "preference_candidate_count": 1, + "preference_admission_count": 1, + "commitment_candidate_count": 0, + "commitment_admission_count": 0, + } + + +def test_capture_explicit_signals_endpoint_remains_idempotent_for_open_loop_creation( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_explicit_signal_capture_events(migrated_database_urls["app"]) + commitment_memory_key = build_commitment_memory_key("submit tax forms") + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + first_status, first_payload = invoke_request( + "POST", + "/v0/memories/capture-explicit-signals", + payload={ + "user_id": str(seeded["user_id"]), + "source_event_id": str(seeded["commitment_event_id"]), + }, + ) + repeat_status, repeat_payload = invoke_request( + "POST", + "/v0/memories/capture-explicit-signals", + payload={ + "user_id": str(seeded["user_id"]), + "source_event_id": str(seeded["commitment_event_id"]), + }, + ) + + assert first_status == 200 + assert first_payload["commitments"]["candidates"] == [ + { + "memory_key": commitment_memory_key, + "value": { + "kind": "explicit_commitment", + "text": "submit tax forms", + }, + "source_event_ids": [str(seeded["commitment_event_id"])], + "delete_requested": False, + "pattern": "remind_me_to", + "commitment_text": "submit tax forms", + "open_loop_title": "Remember to submit tax forms", + } + ] + assert first_payload["commitments"]["admissions"][0]["decision"] == "ADD" + assert first_payload["commitments"]["admissions"][0]["open_loop"]["decision"] == "CREATED" + assert first_payload["summary"] == { + "source_event_id": str(seeded["commitment_event_id"]), + "source_event_kind": "message.user", + "candidate_count": 1, + "admission_count": 1, + "persisted_change_count": 1, + "noop_count": 0, + "open_loop_created_count": 1, + "open_loop_noop_count": 0, + "preference_candidate_count": 0, + "preference_admission_count": 0, + "commitment_candidate_count": 1, + "commitment_admission_count": 1, + } + + assert repeat_status == 200 + assert repeat_payload["commitments"]["admissions"][0]["decision"] == "NOOP" + assert repeat_payload["commitments"]["admissions"][0]["open_loop"]["decision"] == "NOOP_ACTIVE_EXISTS" + assert repeat_payload["summary"] == { + "source_event_id": str(seeded["commitment_event_id"]), + "source_event_kind": "message.user", + "candidate_count": 1, + "admission_count": 1, + "persisted_change_count": 0, + "noop_count": 1, + "open_loop_created_count": 0, + "open_loop_noop_count": 1, + "preference_candidate_count": 0, + "preference_admission_count": 0, + "commitment_candidate_count": 1, + "commitment_admission_count": 1, + } + + open_loops_status, open_loops_payload = invoke_request( + "GET", + "/v0/open-loops", + query_params={ + "user_id": str(seeded["user_id"]), + "status": "open", + "limit": "20", + }, + ) + assert open_loops_status == 200 + assert len(open_loops_payload["items"]) == 1 + + +def test_capture_explicit_signals_endpoint_rejects_invalid_source_event_and_user_scope( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_explicit_signal_capture_events(migrated_database_urls["app"]) + intruder_id = uuid4() + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + with user_connection(migrated_database_urls["app"], intruder_id) as conn: + ContinuityStore(conn).create_user(intruder_id, "intruder@example.com", "Intruder") + + assistant_status, assistant_payload = invoke_request( + "POST", + "/v0/memories/capture-explicit-signals", + payload={ + "user_id": str(seeded["user_id"]), + "source_event_id": str(seeded["assistant_event_id"]), + }, + ) + intruder_status, intruder_payload = invoke_request( + "POST", + "/v0/memories/capture-explicit-signals", + payload={ + "user_id": str(intruder_id), + "source_event_id": str(seeded["commitment_event_id"]), + }, + ) + + assert assistant_status == 400 + assert assistant_payload == { + "detail": "source_event_id must reference an existing message.user event owned by the user" + } + assert intruder_status == 400 + assert intruder_payload == { + "detail": "source_event_id must reference an existing message.user event owned by the user" + } + + with user_connection(migrated_database_urls["app"], intruder_id) as conn: + store = ContinuityStore(conn) + assert store.list_memories() == [] + assert store.list_open_loops(status="open") == [] diff --git a/tests/integration/test_gmail_accounts_api.py b/tests/integration/test_gmail_accounts_api.py new file mode 100644 index 0000000..acfbae4 --- /dev/null +++ b/tests/integration/test_gmail_accounts_api.py @@ -0,0 +1,1176 @@ +from __future__ import annotations + +import json +from datetime import datetime +from pathlib import Path +from typing import Any +from urllib.parse import urlencode +from uuid import UUID, uuid4 + +import anyio +import psycopg + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +import alicebot_api.gmail as gmail_module +from alicebot_api.db import user_connection +from alicebot_api.store import ContinuityStore + + +def invoke_request( + method: str, + path: str, + *, + query_params: dict[str, str] | None = None, + payload: dict[str, Any] | None = None, +) -> tuple[int, dict[str, Any]]: + messages: list[dict[str, object]] = [] + encoded_body = b"" if payload is None else json.dumps(payload).encode() + request_received = False + + async def receive() -> dict[str, object]: + nonlocal request_received + if request_received: + return {"type": "http.disconnect"} + + request_received = True + return {"type": "http.request", "body": encoded_body, "more_body": False} + + async def send(message: dict[str, object]) -> None: + messages.append(message) + + query_string = urlencode(query_params or {}).encode() + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": query_string, + "headers": [(b"content-type", b"application/json")], + "client": ("testclient", 50000), + "server": ("testserver", 80), + "root_path": "", + } + + anyio.run(main_module.app, scope, receive, send) + + start_message = next(message for message in messages if message["type"] == "http.response.start") + body = b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + return start_message["status"], json.loads(body) + + +def _build_rfc822_email_bytes(*, subject: str, plain_body: str) -> bytes: + return ( + "\r\n".join( + [ + "From: Alice <alice@example.com>", + "To: Bob <bob@example.com>", + f"Subject: {subject}", + 'Content-Type: text/plain; charset="utf-8"', + "Content-Transfer-Encoding: 8bit", + "", + plain_body, + ] + ).encode("utf-8") + ) + + +def _build_gmail_secret_manager_url(root: Path) -> str: + return root.resolve().as_uri() + + +def seed_user(database_url: str, *, email: str) -> dict[str, UUID]: + user_id = uuid4() + + with user_connection(database_url, user_id) as conn: + store = ContinuityStore(conn) + store.create_user(user_id, email, email.split("@", 1)[0].title()) + + return {"user_id": user_id} + + +def seed_task(database_url: str, *, email: str) -> dict[str, UUID]: + user_id = uuid4() + + with user_connection(database_url, user_id) as conn: + store = ContinuityStore(conn) + store.create_user(user_id, email, email.split("@", 1)[0].title()) + thread = store.create_thread("Gmail thread") + tool = store.create_tool( + tool_key="proxy.echo", + name="Proxy Echo", + description="Deterministic proxy handler.", + version="1.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["proxy"], + action_hints=["tool.run"], + scope_hints=["workspace"], + domain_hints=[], + risk_hints=[], + metadata={"transport": "proxy"}, + ) + task = store.create_task( + thread_id=thread["id"], + tool_id=tool["id"], + status="approved", + request={ + "thread_id": str(thread["id"]), + "tool_id": str(tool["id"]), + "action": "tool.run", + "scope": "workspace", + "domain_hint": None, + "risk_hint": None, + "attributes": {}, + }, + tool={ + "id": str(tool["id"]), + "tool_key": "proxy.echo", + "name": "Proxy Echo", + "description": "Deterministic proxy handler.", + "version": "1.0.0", + "metadata_version": "tool_metadata_v0", + "active": True, + "tags": ["proxy"], + "action_hints": ["tool.run"], + "scope_hints": ["workspace"], + "domain_hints": [], + "risk_hints": [], + "metadata": {"transport": "proxy"}, + "created_at": tool["created_at"].isoformat(), + }, + latest_approval_id=None, + latest_execution_id=None, + ) + + return { + "user_id": user_id, + "task_id": task["id"], + } + + +def _connect_gmail_account( + *, + user_id: UUID, + provider_account_id: str, + email_address: str, + credential_overrides: dict[str, Any] | None = None, +) -> tuple[int, dict[str, Any]]: + payload = { + "user_id": str(user_id), + "provider_account_id": provider_account_id, + "email_address": email_address, + "display_name": email_address.split("@", 1)[0].title(), + "scope": "https://www.googleapis.com/auth/gmail.readonly", + "access_token": f"token-for-{provider_account_id}", + } + if credential_overrides is not None: + payload.update(credential_overrides) + + return invoke_request( + "POST", + "/v0/gmail-accounts", + payload=payload, + ) + + +def test_gmail_account_endpoints_connect_list_detail_and_isolate( + migrated_database_urls, + monkeypatch, + tmp_path, +) -> None: + owner = seed_user(migrated_database_urls["app"], email="owner@example.com") + intruder = seed_user(migrated_database_urls["app"], email="intruder@example.com") + gmail_secret_root = tmp_path / "gmail-secrets" + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + gmail_secret_manager_url=_build_gmail_secret_manager_url(gmail_secret_root), + ), + ) + + create_status, create_payload = _connect_gmail_account( + user_id=owner["user_id"], + provider_account_id="acct-owner-001", + email_address="owner@gmail.example", + ) + list_status, list_payload = invoke_request( + "GET", + "/v0/gmail-accounts", + query_params={"user_id": str(owner["user_id"])}, + ) + detail_status, detail_payload = invoke_request( + "GET", + f"/v0/gmail-accounts/{create_payload['account']['id']}", + query_params={"user_id": str(owner["user_id"])}, + ) + duplicate_status, duplicate_payload = _connect_gmail_account( + user_id=owner["user_id"], + provider_account_id="acct-owner-001", + email_address="owner@gmail.example", + ) + isolated_list_status, isolated_list_payload = invoke_request( + "GET", + "/v0/gmail-accounts", + query_params={"user_id": str(intruder["user_id"])}, + ) + isolated_detail_status, isolated_detail_payload = invoke_request( + "GET", + f"/v0/gmail-accounts/{create_payload['account']['id']}", + query_params={"user_id": str(intruder["user_id"])}, + ) + + assert create_status == 201 + assert create_payload == { + "account": { + "id": create_payload["account"]["id"], + "provider": "gmail", + "auth_kind": "oauth_access_token", + "provider_account_id": "acct-owner-001", + "email_address": "owner@gmail.example", + "display_name": "Owner", + "scope": "https://www.googleapis.com/auth/gmail.readonly", + "created_at": create_payload["account"]["created_at"], + "updated_at": create_payload["account"]["updated_at"], + } + } + assert list_status == 200 + assert list_payload == { + "items": [create_payload["account"]], + "summary": {"total_count": 1, "order": ["created_at_asc", "id_asc"]}, + } + assert detail_status == 200 + assert detail_payload == {"account": create_payload["account"]} + assert duplicate_status == 409 + assert duplicate_payload == {"detail": "gmail account acct-owner-001 is already connected"} + assert isolated_list_status == 200 + assert isolated_list_payload == { + "items": [], + "summary": {"total_count": 0, "order": ["created_at_asc", "id_asc"]}, + } + assert isolated_detail_status == 404 + assert isolated_detail_payload == { + "detail": f"gmail account {create_payload['account']['id']} was not found" + } + assert '"access_token":' not in json.dumps(create_payload) + assert '"access_token":' not in json.dumps(list_payload) + assert '"access_token":' not in json.dumps(detail_payload) + assert '"refresh_token":' not in json.dumps(create_payload) + assert '"client_secret":' not in json.dumps(create_payload) + + with psycopg.connect(migrated_database_urls["admin"]) as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT column_name + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'gmail_accounts' + ORDER BY ordinal_position + """ + ) + gmail_account_columns = {row[0] for row in cur.fetchall()} + assert "access_token" not in gmail_account_columns + cur.execute( + """ + SELECT + auth_kind, + credential_kind, + secret_manager_kind, + secret_ref, + credential_blob IS NULL + FROM gmail_account_credentials + WHERE gmail_account_id = %s + """, + (UUID(create_payload["account"]["id"]),), + ) + credential_row = cur.fetchone() + + assert credential_row is not None + assert credential_row[0] == "oauth_access_token" + assert credential_row[1] == "gmail_oauth_access_token_v1" + assert credential_row[2] == "file_v1" + assert credential_row[4] is True + assert credential_row[3] is not None + secret_payload = json.loads((gmail_secret_root / credential_row[3]).read_text(encoding="utf-8")) + assert secret_payload == { + "credential_kind": "gmail_oauth_access_token_v1", + "access_token": "token-for-acct-owner-001", + } + + +def test_gmail_message_ingestion_endpoint_persists_artifact_and_chunks( + migrated_database_urls, + monkeypatch, + tmp_path, +) -> None: + owner = seed_task(migrated_database_urls["app"], email="owner@example.com") + workspace_root = tmp_path / "task-workspaces" + gmail_secret_root = tmp_path / "gmail-secrets" + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + task_workspace_root=str(workspace_root), + gmail_secret_manager_url=_build_gmail_secret_manager_url(gmail_secret_root), + ), + ) + raw_bytes = _build_rfc822_email_bytes(subject="Inbox Update", plain_body="ingest this message") + monkeypatch.setattr( + gmail_module, + "fetch_gmail_message_raw_bytes", + lambda **_kwargs: raw_bytes, + ) + + account_status, account_payload = _connect_gmail_account( + user_id=owner["user_id"], + provider_account_id="acct-owner-001", + email_address="owner@gmail.example", + ) + workspace_status, workspace_payload = invoke_request( + "POST", + f"/v0/tasks/{owner['task_id']}/workspace", + payload={"user_id": str(owner["user_id"])}, + ) + ingest_status, ingest_payload = invoke_request( + "POST", + f"/v0/gmail-accounts/{account_payload['account']['id']}/messages/msg-001/ingest", + payload={ + "user_id": str(owner["user_id"]), + "task_workspace_id": workspace_payload["workspace"]["id"], + }, + ) + + assert account_status == 201 + assert workspace_status == 201 + assert ingest_status == 200 + assert ingest_payload == { + "account": account_payload["account"], + "message": { + "provider_message_id": "msg-001", + "artifact_relative_path": "gmail/acct-owner-001/msg-001.eml", + "media_type": "message/rfc822", + }, + "artifact": { + "id": ingest_payload["artifact"]["id"], + "task_id": str(owner["task_id"]), + "task_workspace_id": workspace_payload["workspace"]["id"], + "status": "registered", + "ingestion_status": "ingested", + "relative_path": "gmail/acct-owner-001/msg-001.eml", + "media_type_hint": "message/rfc822", + "created_at": ingest_payload["artifact"]["created_at"], + "updated_at": ingest_payload["artifact"]["updated_at"], + }, + "summary": { + "total_count": ingest_payload["summary"]["total_count"], + "total_characters": ingest_payload["summary"]["total_characters"], + "media_type": "message/rfc822", + "chunking_rule": "normalized_utf8_text_fixed_window_1000_chars_v1", + "order": ["sequence_no_asc", "id_asc"], + }, + } + assert ingest_payload["summary"]["total_count"] >= 1 + artifact_file = ( + Path(workspace_payload["workspace"]["local_path"]) / "gmail" / "acct-owner-001" / "msg-001.eml" + ) + assert artifact_file.read_bytes() == raw_bytes + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + store = ContinuityStore(conn) + artifact_rows = store.list_task_artifacts_for_task(owner["task_id"]) + assert len(artifact_rows) == 1 + assert artifact_rows[0]["relative_path"] == "gmail/acct-owner-001/msg-001.eml" + assert artifact_rows[0]["ingestion_status"] == "ingested" + chunk_rows = store.list_task_artifact_chunks(artifact_rows[0]["id"]) + assert len(chunk_rows) == ingest_payload["summary"]["total_count"] + assert chunk_rows[0]["text"].startswith("From: Alice <alice@example.com>") + + +def test_gmail_message_ingestion_endpoint_renews_expired_access_token( + migrated_database_urls, + monkeypatch, + tmp_path, +) -> None: + owner = seed_task(migrated_database_urls["app"], email="owner@example.com") + workspace_root = tmp_path / "task-workspaces" + gmail_secret_root = tmp_path / "gmail-secrets" + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + task_workspace_root=str(workspace_root), + gmail_secret_manager_url=_build_gmail_secret_manager_url(gmail_secret_root), + ), + ) + raw_bytes = _build_rfc822_email_bytes(subject="Inbox Update", plain_body="renewed token path") + fetch_tokens: list[str] = [] + + monkeypatch.setattr( + gmail_module, + "refresh_gmail_access_token", + lambda **_kwargs: gmail_module.RefreshedGmailCredential( + access_token="token-refreshed", + access_token_expires_at=datetime.fromisoformat("2030-01-01T00:05:00+00:00"), + ), + ) + + def fake_fetch_gmail_message_raw_bytes(*, access_token: str, **_kwargs) -> bytes: + fetch_tokens.append(access_token) + return raw_bytes + + monkeypatch.setattr( + gmail_module, + "fetch_gmail_message_raw_bytes", + fake_fetch_gmail_message_raw_bytes, + ) + + account_status, account_payload = _connect_gmail_account( + user_id=owner["user_id"], + provider_account_id="acct-owner-refresh-001", + email_address="owner@gmail.example", + credential_overrides={ + "refresh_token": "refresh-owner-001", + "client_id": "client-owner-001", + "client_secret": "secret-owner-001", + "access_token_expires_at": "2020-01-01T00:00:00+00:00", + }, + ) + workspace_status, workspace_payload = invoke_request( + "POST", + f"/v0/tasks/{owner['task_id']}/workspace", + payload={"user_id": str(owner["user_id"])}, + ) + ingest_status, ingest_payload = invoke_request( + "POST", + f"/v0/gmail-accounts/{account_payload['account']['id']}/messages/msg-001/ingest", + payload={ + "user_id": str(owner["user_id"]), + "task_workspace_id": workspace_payload["workspace"]["id"], + }, + ) + + assert account_status == 201 + assert workspace_status == 201 + assert ingest_status == 200 + assert fetch_tokens == ["token-refreshed"] + assert ingest_payload["message"] == { + "provider_message_id": "msg-001", + "artifact_relative_path": "gmail/acct-owner-refresh-001/msg-001.eml", + "media_type": "message/rfc822", + } + assert '"refresh_token":' not in json.dumps(ingest_payload) + assert '"client_secret":' not in json.dumps(ingest_payload) + + with psycopg.connect(migrated_database_urls["admin"]) as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT + credential_kind, + secret_manager_kind, + secret_ref, + credential_blob IS NULL + FROM gmail_account_credentials + WHERE gmail_account_id = %s + """, + (UUID(account_payload["account"]["id"]),), + ) + credential_row = cur.fetchone() + + assert credential_row is not None + assert credential_row[0] == "gmail_oauth_refresh_token_v2" + assert credential_row[1] == "file_v1" + assert credential_row[3] is True + assert credential_row[2] is not None + secret_payload = json.loads((gmail_secret_root / credential_row[2]).read_text(encoding="utf-8")) + assert secret_payload == { + "credential_kind": "gmail_oauth_refresh_token_v2", + "access_token": "token-refreshed", + "refresh_token": "refresh-owner-001", + "client_id": "client-owner-001", + "client_secret": "secret-owner-001", + "access_token_expires_at": "2030-01-01T00:05:00+00:00", + } + + +def test_gmail_message_ingestion_endpoint_persists_rotated_refresh_token( + migrated_database_urls, + monkeypatch, + tmp_path, +) -> None: + owner = seed_task(migrated_database_urls["app"], email="owner@example.com") + workspace_root = tmp_path / "task-workspaces" + gmail_secret_root = tmp_path / "gmail-secrets" + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + task_workspace_root=str(workspace_root), + gmail_secret_manager_url=_build_gmail_secret_manager_url(gmail_secret_root), + ), + ) + raw_bytes = _build_rfc822_email_bytes(subject="Inbox Update", plain_body="rotated token path") + fetch_tokens: list[str] = [] + + monkeypatch.setattr( + gmail_module, + "refresh_gmail_access_token", + lambda **_kwargs: gmail_module.RefreshedGmailCredential( + access_token="token-refreshed-rotated", + access_token_expires_at=datetime.fromisoformat("2030-01-01T00:05:00+00:00"), + refresh_token="refresh-owner-rotated-002", + ), + ) + + def fake_fetch_gmail_message_raw_bytes(*, access_token: str, **_kwargs) -> bytes: + fetch_tokens.append(access_token) + return raw_bytes + + monkeypatch.setattr( + gmail_module, + "fetch_gmail_message_raw_bytes", + fake_fetch_gmail_message_raw_bytes, + ) + + account_status, account_payload = _connect_gmail_account( + user_id=owner["user_id"], + provider_account_id="acct-owner-rotated-001", + email_address="owner@gmail.example", + credential_overrides={ + "refresh_token": "refresh-owner-001", + "client_id": "client-owner-001", + "client_secret": "secret-owner-001", + "access_token_expires_at": "2020-01-01T00:00:00+00:00", + }, + ) + workspace_status, workspace_payload = invoke_request( + "POST", + f"/v0/tasks/{owner['task_id']}/workspace", + payload={"user_id": str(owner["user_id"])}, + ) + ingest_status, ingest_payload = invoke_request( + "POST", + f"/v0/gmail-accounts/{account_payload['account']['id']}/messages/msg-001/ingest", + payload={ + "user_id": str(owner["user_id"]), + "task_workspace_id": workspace_payload["workspace"]["id"], + }, + ) + + assert account_status == 201 + assert workspace_status == 201 + assert ingest_status == 200 + assert fetch_tokens == ["token-refreshed-rotated"] + assert ingest_payload == { + "account": { + **account_payload["account"], + }, + "message": { + "provider_message_id": "msg-001", + "artifact_relative_path": "gmail/acct-owner-rotated-001/msg-001.eml", + "media_type": "message/rfc822", + }, + "artifact": { + "id": ingest_payload["artifact"]["id"], + "task_id": str(owner["task_id"]), + "task_workspace_id": workspace_payload["workspace"]["id"], + "status": "registered", + "ingestion_status": "ingested", + "relative_path": "gmail/acct-owner-rotated-001/msg-001.eml", + "media_type_hint": "message/rfc822", + "created_at": ingest_payload["artifact"]["created_at"], + "updated_at": ingest_payload["artifact"]["updated_at"], + }, + "summary": { + "total_count": ingest_payload["summary"]["total_count"], + "total_characters": ingest_payload["summary"]["total_characters"], + "media_type": "message/rfc822", + "chunking_rule": "normalized_utf8_text_fixed_window_1000_chars_v1", + "order": ["sequence_no_asc", "id_asc"], + }, + } + assert '"refresh_token":' not in json.dumps(ingest_payload) + assert '"client_secret":' not in json.dumps(ingest_payload) + + with psycopg.connect(migrated_database_urls["admin"]) as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT + credential_kind, + secret_manager_kind, + secret_ref, + credential_blob IS NULL + FROM gmail_account_credentials + WHERE gmail_account_id = %s + """, + (UUID(account_payload["account"]["id"]),), + ) + credential_row = cur.fetchone() + + assert credential_row is not None + assert credential_row[0] == "gmail_oauth_refresh_token_v2" + assert credential_row[1] == "file_v1" + assert credential_row[3] is True + assert credential_row[2] is not None + secret_payload = json.loads((gmail_secret_root / credential_row[2]).read_text(encoding="utf-8")) + assert secret_payload == { + "credential_kind": "gmail_oauth_refresh_token_v2", + "access_token": "token-refreshed-rotated", + "refresh_token": "refresh-owner-rotated-002", + "client_id": "client-owner-001", + "client_secret": "secret-owner-001", + "access_token_expires_at": "2030-01-01T00:05:00+00:00", + } + + +def test_gmail_message_ingestion_endpoint_fails_deterministically_when_rotated_credentials_cannot_be_persisted( + migrated_database_urls, + monkeypatch, + tmp_path, +) -> None: + owner = seed_task(migrated_database_urls["app"], email="owner@example.com") + workspace_root = tmp_path / "task-workspaces" + gmail_secret_root = tmp_path / "gmail-secrets" + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + task_workspace_root=str(workspace_root), + gmail_secret_manager_url=_build_gmail_secret_manager_url(gmail_secret_root), + ), + ) + + monkeypatch.setattr( + gmail_module, + "refresh_gmail_access_token", + lambda **_kwargs: gmail_module.RefreshedGmailCredential( + access_token="token-refreshed-rotated", + access_token_expires_at=datetime.fromisoformat("2030-01-01T00:05:00+00:00"), + refresh_token="refresh-owner-rotated-002", + ), + ) + + def fail_update_gmail_account_credential(self, **_kwargs): + raise psycopg.Error("simulated credential persistence failure") + + def fail_fetch(**_kwargs): + raise AssertionError("fetch_gmail_message_raw_bytes should not be called") + + monkeypatch.setattr( + ContinuityStore, + "update_gmail_account_credential", + fail_update_gmail_account_credential, + ) + monkeypatch.setattr(gmail_module, "fetch_gmail_message_raw_bytes", fail_fetch) + + _, account_payload = _connect_gmail_account( + user_id=owner["user_id"], + provider_account_id="acct-owner-rotated-001", + email_address="owner@gmail.example", + credential_overrides={ + "refresh_token": "refresh-owner-001", + "client_id": "client-owner-001", + "client_secret": "secret-owner-001", + "access_token_expires_at": "2020-01-01T00:00:00+00:00", + }, + ) + _, workspace_payload = invoke_request( + "POST", + f"/v0/tasks/{owner['task_id']}/workspace", + payload={"user_id": str(owner["user_id"])}, + ) + + ingest_status, ingest_payload = invoke_request( + "POST", + f"/v0/gmail-accounts/{account_payload['account']['id']}/messages/msg-001/ingest", + payload={ + "user_id": str(owner["user_id"]), + "task_workspace_id": workspace_payload["workspace"]["id"], + }, + ) + + assert ingest_status == 409 + assert ingest_payload == { + "detail": ( + f"gmail account {account_payload['account']['id']} renewed protected credentials " + "could not be persisted" + ) + } + artifact_file = ( + Path(workspace_payload["workspace"]["local_path"]) / "gmail" / "acct-owner-rotated-001" / "msg-001.eml" + ) + assert not artifact_file.exists() + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + store = ContinuityStore(conn) + assert store.list_task_artifacts_for_task(owner["task_id"]) == [] + + with psycopg.connect(migrated_database_urls["admin"]) as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT + credential_kind, + secret_manager_kind, + secret_ref, + credential_blob IS NULL + FROM gmail_account_credentials + WHERE gmail_account_id = %s + """, + (UUID(account_payload["account"]["id"]),), + ) + credential_row = cur.fetchone() + + assert credential_row is not None + assert credential_row[0] == "gmail_oauth_refresh_token_v2" + assert credential_row[1] == "file_v1" + assert credential_row[3] is True + assert credential_row[2] is not None + secret_payload = json.loads((gmail_secret_root / credential_row[2]).read_text(encoding="utf-8")) + assert secret_payload == { + "credential_kind": "gmail_oauth_refresh_token_v2", + "access_token": f"token-for-{account_payload['account']['provider_account_id']}", + "refresh_token": "refresh-owner-001", + "client_id": "client-owner-001", + "client_secret": "secret-owner-001", + "access_token_expires_at": "2020-01-01T00:00:00+00:00", + } + + +def test_gmail_message_ingestion_endpoint_rejects_cross_user_workspace_access( + migrated_database_urls, + monkeypatch, + tmp_path, +) -> None: + owner = seed_task(migrated_database_urls["app"], email="owner@example.com") + intruder = seed_task(migrated_database_urls["app"], email="intruder@example.com") + workspace_root = tmp_path / "task-workspaces" + gmail_secret_root = tmp_path / "gmail-secrets" + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + task_workspace_root=str(workspace_root), + gmail_secret_manager_url=_build_gmail_secret_manager_url(gmail_secret_root), + ), + ) + monkeypatch.setattr( + gmail_module, + "fetch_gmail_message_raw_bytes", + lambda **_kwargs: _build_rfc822_email_bytes( + subject="Inbox Update", + plain_body="ingest this message", + ), + ) + + _, owner_workspace_payload = invoke_request( + "POST", + f"/v0/tasks/{owner['task_id']}/workspace", + payload={"user_id": str(owner["user_id"])}, + ) + _, intruder_account_payload = _connect_gmail_account( + user_id=intruder["user_id"], + provider_account_id="acct-intruder-001", + email_address="intruder@gmail.example", + ) + ingest_status, ingest_payload = invoke_request( + "POST", + f"/v0/gmail-accounts/{intruder_account_payload['account']['id']}/messages/msg-001/ingest", + payload={ + "user_id": str(intruder["user_id"]), + "task_workspace_id": owner_workspace_payload["workspace"]["id"], + }, + ) + + assert ingest_status == 404 + assert ingest_payload == { + "detail": f"task workspace {owner_workspace_payload['workspace']['id']} was not found" + } + + +def test_gmail_message_ingestion_endpoint_rejects_missing_protected_credentials_without_side_effects( + migrated_database_urls, + monkeypatch, + tmp_path, +) -> None: + owner = seed_task(migrated_database_urls["app"], email="owner@example.com") + workspace_root = tmp_path / "task-workspaces" + gmail_secret_root = tmp_path / "gmail-secrets" + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + task_workspace_root=str(workspace_root), + gmail_secret_manager_url=_build_gmail_secret_manager_url(gmail_secret_root), + ), + ) + + def fail_fetch(**_kwargs): + raise AssertionError("fetch_gmail_message_raw_bytes should not be called") + + monkeypatch.setattr(gmail_module, "fetch_gmail_message_raw_bytes", fail_fetch) + + _, account_payload = _connect_gmail_account( + user_id=owner["user_id"], + provider_account_id="acct-owner-001", + email_address="owner@gmail.example", + ) + _, workspace_payload = invoke_request( + "POST", + f"/v0/tasks/{owner['task_id']}/workspace", + payload={"user_id": str(owner["user_id"])}, + ) + + with psycopg.connect(migrated_database_urls["admin"]) as conn: + with conn.cursor() as cur: + cur.execute( + "DELETE FROM gmail_account_credentials WHERE gmail_account_id = %s", + (UUID(account_payload["account"]["id"]),), + ) + conn.commit() + + ingest_status, ingest_payload = invoke_request( + "POST", + f"/v0/gmail-accounts/{account_payload['account']['id']}/messages/msg-001/ingest", + payload={ + "user_id": str(owner["user_id"]), + "task_workspace_id": workspace_payload["workspace"]["id"], + }, + ) + + assert ingest_status == 409 + assert ingest_payload == { + "detail": ( + f"gmail account {account_payload['account']['id']} is missing protected credentials" + ) + } + artifact_file = ( + Path(workspace_payload["workspace"]["local_path"]) / "gmail" / "acct-owner-001" / "msg-001.eml" + ) + assert not artifact_file.exists() + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + store = ContinuityStore(conn) + assert store.list_task_artifacts_for_task(owner["task_id"]) == [] + + +def test_gmail_message_ingestion_endpoint_rejects_missing_external_secret_without_side_effects( + migrated_database_urls, + monkeypatch, + tmp_path, +) -> None: + owner = seed_task(migrated_database_urls["app"], email="owner@example.com") + workspace_root = tmp_path / "task-workspaces" + gmail_secret_root = tmp_path / "gmail-secrets" + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + task_workspace_root=str(workspace_root), + gmail_secret_manager_url=_build_gmail_secret_manager_url(gmail_secret_root), + ), + ) + + monkeypatch.setattr( + gmail_module, + "fetch_gmail_message_raw_bytes", + lambda **_kwargs: (_ for _ in ()).throw( + AssertionError("fetch_gmail_message_raw_bytes should not be called") + ), + ) + + _, account_payload = _connect_gmail_account( + user_id=owner["user_id"], + provider_account_id="acct-owner-secret-missing-001", + email_address="owner@gmail.example", + ) + _, workspace_payload = invoke_request( + "POST", + f"/v0/tasks/{owner['task_id']}/workspace", + payload={"user_id": str(owner["user_id"])}, + ) + + with psycopg.connect(migrated_database_urls["admin"]) as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT secret_ref + FROM gmail_account_credentials + WHERE gmail_account_id = %s + """, + (UUID(account_payload["account"]["id"]),), + ) + secret_ref_row = cur.fetchone() + + assert secret_ref_row is not None + secret_path = gmail_secret_root / secret_ref_row[0] + secret_path.unlink() + + ingest_status, ingest_payload = invoke_request( + "POST", + f"/v0/gmail-accounts/{account_payload['account']['id']}/messages/msg-001/ingest", + payload={ + "user_id": str(owner["user_id"]), + "task_workspace_id": workspace_payload["workspace"]["id"], + }, + ) + + assert ingest_status == 409 + assert ingest_payload == { + "detail": ( + f"gmail account {account_payload['account']['id']} is missing protected credentials" + ) + } + artifact_file = ( + Path(workspace_payload["workspace"]["local_path"]) + / "gmail" + / "acct-owner-secret-missing-001" + / "msg-001.eml" + ) + assert not artifact_file.exists() + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + store = ContinuityStore(conn) + assert store.list_task_artifacts_for_task(owner["task_id"]) == [] + + +def test_gmail_message_ingestion_endpoint_rejects_invalid_refresh_credentials_without_side_effects( + migrated_database_urls, + monkeypatch, + tmp_path, +) -> None: + owner = seed_task(migrated_database_urls["app"], email="owner@example.com") + workspace_root = tmp_path / "task-workspaces" + gmail_secret_root = tmp_path / "gmail-secrets" + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + task_workspace_root=str(workspace_root), + gmail_secret_manager_url=_build_gmail_secret_manager_url(gmail_secret_root), + ), + ) + + _, account_payload = _connect_gmail_account( + user_id=owner["user_id"], + provider_account_id="acct-owner-refresh-001", + email_address="owner@gmail.example", + credential_overrides={ + "refresh_token": "refresh-owner-001", + "client_id": "client-owner-001", + "client_secret": "secret-owner-001", + "access_token_expires_at": "2020-01-01T00:00:00+00:00", + }, + ) + _, workspace_payload = invoke_request( + "POST", + f"/v0/tasks/{owner['task_id']}/workspace", + payload={"user_id": str(owner["user_id"])}, + ) + + def fail_refresh(**_kwargs): + raise gmail_module.GmailCredentialInvalidError( + f"gmail account {account_payload['account']['id']} refresh credentials were rejected" + ) + + monkeypatch.setattr(gmail_module, "refresh_gmail_access_token", fail_refresh) + monkeypatch.setattr( + gmail_module, + "fetch_gmail_message_raw_bytes", + lambda **_kwargs: (_ for _ in ()).throw( + AssertionError("fetch_gmail_message_raw_bytes should not be called") + ), + ) + + ingest_status, ingest_payload = invoke_request( + "POST", + f"/v0/gmail-accounts/{account_payload['account']['id']}/messages/msg-001/ingest", + payload={ + "user_id": str(owner["user_id"]), + "task_workspace_id": workspace_payload["workspace"]["id"], + }, + ) + + assert ingest_status == 409 + assert ingest_payload == { + "detail": ( + f"gmail account {account_payload['account']['id']} refresh credentials were rejected" + ) + } + artifact_file = ( + Path(workspace_payload["workspace"]["local_path"]) / "gmail" / "acct-owner-refresh-001" / "msg-001.eml" + ) + assert not artifact_file.exists() + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + store = ContinuityStore(conn) + assert store.list_task_artifacts_for_task(owner["task_id"]) == [] + + +def test_gmail_message_ingestion_endpoint_rejects_sanitized_path_collisions_without_overwrite( + migrated_database_urls, + monkeypatch, + tmp_path, +) -> None: + owner = seed_task(migrated_database_urls["app"], email="owner@example.com") + workspace_root = tmp_path / "task-workspaces" + gmail_secret_root = tmp_path / "gmail-secrets" + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + task_workspace_root=str(workspace_root), + gmail_secret_manager_url=_build_gmail_secret_manager_url(gmail_secret_root), + ), + ) + first_bytes = _build_rfc822_email_bytes(subject="First", plain_body="first message body") + second_bytes = _build_rfc822_email_bytes(subject="Second", plain_body="second message body") + + def fake_fetch_gmail_message_raw_bytes(*, provider_message_id: str, **_kwargs) -> bytes: + if provider_message_id == "msg+001": + return first_bytes + if provider_message_id == "msg:001": + return second_bytes + raise AssertionError(f"unexpected provider_message_id: {provider_message_id}") + + monkeypatch.setattr( + gmail_module, + "fetch_gmail_message_raw_bytes", + fake_fetch_gmail_message_raw_bytes, + ) + + _, account_payload = _connect_gmail_account( + user_id=owner["user_id"], + provider_account_id="acct-owner-001", + email_address="owner@gmail.example", + ) + _, workspace_payload = invoke_request( + "POST", + f"/v0/tasks/{owner['task_id']}/workspace", + payload={"user_id": str(owner["user_id"])}, + ) + first_ingest_status, first_ingest_payload = invoke_request( + "POST", + f"/v0/gmail-accounts/{account_payload['account']['id']}/messages/msg+001/ingest", + payload={ + "user_id": str(owner["user_id"]), + "task_workspace_id": workspace_payload["workspace"]["id"], + }, + ) + second_ingest_status, second_ingest_payload = invoke_request( + "POST", + f"/v0/gmail-accounts/{account_payload['account']['id']}/messages/msg:001/ingest", + payload={ + "user_id": str(owner["user_id"]), + "task_workspace_id": workspace_payload["workspace"]["id"], + }, + ) + + artifact_file = ( + Path(workspace_payload["workspace"]["local_path"]) / "gmail" / "acct-owner-001" / "msg_001.eml" + ) + + assert first_ingest_status == 200 + assert second_ingest_status == 409 + assert second_ingest_payload == { + "detail": ( + "artifact gmail/acct-owner-001/msg_001.eml is already registered for task workspace " + f"{workspace_payload['workspace']['id']}" + ) + } + assert artifact_file.read_bytes() == first_bytes + assert first_ingest_payload["artifact"]["relative_path"] == "gmail/acct-owner-001/msg_001.eml" + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + store = ContinuityStore(conn) + artifact_rows = store.list_task_artifacts_for_task(owner["task_id"]) + assert len(artifact_rows) == 1 + assert artifact_rows[0]["relative_path"] == "gmail/acct-owner-001/msg_001.eml" + + +def test_gmail_message_ingestion_endpoint_rejects_missing_and_unsupported_messages( + migrated_database_urls, + monkeypatch, + tmp_path, +) -> None: + owner = seed_task(migrated_database_urls["app"], email="owner@example.com") + workspace_root = tmp_path / "task-workspaces" + gmail_secret_root = tmp_path / "gmail-secrets" + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + task_workspace_root=str(workspace_root), + gmail_secret_manager_url=_build_gmail_secret_manager_url(gmail_secret_root), + ), + ) + + _, account_payload = _connect_gmail_account( + user_id=owner["user_id"], + provider_account_id="acct-owner-001", + email_address="owner@gmail.example", + ) + _, workspace_payload = invoke_request( + "POST", + f"/v0/tasks/{owner['task_id']}/workspace", + payload={"user_id": str(owner["user_id"])}, + ) + + def fake_missing(**_kwargs): + raise gmail_module.GmailMessageNotFoundError("gmail message msg-missing was not found") + + monkeypatch.setattr(gmail_module, "fetch_gmail_message_raw_bytes", fake_missing) + missing_status, missing_payload = invoke_request( + "POST", + f"/v0/gmail-accounts/{account_payload['account']['id']}/messages/msg-missing/ingest", + payload={ + "user_id": str(owner["user_id"]), + "task_workspace_id": workspace_payload["workspace"]["id"], + }, + ) + + monkeypatch.setattr( + gmail_module, + "fetch_gmail_message_raw_bytes", + lambda **_kwargs: b"not-a-valid-rfc822-email", + ) + unsupported_status, unsupported_payload = invoke_request( + "POST", + f"/v0/gmail-accounts/{account_payload['account']['id']}/messages/msg-unsupported/ingest", + payload={ + "user_id": str(owner["user_id"]), + "task_workspace_id": workspace_payload["workspace"]["id"], + }, + ) + + assert missing_status == 404 + assert missing_payload == {"detail": "gmail message msg-missing was not found"} + assert unsupported_status == 400 + assert unsupported_payload == { + "detail": "gmail message msg-unsupported is not a supported RFC822 email" + } + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + store = ContinuityStore(conn) + assert store.list_task_artifacts_for_task(owner["task_id"]) == [] diff --git a/tests/integration/test_healthcheck.py b/tests/integration/test_healthcheck.py new file mode 100644 index 0000000..47801f1 --- /dev/null +++ b/tests/integration/test_healthcheck.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +import socket +import subprocess +import time +from urllib import error, request + +import anyio + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings + + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def invoke_healthcheck() -> tuple[int, dict[str, object]]: + messages: list[dict[str, object]] = [] + + async def receive() -> dict[str, object]: + return {"type": "http.request", "body": b"", "more_body": False} + + async def send(message: dict[str, object]) -> None: + messages.append(message) + + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": "GET", + "scheme": "http", + "path": "/healthz", + "raw_path": b"/healthz", + "query_string": b"", + "headers": [], + "client": ("testclient", 50000), + "server": ("testserver", 80), + "root_path": "", + } + + anyio.run(main_module.app, scope, receive, send) + + start_message = next(message for message in messages if message["type"] == "http.response.start") + body = b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + return start_message["status"], json.loads(body) + + +def test_healthcheck_endpoint_returns_ok_response(monkeypatch) -> None: + settings = Settings( + app_env="test", + database_url="postgresql://db", + redis_url="redis://alicebot:supersecret@cache:6379/0", + s3_endpoint_url="http://object-store", + healthcheck_timeout_seconds=2, + ) + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "ping_database", lambda *_args, **_kwargs: True) + + status_code, payload = invoke_healthcheck() + + assert status_code == 200 + assert payload["status"] == "ok" + assert payload["services"]["database"]["status"] == "ok" + assert payload["services"]["redis"]["status"] == "not_checked" + assert payload["services"]["redis"]["url"] == "redis://cache:6379/0" + assert payload["services"]["object_storage"]["status"] == "not_checked" + + +def test_healthcheck_endpoint_returns_degraded_response(monkeypatch) -> None: + settings = Settings( + app_env="test", + database_url="postgresql://db", + redis_url="redis://alicebot:supersecret@cache:6379/0", + s3_endpoint_url="http://object-store", + healthcheck_timeout_seconds=2, + ) + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "ping_database", lambda *_args, **_kwargs: False) + + status_code, payload = invoke_healthcheck() + + assert status_code == 503 + assert payload["status"] == "degraded" + assert payload["services"]["database"]["status"] == "unreachable" + assert payload["services"]["redis"]["status"] == "not_checked" + assert payload["services"]["redis"]["url"] == "redis://cache:6379/0" + assert payload["services"]["object_storage"]["status"] == "not_checked" + + +def test_api_dev_script_serves_live_healthcheck() -> None: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + + env = os.environ.copy() + env.update( + { + "APP_HOST": "127.0.0.1", + "APP_PORT": str(port), + "APP_RELOAD": "false", + "APP_ENV": "test", + "DATABASE_URL": "postgresql://invalid:invalid@127.0.0.1:1/invalid", + "REDIS_URL": "redis://alicebot:supersecret@localhost:6379/0", + "HEALTHCHECK_TIMEOUT_SECONDS": "1", + } + ) + + process = subprocess.Popen( + ["/bin/bash", str(REPO_ROOT / "scripts" / "api_dev.sh")], + cwd=REPO_ROOT, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + payload: dict[str, object] | None = None + status_code: int | None = None + + try: + deadline = time.time() + 15 + url = f"http://127.0.0.1:{port}/healthz" + + while time.time() < deadline: + if process.poll() is not None: + stdout, stderr = process.communicate(timeout=1) + raise AssertionError( + "api_dev.sh exited before serving /healthz\n" + f"stdout:\n{stdout}\n" + f"stderr:\n{stderr}" + ) + + try: + with request.urlopen(url, timeout=0.5) as response: + status_code = response.status + payload = json.loads(response.read()) + break + except error.HTTPError as exc: + status_code = exc.code + payload = json.loads(exc.read()) + break + except OSError: + time.sleep(0.1) + else: + raise AssertionError("Timed out waiting for api_dev.sh to serve /healthz") + finally: + process.terminate() + try: + process.communicate(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.communicate(timeout=5) + + assert status_code == 503 + assert payload == { + "status": "degraded", + "environment": "test", + "services": { + "database": {"status": "unreachable"}, + "redis": {"status": "not_checked", "url": "redis://localhost:6379/0"}, + "object_storage": { + "status": "not_checked", + "endpoint_url": "http://localhost:9000", + }, + }, + } diff --git a/tests/integration/test_http_security_posture.py b/tests/integration/test_http_security_posture.py new file mode 100644 index 0000000..c4474ee --- /dev/null +++ b/tests/integration/test_http_security_posture.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +import json + +import anyio + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings + + +def invoke_request( + method: str, + path: str, + *, + scheme: str = "http", + headers: dict[str, str] | None = None, +) -> tuple[int, dict[str, str], bytes]: + messages: list[dict[str, object]] = [] + request_received = False + + async def receive() -> dict[str, object]: + nonlocal request_received + if request_received: + return {"type": "http.disconnect"} + request_received = True + return {"type": "http.request", "body": b"", "more_body": False} + + async def send(message: dict[str, object]) -> None: + messages.append(message) + + request_headers = [(b"content-type", b"application/json")] + for key, value in (headers or {}).items(): + request_headers.append((key.lower().encode("utf-8"), value.encode("utf-8"))) + + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": method, + "scheme": scheme, + "path": path, + "raw_path": path.encode("utf-8"), + "query_string": b"", + "headers": request_headers, + "client": ("testclient", 50000), + "server": ("testserver", 80), + "root_path": "", + } + + anyio.run(main_module.app, scope, receive, send) + + start_message = next(message for message in messages if message["type"] == "http.response.start") + body = b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + response_headers = { + key.decode("utf-8").lower(): value.decode("utf-8") + for key, value in start_message["headers"] + } + return start_message["status"], response_headers, body + + +def test_security_headers_are_applied_to_api_responses(monkeypatch) -> None: + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + app_env="test", + database_url="postgresql://db", + redis_url="redis://localhost:6379/0", + s3_endpoint_url="http://localhost:9000", + ), + ) + monkeypatch.setattr(main_module, "ping_database", lambda *_args, **_kwargs: True) + + status_code, headers, body = invoke_request("GET", "/healthz") + + assert status_code == 200 + assert json.loads(body)["status"] == "ok" + assert headers["x-content-type-options"] == "nosniff" + assert headers["x-frame-options"] == "DENY" + assert headers["referrer-policy"] == "no-referrer" + assert "permissions-policy" in headers + assert "strict-transport-security" not in headers + + +def test_security_headers_include_hsts_for_https_outside_dev(monkeypatch) -> None: + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + app_env="staging", + database_url="postgresql://db", + security_headers_hsts_max_age_seconds=86_400, + security_headers_hsts_include_subdomains=True, + ), + ) + monkeypatch.setattr(main_module, "ping_database", lambda *_args, **_kwargs: True) + + status_code, headers, _body = invoke_request("GET", "/healthz", scheme="https") + + assert status_code == 200 + assert headers["strict-transport-security"] == "max-age=86400; includeSubDomains" + + +def test_cors_preflight_allows_configured_origin(monkeypatch) -> None: + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + app_env="test", + database_url="postgresql://db", + cors_allowed_origins=("https://app.example.com",), + cors_allowed_methods=("GET", "POST", "OPTIONS"), + cors_allowed_headers=("Authorization", "Content-Type"), + cors_allow_credentials=True, + cors_preflight_max_age_seconds=900, + ), + ) + + status_code, headers, body = invoke_request( + "OPTIONS", + "/healthz", + headers={ + "origin": "https://app.example.com", + "access-control-request-method": "GET", + "access-control-request-headers": "authorization,content-type", + }, + ) + + assert status_code == 204 + assert body == b"" + assert headers["access-control-allow-origin"] == "https://app.example.com" + assert headers["access-control-allow-methods"] == "GET, POST, OPTIONS" + assert headers["access-control-allow-headers"] == "Authorization, Content-Type" + assert headers["access-control-allow-credentials"] == "true" + assert headers["access-control-max-age"] == "900" + + +def test_cors_preflight_rejects_disallowed_origin(monkeypatch) -> None: + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + app_env="test", + database_url="postgresql://db", + cors_allowed_origins=("https://app.example.com",), + ), + ) + + status_code, headers, body = invoke_request( + "OPTIONS", + "/healthz", + headers={ + "origin": "https://evil.example.com", + "access-control-request-method": "GET", + }, + ) + + assert status_code == 403 + assert json.loads(body) == {"detail": "CORS origin is not allowed"} + assert headers["x-content-type-options"] == "nosniff" diff --git a/tests/integration/test_markdown_import.py b/tests/integration/test_markdown_import.py new file mode 100644 index 0000000..615803a --- /dev/null +++ b/tests/integration/test_markdown_import.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from pathlib import Path +from uuid import UUID, uuid4 + +from alicebot_api.continuity_recall import query_continuity_recall +from alicebot_api.continuity_resumption import compile_continuity_resumption_brief +from alicebot_api.contracts import ContinuityRecallQueryInput, ContinuityResumptionBriefRequestInput +from alicebot_api.db import user_connection +from alicebot_api.markdown_import import import_markdown_source +from alicebot_api.store import ContinuityStore + + +REPO_ROOT = Path(__file__).resolve().parents[2] +MARKDOWN_FIXTURE_PATH = REPO_ROOT / "fixtures" / "importers" / "markdown" / "workspace_v1.md" +THREAD_ID = UUID("eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee") + + +def seed_user(database_url: str, *, email: str) -> UUID: + user_id = uuid4() + with user_connection(database_url, user_id) as conn: + ContinuityStore(conn).create_user(user_id, email, email.split("@", 1)[0].title()) + return user_id + + +def test_markdown_import_supports_recall_resumption_and_idempotent_dedupe(migrated_database_urls) -> None: + user_id = seed_user(migrated_database_urls["app"], email="markdown-import@example.com") + + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + + first_import = import_markdown_source( + store, + user_id=user_id, + source=MARKDOWN_FIXTURE_PATH, + ) + + assert first_import["status"] == "ok" + assert first_import["fixture_id"] == "markdown-s37-workspace-v1" + assert first_import["workspace_id"] == "markdown-workspace-demo-001" + assert first_import["total_candidates"] == 5 + assert first_import["imported_count"] == 4 + assert first_import["skipped_duplicates"] == 1 + assert first_import["provenance_source_kind"] == "markdown_import" + + recall = query_continuity_recall( + store, + user_id=user_id, + request=ContinuityRecallQueryInput( + thread_id=THREAD_ID, + project="Markdown Import Project", + query="markdown importer deterministic", + limit=20, + ), + ) + + assert recall["summary"]["returned_count"] == 4 + assert all(item["provenance"]["source_kind"] == "markdown_import" for item in recall["items"]) + assert all( + item["provenance"].get("markdown_workspace_id") == "markdown-workspace-demo-001" + for item in recall["items"] + ) + + resumption = compile_continuity_resumption_brief( + store, + user_id=user_id, + request=ContinuityResumptionBriefRequestInput( + thread_id=THREAD_ID, + project="Markdown Import Project", + query="markdown importer deterministic", + max_recent_changes=10, + max_open_loops=10, + ), + ) + + brief = resumption["brief"] + assert brief["last_decision"]["item"] is not None + assert brief["last_decision"]["item"]["provenance"]["source_kind"] == "markdown_import" + assert brief["next_action"]["item"] is not None + assert brief["next_action"]["item"]["provenance"]["source_kind"] == "markdown_import" + + second_import = import_markdown_source( + store, + user_id=user_id, + source=MARKDOWN_FIXTURE_PATH, + ) + + assert second_import["status"] == "noop" + assert second_import["total_candidates"] == 5 + assert second_import["imported_count"] == 0 + assert second_import["skipped_duplicates"] == 5 diff --git a/tests/integration/test_mcp_cli_parity.py b/tests/integration/test_mcp_cli_parity.py new file mode 100644 index 0000000..c04ac17 --- /dev/null +++ b/tests/integration/test_mcp_cli_parity.py @@ -0,0 +1,273 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime +import json +import os +from pathlib import Path +import subprocess +import sys +from typing import Any +from uuid import UUID, uuid4 + +import psycopg + +from alicebot_api.continuity_recall import query_continuity_recall +from alicebot_api.continuity_resumption import compile_continuity_resumption_brief +from alicebot_api.contracts import ContinuityRecallQueryInput, ContinuityResumptionBriefRequestInput +from alicebot_api.db import user_connection +from alicebot_api.store import ContinuityStore + + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def seed_user(database_url: str, *, email: str) -> UUID: + user_id = uuid4() + with user_connection(database_url, user_id) as conn: + ContinuityStore(conn).create_user(user_id, email, email.split("@", 1)[0].title()) + return user_id + + +def set_continuity_timestamps( + admin_database_url: str, + *, + continuity_object_id: UUID, + created_at: datetime, +) -> None: + with psycopg.connect(admin_database_url, autocommit=True) as conn: + with conn.cursor() as cur: + cur.execute( + "UPDATE continuity_objects SET created_at = %s, updated_at = %s WHERE id = %s", + (created_at, created_at, continuity_object_id), + ) + + +def build_runtime_env(*, database_url: str, user_id: UUID) -> dict[str, str]: + env = os.environ.copy() + env["DATABASE_URL"] = database_url + env["ALICEBOT_AUTH_USER_ID"] = str(user_id) + pythonpath_entries = [str(REPO_ROOT / "apps" / "api" / "src"), str(REPO_ROOT / "workers")] + existing_pythonpath = env.get("PYTHONPATH") + if existing_pythonpath: + pythonpath_entries.append(existing_pythonpath) + env["PYTHONPATH"] = os.pathsep.join(pythonpath_entries) + return env + + +def run_cli(args: list[str], *, env: dict[str, str]) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, "-m", "alicebot_api", *args], + cwd=REPO_ROOT, + env=env, + check=False, + capture_output=True, + text=True, + ) + + +def _write_mcp_message(stream, payload: dict[str, object]) -> None: + encoded = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8") + stream.write(f"Content-Length: {len(encoded)}\r\n\r\n".encode("ascii")) + stream.write(encoded) + stream.flush() + + +def _read_mcp_message(stream) -> dict[str, object]: + headers: dict[str, str] = {} + while True: + line = stream.readline() + if line == b"": + raise RuntimeError("MCP server closed stdout unexpectedly") + if line in {b"\r\n", b"\n"}: + break + decoded = line.decode("utf-8").strip() + key, value = decoded.split(":", 1) + headers[key.strip().lower()] = value.strip() + + content_length = int(headers["content-length"]) + body = stream.read(content_length) + return json.loads(body.decode("utf-8")) + + +@dataclass +class MCPClient: + process: subprocess.Popen[bytes] + _next_id: int = 1 + + def request(self, method: str, params: dict[str, object] | None = None) -> dict[str, object]: + request_id = self._next_id + self._next_id += 1 + payload: dict[str, object] = {"jsonrpc": "2.0", "id": request_id, "method": method} + if params is not None: + payload["params"] = params + _write_mcp_message(self.process.stdin, payload) + response = _read_mcp_message(self.process.stdout) + assert response.get("id") == request_id + return response + + def notify(self, method: str, params: dict[str, object] | None = None) -> None: + payload: dict[str, object] = {"jsonrpc": "2.0", "method": method} + if params is not None: + payload["params"] = params + _write_mcp_message(self.process.stdin, payload) + + def close(self) -> None: + if self.process.poll() is None: + self.process.terminate() + try: + self.process.wait(timeout=5) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.wait(timeout=5) + + +def start_mcp_client(*, database_url: str, user_id: UUID) -> MCPClient: + env = build_runtime_env(database_url=database_url, user_id=user_id) + process = subprocess.Popen( + [sys.executable, "-m", "alicebot_api.mcp_server"], + cwd=REPO_ROOT, + env=env, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=False, + ) + assert process.stdin is not None + assert process.stdout is not None + + client = MCPClient(process=process) + initialize = client.request( + "initialize", + params={ + "protocolVersion": "2024-11-05", + "clientInfo": {"name": "pytest-mcp-client", "version": "1.0"}, + "capabilities": {}, + }, + ) + assert initialize["result"]["protocolVersion"] == "2024-11-05" + client.notify("notifications/initialized", {}) + return client + + +def _call_tool(client: MCPClient, *, name: str, arguments: dict[str, object]) -> dict[str, Any]: + response = client.request("tools/call", params={"name": name, "arguments": arguments}) + assert "error" not in response + result = response["result"] + assert result["isError"] is False + return result["structuredContent"] + + +def test_mcp_recall_and_resume_match_core_and_cli_behavior(migrated_database_urls) -> None: + user_id = seed_user(migrated_database_urls["app"], email="mcp-parity@example.com") + thread_id = UUID("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") + + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + + decision_capture = store.create_continuity_capture_event( + raw_content="Decision: Keep release freeze", + explicit_signal="decision", + admission_posture="DERIVED", + admission_reason="explicit_signal_decision", + ) + decision_object = store.create_continuity_object( + capture_event_id=decision_capture["id"], + object_type="Decision", + status="active", + title="Decision: Keep release freeze", + body={"decision_text": "Keep release freeze"}, + provenance={"thread_id": str(thread_id), "source_event_ids": ["mcp-parity-1"]}, + confidence=0.96, + ) + + next_action_capture = store.create_continuity_capture_event( + raw_content="Next Action: Draft release memo", + explicit_signal="next_action", + admission_posture="DERIVED", + admission_reason="explicit_signal_next_action", + ) + next_action_object = store.create_continuity_object( + capture_event_id=next_action_capture["id"], + object_type="NextAction", + status="active", + title="Next Action: Draft release memo", + body={"action_text": "Draft release memo"}, + provenance={"thread_id": str(thread_id), "source_event_ids": ["mcp-parity-2"]}, + confidence=0.92, + ) + + set_continuity_timestamps( + migrated_database_urls["admin"], + continuity_object_id=decision_object["id"], + created_at=datetime(2026, 4, 2, 9, 0, tzinfo=UTC), + ) + set_continuity_timestamps( + migrated_database_urls["admin"], + continuity_object_id=next_action_object["id"], + created_at=datetime(2026, 4, 2, 9, 5, tzinfo=UTC), + ) + + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + core_recall = query_continuity_recall( + store, + user_id=user_id, + request=ContinuityRecallQueryInput( + thread_id=thread_id, + query="release", + limit=20, + ), + ) + core_resume = compile_continuity_resumption_brief( + store, + user_id=user_id, + request=ContinuityResumptionBriefRequestInput( + thread_id=thread_id, + max_recent_changes=5, + max_open_loops=5, + ), + ) + + client = start_mcp_client(database_url=migrated_database_urls["app"], user_id=user_id) + try: + mcp_recall = _call_tool( + client, + name="alice_recall", + arguments={ + "thread_id": str(thread_id), + "query": "release", + "limit": 20, + }, + ) + mcp_resume = _call_tool( + client, + name="alice_resume", + arguments={ + "thread_id": str(thread_id), + "max_recent_changes": 5, + "max_open_loops": 5, + }, + ) + finally: + client.close() + + assert mcp_recall == core_recall + assert mcp_resume == core_resume + + env = build_runtime_env(database_url=migrated_database_urls["app"], user_id=user_id) + cli_recall = run_cli( + ["recall", "--thread-id", str(thread_id), "--query", "release", "--limit", "20"], + env=env, + ) + assert cli_recall.returncode == 0 + assert core_recall["items"][0]["title"] in cli_recall.stdout + assert core_recall["items"][0]["id"] in cli_recall.stdout + + cli_resume = run_cli( + ["resume", "--thread-id", str(thread_id), "--max-recent-changes", "5", "--max-open-loops", "5"], + env=env, + ) + assert cli_resume.returncode == 0 + assert core_resume["brief"]["last_decision"]["item"]["title"] in cli_resume.stdout + assert core_resume["brief"]["next_action"]["item"]["title"] in cli_resume.stdout diff --git a/tests/integration/test_mcp_server.py b/tests/integration/test_mcp_server.py new file mode 100644 index 0000000..e8df32f --- /dev/null +++ b/tests/integration/test_mcp_server.py @@ -0,0 +1,295 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime +import json +import os +from pathlib import Path +import subprocess +import sys +from typing import Any +from uuid import UUID, uuid4 + +import psycopg + +from alicebot_api.db import user_connection +from alicebot_api.store import ContinuityStore + + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def seed_user(database_url: str, *, email: str) -> UUID: + user_id = uuid4() + with user_connection(database_url, user_id) as conn: + ContinuityStore(conn).create_user(user_id, email, email.split("@", 1)[0].title()) + return user_id + + +def set_continuity_timestamps( + admin_database_url: str, + *, + continuity_object_id: UUID, + created_at: datetime, +) -> None: + with psycopg.connect(admin_database_url, autocommit=True) as conn: + with conn.cursor() as cur: + cur.execute( + "UPDATE continuity_objects SET created_at = %s, updated_at = %s WHERE id = %s", + (created_at, created_at, continuity_object_id), + ) + + +def build_runtime_env(*, database_url: str, user_id: UUID) -> dict[str, str]: + env = os.environ.copy() + env["DATABASE_URL"] = database_url + env["ALICEBOT_AUTH_USER_ID"] = str(user_id) + + pythonpath_entries = [str(REPO_ROOT / "apps" / "api" / "src"), str(REPO_ROOT / "workers")] + existing_pythonpath = env.get("PYTHONPATH") + if existing_pythonpath: + pythonpath_entries.append(existing_pythonpath) + env["PYTHONPATH"] = os.pathsep.join(pythonpath_entries) + return env + + +def _write_mcp_message(stream, payload: dict[str, object]) -> None: + encoded = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8") + stream.write(f"Content-Length: {len(encoded)}\r\n\r\n".encode("ascii")) + stream.write(encoded) + stream.flush() + + +def _read_mcp_message(stream) -> dict[str, object]: + headers: dict[str, str] = {} + while True: + line = stream.readline() + if line == b"": + raise RuntimeError("MCP server closed stdout unexpectedly") + if line in {b"\r\n", b"\n"}: + break + decoded = line.decode("utf-8").strip() + key, value = decoded.split(":", 1) + headers[key.strip().lower()] = value.strip() + + content_length = int(headers["content-length"]) + body = stream.read(content_length) + return json.loads(body.decode("utf-8")) + + +@dataclass +class MCPClient: + process: subprocess.Popen[bytes] + _next_id: int = 1 + + def request(self, method: str, params: dict[str, object] | None = None) -> dict[str, object]: + request_id = self._next_id + self._next_id += 1 + payload: dict[str, object] = { + "jsonrpc": "2.0", + "id": request_id, + "method": method, + } + if params is not None: + payload["params"] = params + + _write_mcp_message(self.process.stdin, payload) + response = _read_mcp_message(self.process.stdout) + assert response.get("id") == request_id + return response + + def notify(self, method: str, params: dict[str, object] | None = None) -> None: + payload: dict[str, object] = { + "jsonrpc": "2.0", + "method": method, + } + if params is not None: + payload["params"] = params + _write_mcp_message(self.process.stdin, payload) + + def close(self) -> None: + if self.process.poll() is None: + self.process.terminate() + try: + self.process.wait(timeout=5) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.wait(timeout=5) + + +def start_mcp_client(*, database_url: str, user_id: UUID) -> MCPClient: + env = build_runtime_env(database_url=database_url, user_id=user_id) + process = subprocess.Popen( + [sys.executable, "-m", "alicebot_api.mcp_server"], + cwd=REPO_ROOT, + env=env, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=False, + ) + assert process.stdin is not None + assert process.stdout is not None + + client = MCPClient(process=process) + initialize = client.request( + "initialize", + params={ + "protocolVersion": "2024-11-05", + "clientInfo": {"name": "pytest-mcp-client", "version": "1.0"}, + "capabilities": {}, + }, + ) + assert initialize["result"]["protocolVersion"] == "2024-11-05" + client.notify("notifications/initialized", {}) + return client + + +def _call_tool(client: MCPClient, *, name: str, arguments: dict[str, object]) -> dict[str, object]: + response = client.request("tools/call", params={"name": name, "arguments": arguments}) + assert "error" not in response + return response["result"] + + +def test_mcp_server_tool_calls_and_correction_flow(migrated_database_urls) -> None: + user_id = seed_user(migrated_database_urls["app"], email="mcp-user@example.com") + thread_id = UUID("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") + + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + legacy_capture = store.create_continuity_capture_event( + raw_content="Decision: Legacy rollout plan", + explicit_signal="decision", + admission_posture="DERIVED", + admission_reason="explicit_signal_decision", + ) + legacy_decision = store.create_continuity_object( + capture_event_id=legacy_capture["id"], + object_type="Decision", + status="active", + title="Decision: Legacy rollout plan", + body={"decision_text": "Legacy rollout plan"}, + provenance={"thread_id": str(thread_id), "source_event_ids": ["mcp-seed-1"]}, + confidence=0.93, + ) + + waiting_capture = store.create_continuity_capture_event( + raw_content="Waiting For: Reviewer PASS", + explicit_signal="waiting_for", + admission_posture="DERIVED", + admission_reason="explicit_signal_waiting_for", + ) + waiting_for = store.create_continuity_object( + capture_event_id=waiting_capture["id"], + object_type="WaitingFor", + status="active", + title="Waiting For: Reviewer PASS", + body={"waiting_for_text": "Reviewer PASS"}, + provenance={"thread_id": str(thread_id), "source_event_ids": ["mcp-seed-2"]}, + confidence=0.9, + ) + + set_continuity_timestamps( + migrated_database_urls["admin"], + continuity_object_id=legacy_decision["id"], + created_at=datetime(2026, 4, 1, 10, 0, tzinfo=UTC), + ) + set_continuity_timestamps( + migrated_database_urls["admin"], + continuity_object_id=waiting_for["id"], + created_at=datetime(2026, 4, 1, 10, 5, tzinfo=UTC), + ) + + client = start_mcp_client(database_url=migrated_database_urls["app"], user_id=user_id) + try: + tools_list = client.request("tools/list") + tool_names = [tool["name"] for tool in tools_list["result"]["tools"]] + assert "alice_recall" in tool_names + assert "alice_resume" in tool_names + assert "alice_open_loops" in tool_names + assert "alice_memory_correct" in tool_names + + recall_before = _call_tool( + client, + name="alice_recall", + arguments={ + "thread_id": str(thread_id), + "query": "rollout", + "limit": 20, + }, + ) + assert recall_before["isError"] is False + before_payload = recall_before["structuredContent"] + assert before_payload["items"][0]["id"] == str(legacy_decision["id"]) + + resume_before = _call_tool( + client, + name="alice_resume", + arguments={ + "thread_id": str(thread_id), + "max_recent_changes": 5, + "max_open_loops": 5, + }, + ) + assert resume_before["isError"] is False + assert resume_before["structuredContent"]["brief"]["last_decision"]["item"]["id"] == str(legacy_decision["id"]) + + open_loops = _call_tool( + client, + name="alice_open_loops", + arguments={ + "thread_id": str(thread_id), + "limit": 20, + }, + ) + assert open_loops["isError"] is False + open_loop_dashboard = open_loops["structuredContent"]["dashboard"] + assert open_loop_dashboard["summary"]["total_count"] == 1 + assert open_loop_dashboard["waiting_for"]["items"][0]["id"] == str(waiting_for["id"]) + + correction = _call_tool( + client, + name="alice_memory_correct", + arguments={ + "continuity_object_id": str(legacy_decision["id"]), + "action": "supersede", + "reason": "Latest rollout decision supersedes legacy plan", + "replacement_title": "Decision: Updated rollout plan", + "replacement_body": {"decision_text": "Updated rollout plan"}, + "replacement_provenance": { + "thread_id": str(thread_id), + "source_event_ids": ["mcp-correction-1"], + }, + "replacement_confidence": 0.98, + }, + ) + assert correction["isError"] is False + replacement_id = correction["structuredContent"]["replacement_object"]["id"] + + recall_after = _call_tool( + client, + name="alice_recall", + arguments={ + "thread_id": str(thread_id), + "query": "rollout", + "limit": 20, + }, + ) + assert recall_after["isError"] is False + after_payload = recall_after["structuredContent"] + assert after_payload["items"][0]["id"] == replacement_id + assert any(item["id"] == str(legacy_decision["id"]) for item in after_payload["items"]) + + resume_after = _call_tool( + client, + name="alice_resume", + arguments={ + "thread_id": str(thread_id), + "max_recent_changes": 5, + "max_open_loops": 5, + }, + ) + assert resume_after["isError"] is False + assert resume_after["structuredContent"]["brief"]["last_decision"]["item"]["id"] == replacement_id + finally: + client.close() diff --git a/tests/integration/test_memory_admission.py b/tests/integration/test_memory_admission.py new file mode 100644 index 0000000..8a4fade --- /dev/null +++ b/tests/integration/test_memory_admission.py @@ -0,0 +1,286 @@ +from __future__ import annotations + +import json +from typing import Any +from uuid import UUID, uuid4 + +import anyio +import psycopg +import pytest + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.db import user_connection +from alicebot_api.store import ContinuityStore + + +def invoke_admit_memory(payload: dict[str, Any]) -> tuple[int, dict[str, Any]]: + messages: list[dict[str, object]] = [] + encoded_body = json.dumps(payload).encode() + request_received = False + + async def receive() -> dict[str, object]: + nonlocal request_received + if request_received: + return {"type": "http.disconnect"} + + request_received = True + return {"type": "http.request", "body": encoded_body, "more_body": False} + + async def send(message: dict[str, object]) -> None: + messages.append(message) + + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": "POST", + "scheme": "http", + "path": "/v0/memories/admit", + "raw_path": b"/v0/memories/admit", + "query_string": b"", + "headers": [(b"content-type", b"application/json")], + "client": ("testclient", 50000), + "server": ("testserver", 80), + "root_path": "", + } + + anyio.run(main_module.app, scope, receive, send) + + start_message = next(message for message in messages if message["type"] == "http.response.start") + body = b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + return start_message["status"], json.loads(body) + + +def seed_memory_evidence(database_url: str) -> tuple[UUID, list[UUID]]: + user_id = uuid4() + + with user_connection(database_url, user_id) as conn: + store = ContinuityStore(conn) + store.create_user(user_id, "owner@example.com", "Owner") + thread = store.create_thread("Memory thread") + session = store.create_session(thread["id"], status="active") + event_ids = [ + store.append_event(thread["id"], session["id"], "message.user", {"text": "likes black coffee"})["id"], + store.append_event(thread["id"], session["id"], "message.user", {"text": "actually likes oat milk"})["id"], + store.append_event(thread["id"], session["id"], "message.user", {"text": "stop remembering coffee"})["id"], + ] + + return user_id, event_ids + + +def test_admit_memory_endpoint_returns_noop_and_persists_nothing_without_value( + migrated_database_urls, + monkeypatch, +) -> None: + user_id, event_ids = seed_memory_evidence(migrated_database_urls["app"]) + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + status_code, payload = invoke_admit_memory( + { + "user_id": str(user_id), + "memory_key": "user.preference.coffee", + "value": None, + "source_event_ids": [str(event_ids[0])], + } + ) + + assert status_code == 200 + assert payload == { + "decision": "NOOP", + "reason": "candidate_value_missing", + "memory": None, + "revision": None, + } + + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + assert store.list_memories() == [] + + +def test_admit_memory_endpoint_rejects_unknown_source_events(migrated_database_urls, monkeypatch) -> None: + user_id = uuid4() + + with user_connection(migrated_database_urls["app"], user_id) as conn: + ContinuityStore(conn).create_user(user_id, "owner@example.com", "Owner") + + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + status_code, payload = invoke_admit_memory( + { + "user_id": str(user_id), + "memory_key": "user.preference.coffee", + "value": {"likes": "black"}, + "source_event_ids": [str(uuid4())], + } + ) + + assert status_code == 400 + assert payload["detail"].startswith( + "source_event_ids must all reference existing events owned by the user" + ) + + +def test_admit_memory_endpoint_rejects_invalid_memory_type(migrated_database_urls, monkeypatch) -> None: + user_id, event_ids = seed_memory_evidence(migrated_database_urls["app"]) + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + status_code, payload = invoke_admit_memory( + { + "user_id": str(user_id), + "memory_key": "user.preference.coffee", + "value": {"likes": "black"}, + "source_event_ids": [str(event_ids[0])], + "memory_type": "unknown_type", + } + ) + + assert status_code == 400 + assert payload == { + "detail": ( + "memory_type must be one of: preference, identity_fact, relationship_fact, project_fact, " + "decision, commitment, routine, constraint, working_style" + ) + } + + +def test_admit_memory_endpoint_persists_add_update_and_delete_revisions( + migrated_database_urls, + monkeypatch, +) -> None: + user_id, event_ids = seed_memory_evidence(migrated_database_urls["app"]) + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + add_status, add_payload = invoke_admit_memory( + { + "user_id": str(user_id), + "memory_key": "user.preference.coffee", + "value": {"likes": "black"}, + "source_event_ids": [str(event_ids[0])], + } + ) + update_status, update_payload = invoke_admit_memory( + { + "user_id": str(user_id), + "memory_key": "user.preference.coffee", + "value": {"likes": "oat milk"}, + "source_event_ids": [str(event_ids[1])], + } + ) + delete_status, delete_payload = invoke_admit_memory( + { + "user_id": str(user_id), + "memory_key": "user.preference.coffee", + "value": None, + "source_event_ids": [str(event_ids[2])], + "delete_requested": True, + } + ) + + assert add_status == 200 + assert add_payload["decision"] == "ADD" + assert update_status == 200 + assert update_payload["decision"] == "UPDATE" + assert delete_status == 200 + assert delete_payload["decision"] == "DELETE" + + memory_id = UUID(delete_payload["memory"]["id"]) + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + memories = store.list_memories() + revisions = store.list_memory_revisions(memory_id) + + assert len(memories) == 1 + assert memories[0]["id"] == memory_id + assert memories[0]["status"] == "deleted" + assert memories[0]["memory_type"] == "preference" + assert memories[0]["confirmation_status"] == "unconfirmed" + assert memories[0]["confidence"] is None + assert memories[0]["salience"] is None + assert memories[0]["valid_from"] is None + assert memories[0]["valid_to"] is None + assert memories[0]["last_confirmed_at"] is None + assert memories[0]["source_event_ids"] == [str(event_ids[2])] + assert [revision["sequence_no"] for revision in revisions] == [1, 2, 3] + assert [revision["action"] for revision in revisions] == ["ADD", "UPDATE", "DELETE"] + assert revisions[0]["new_value"] == {"likes": "black"} + assert revisions[1]["previous_value"] == {"likes": "black"} + assert revisions[1]["new_value"] == {"likes": "oat milk"} + assert revisions[2]["previous_value"] == {"likes": "oat milk"} + assert revisions[2]["new_value"] is None + + with psycopg.connect(migrated_database_urls["admin"]) as conn: + with conn.cursor() as cur: + with pytest.raises(psycopg.Error, match="append-only"): + cur.execute( + "UPDATE memory_revisions SET action = 'MUTATED' WHERE memory_id = %s", + (memory_id,), + ) + + +def test_memories_and_memory_revisions_respect_per_user_isolation( + migrated_database_urls, + monkeypatch, +) -> None: + owner_id, event_ids = seed_memory_evidence(migrated_database_urls["app"]) + intruder_id = uuid4() + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + status_code, payload = invoke_admit_memory( + { + "user_id": str(owner_id), + "memory_key": "user.preference.coffee", + "value": {"likes": "black"}, + "source_event_ids": [str(event_ids[0])], + } + ) + + assert status_code == 200 + memory_id = UUID(payload["memory"]["id"]) + + with user_connection(migrated_database_urls["app"], intruder_id) as conn: + store = ContinuityStore(conn) + store.create_user(intruder_id, "intruder@example.com", "Intruder") + with conn.cursor() as cur: + cur.execute("SELECT COUNT(*) AS count FROM memories WHERE id = %s", (memory_id,)) + memory_count = cur.fetchone() + cur.execute( + "SELECT COUNT(*) AS count FROM memory_revisions WHERE memory_id = %s", + (memory_id,), + ) + revision_count = cur.fetchone() + cur.execute( + "UPDATE memories SET status = 'deleted' WHERE id = %s RETURNING id", + (memory_id,), + ) + updated_rows = cur.fetchall() + + assert memory_count["count"] == 0 + assert revision_count["count"] == 0 + assert updated_rows == [] + assert store.list_memories() == [] + assert store.list_memory_revisions(memory_id) == [] diff --git a/tests/integration/test_memory_quality_gate_api.py b/tests/integration/test_memory_quality_gate_api.py new file mode 100644 index 0000000..f7a8572 --- /dev/null +++ b/tests/integration/test_memory_quality_gate_api.py @@ -0,0 +1,539 @@ +from __future__ import annotations + +import json +from datetime import UTC, datetime +from typing import Any +from urllib.parse import urlencode +from uuid import UUID, uuid4 + +import anyio + +import apps.api.src.alicebot_api.main as main_module +import scripts.run_phase6_quality_evidence as quality_evidence +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.contracts import MemoryCandidateInput +from alicebot_api.db import user_connection +from alicebot_api.memory import admit_memory_candidate +from alicebot_api.store import ContinuityStore + + +def invoke_request( + method: str, + path: str, + *, + query_params: dict[str, str] | None = None, + payload: dict[str, Any] | None = None, +) -> tuple[int, dict[str, Any]]: + messages: list[dict[str, object]] = [] + encoded_body = b"" if payload is None else json.dumps(payload).encode() + request_received = False + + async def receive() -> dict[str, object]: + nonlocal request_received + if request_received: + return {"type": "http.disconnect"} + + request_received = True + return {"type": "http.request", "body": encoded_body, "more_body": False} + + async def send(message: dict[str, object]) -> None: + messages.append(message) + + query_string = urlencode(query_params or {}).encode() + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": query_string, + "headers": [(b"content-type", b"application/json")], + "client": ("testclient", 50000), + "server": ("testserver", 80), + "root_path": "", + } + + anyio.run(main_module.app, scope, receive, send) + + start_message = next(message for message in messages if message["type"] == "http.response.start") + body = b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + return start_message["status"], json.loads(body) + + +def seed_quality_gate_state( + database_url: str, + *, + correct_count: int, + incorrect_count: int, + add_unlabeled: bool, + add_high_risk: bool, + add_stale_truth: bool, + add_outdated_conflict: bool, +) -> str: + user_id = uuid4() + + with user_connection(database_url, user_id) as conn: + store = ContinuityStore(conn) + store.create_user(user_id, f"quality-{user_id}@example.com", "Quality Reviewer") + thread = store.create_thread("Memory quality gate state") + session = store.create_session(thread["id"], status="active") + + memory_ids: list[UUID] = [] + total_base = max(10, correct_count + incorrect_count) + for index in range(total_base): + event_id = store.append_event( + thread["id"], + session["id"], + "message.user", + {"text": f"base memory {index}"}, + )["id"] + decision = admit_memory_candidate( + store, + user_id=user_id, + candidate=MemoryCandidateInput( + memory_key=f"user.quality.base_{index}", + value={"index": index}, + source_event_ids=(event_id,), + confirmation_status="confirmed", + confidence=0.95, + ), + ) + assert decision.memory is not None + memory_ids.append(UUID(decision.memory["id"])) + + cursor = 0 + for _ in range(correct_count): + store.create_memory_review_label( + memory_id=memory_ids[cursor], + label="correct", + note="Correct.", + ) + cursor += 1 + for _ in range(incorrect_count): + store.create_memory_review_label( + memory_id=memory_ids[cursor], + label="incorrect", + note="Incorrect.", + ) + cursor += 1 + + if add_outdated_conflict: + store.create_memory_review_label( + memory_id=memory_ids[0], + label="outdated", + note="Superseded active conflict.", + ) + + if add_unlabeled: + event_id = store.append_event( + thread["id"], + session["id"], + "message.user", + {"text": "unlabeled memory"}, + )["id"] + admit_memory_candidate( + store, + user_id=user_id, + candidate=MemoryCandidateInput( + memory_key="user.quality.unlabeled", + value={"state": "unlabeled"}, + source_event_ids=(event_id,), + confirmation_status="confirmed", + confidence=0.95, + ), + ) + + if add_high_risk: + event_id = store.append_event( + thread["id"], + session["id"], + "message.user", + {"text": "high risk memory"}, + )["id"] + admit_memory_candidate( + store, + user_id=user_id, + candidate=MemoryCandidateInput( + memory_key="user.quality.high_risk", + value={"state": "high_risk"}, + source_event_ids=(event_id,), + confirmation_status="unconfirmed", + confidence=0.2, + ), + ) + + if add_stale_truth: + event_id = store.append_event( + thread["id"], + session["id"], + "message.user", + {"text": "stale truth memory"}, + )["id"] + admit_memory_candidate( + store, + user_id=user_id, + candidate=MemoryCandidateInput( + memory_key="user.quality.stale_truth", + value={"state": "stale"}, + source_event_ids=(event_id,), + confirmation_status="contested", + confidence=0.95, + valid_to=datetime(2026, 3, 1, tzinfo=UTC), + ), + ) + + return str(user_id) + + +def test_memory_quality_gate_endpoint_returns_canonical_status_transitions( + migrated_database_urls, + monkeypatch, +) -> None: + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + insufficient_user = seed_quality_gate_state( + migrated_database_urls["app"], + correct_count=5, + incorrect_count=0, + add_unlabeled=False, + add_high_risk=False, + add_stale_truth=False, + add_outdated_conflict=False, + ) + degraded_precision_user = seed_quality_gate_state( + migrated_database_urls["app"], + correct_count=7, + incorrect_count=3, + add_unlabeled=False, + add_high_risk=False, + add_stale_truth=False, + add_outdated_conflict=False, + ) + degraded_conflict_user = seed_quality_gate_state( + migrated_database_urls["app"], + correct_count=10, + incorrect_count=0, + add_unlabeled=False, + add_high_risk=False, + add_stale_truth=False, + add_outdated_conflict=True, + ) + needs_review_user = seed_quality_gate_state( + migrated_database_urls["app"], + correct_count=10, + incorrect_count=0, + add_unlabeled=True, + add_high_risk=True, + add_stale_truth=False, + add_outdated_conflict=False, + ) + healthy_user = seed_quality_gate_state( + migrated_database_urls["app"], + correct_count=10, + incorrect_count=0, + add_unlabeled=False, + add_high_risk=False, + add_stale_truth=False, + add_outdated_conflict=False, + ) + + insufficient_status, insufficient_payload = invoke_request( + "GET", + "/v0/memories/quality-gate", + query_params={"user_id": insufficient_user}, + ) + degraded_precision_status, degraded_precision_payload = invoke_request( + "GET", + "/v0/memories/quality-gate", + query_params={"user_id": degraded_precision_user}, + ) + degraded_conflict_status, degraded_conflict_payload = invoke_request( + "GET", + "/v0/memories/quality-gate", + query_params={"user_id": degraded_conflict_user}, + ) + needs_review_status, needs_review_payload = invoke_request( + "GET", + "/v0/memories/quality-gate", + query_params={"user_id": needs_review_user}, + ) + healthy_status, healthy_payload = invoke_request( + "GET", + "/v0/memories/quality-gate", + query_params={"user_id": healthy_user}, + ) + + assert insufficient_status == 200 + assert degraded_precision_status == 200 + assert degraded_conflict_status == 200 + assert needs_review_status == 200 + assert healthy_status == 200 + + assert insufficient_payload["summary"]["status"] == "insufficient_sample" + assert degraded_precision_payload["summary"]["status"] == "degraded" + assert degraded_conflict_payload["summary"]["status"] == "degraded" + assert needs_review_payload["summary"]["status"] == "needs_review" + assert healthy_payload["summary"]["status"] == "healthy" + assert degraded_conflict_payload["summary"]["superseded_active_conflict_count"] > 0 + assert needs_review_payload["summary"]["high_risk_memory_count"] > 0 + assert healthy_payload["summary"]["unlabeled_memory_count"] == 0 + assert healthy_payload["summary"]["counts"]["adjudicated_correct_count"] == 10 + + for payload in ( + insufficient_payload, + degraded_precision_payload, + degraded_conflict_payload, + needs_review_payload, + healthy_payload, + ): + assert payload["summary"]["status"] in { + "healthy", + "needs_review", + "insufficient_sample", + "degraded", + } + assert "precision_target" in payload["summary"] + assert "minimum_adjudicated_sample" in payload["summary"] + assert "counts" in payload["summary"] + + +def test_memory_quality_gate_endpoint_is_deterministic_for_fixed_state( + migrated_database_urls, + monkeypatch, +) -> None: + user_id = seed_quality_gate_state( + migrated_database_urls["app"], + correct_count=10, + incorrect_count=0, + add_unlabeled=False, + add_high_risk=False, + add_stale_truth=False, + add_outdated_conflict=False, + ) + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + first_status, first_payload = invoke_request( + "GET", + "/v0/memories/quality-gate", + query_params={"user_id": user_id}, + ) + second_status, second_payload = invoke_request( + "GET", + "/v0/memories/quality-gate", + query_params={"user_id": user_id}, + ) + + assert first_status == 200 + assert second_status == 200 + assert first_payload == second_payload + + +def test_memory_quality_gate_endpoint_enforces_per_user_isolation( + migrated_database_urls, + monkeypatch, +) -> None: + owner_id = seed_quality_gate_state( + migrated_database_urls["app"], + correct_count=10, + incorrect_count=0, + add_unlabeled=False, + add_high_risk=False, + add_stale_truth=False, + add_outdated_conflict=False, + ) + intruder_id = uuid4() + with user_connection(migrated_database_urls["app"], intruder_id) as conn: + ContinuityStore(conn).create_user(intruder_id, "intruder@example.com", "Intruder") + + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + owner_status, owner_payload = invoke_request( + "GET", + "/v0/memories/quality-gate", + query_params={"user_id": owner_id}, + ) + intruder_status, intruder_payload = invoke_request( + "GET", + "/v0/memories/quality-gate", + query_params={"user_id": str(intruder_id)}, + ) + + assert owner_status == 200 + assert intruder_status == 200 + assert owner_payload["summary"]["counts"]["active_memory_count"] > 0 + assert intruder_payload == { + "summary": { + "status": "insufficient_sample", + "precision": None, + "precision_target": 0.8, + "adjudicated_sample_count": 0, + "minimum_adjudicated_sample": 10, + "remaining_to_minimum_sample": 10, + "unlabeled_memory_count": 0, + "high_risk_memory_count": 0, + "stale_truth_count": 0, + "superseded_active_conflict_count": 0, + "counts": { + "active_memory_count": 0, + "labeled_active_memory_count": 0, + "adjudicated_correct_count": 0, + "adjudicated_incorrect_count": 0, + "outdated_label_count": 0, + "insufficient_evidence_label_count": 0, + }, + } + } + + +def test_memory_trust_dashboard_endpoint_aggregates_canonical_quality_inputs( + migrated_database_urls, + monkeypatch, +) -> None: + user_id = seed_quality_gate_state( + migrated_database_urls["app"], + correct_count=10, + incorrect_count=0, + add_unlabeled=True, + add_high_risk=True, + add_stale_truth=True, + add_outdated_conflict=False, + ) + + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + dashboard_status, dashboard_payload = invoke_request( + "GET", + "/v0/memories/trust-dashboard", + query_params={"user_id": user_id}, + ) + gate_status, gate_payload = invoke_request( + "GET", + "/v0/memories/quality-gate", + query_params={"user_id": user_id}, + ) + retrieval_status, retrieval_payload = invoke_request( + "GET", + "/v0/continuity/retrieval-evaluation", + query_params={"user_id": user_id}, + ) + + assert dashboard_status == 200 + assert gate_status == 200 + assert retrieval_status == 200 + assert dashboard_payload["dashboard"]["quality_gate"] == gate_payload["summary"] + assert dashboard_payload["dashboard"]["queue_posture"]["total_count"] == gate_payload["summary"][ + "unlabeled_memory_count" + ] + assert dashboard_payload["dashboard"]["retrieval_quality"] == retrieval_payload["summary"] + assert dashboard_payload["dashboard"]["correction_freshness"] == { + "total_open_loop_count": 0, + "stale_open_loop_count": 0, + "correction_recurrence_count": 0, + "freshness_drift_count": 0, + } + assert dashboard_payload["dashboard"]["recommended_review"]["priority_mode"] == "high_risk_first" + assert dashboard_payload["dashboard"]["recommended_review"]["action"] == "review_high_risk_queue" + + +def test_memory_trust_dashboard_endpoint_is_deterministic_for_fixed_state( + migrated_database_urls, + monkeypatch, +) -> None: + user_id = seed_quality_gate_state( + migrated_database_urls["app"], + correct_count=10, + incorrect_count=0, + add_unlabeled=True, + add_high_risk=False, + add_stale_truth=False, + add_outdated_conflict=False, + ) + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + first_status, first_payload = invoke_request( + "GET", + "/v0/memories/trust-dashboard", + query_params={"user_id": user_id}, + ) + second_status, second_payload = invoke_request( + "GET", + "/v0/memories/trust-dashboard", + query_params={"user_id": user_id}, + ) + + assert first_status == 200 + assert second_status == 200 + assert first_payload == second_payload + + +def test_phase6_quality_evidence_script_writes_deterministic_artifact_matching_dashboard( + migrated_database_urls, + monkeypatch, + tmp_path, +) -> None: + user_id = seed_quality_gate_state( + migrated_database_urls["app"], + correct_count=10, + incorrect_count=0, + add_unlabeled=True, + add_high_risk=True, + add_stale_truth=False, + add_outdated_conflict=False, + ) + + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + monkeypatch.setattr( + quality_evidence, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + dashboard_status, dashboard_payload = invoke_request( + "GET", + "/v0/memories/trust-dashboard", + query_params={"user_id": user_id}, + ) + assert dashboard_status == 200 + + artifact_path = tmp_path / "phase6_quality_evidence.json" + exit_code = quality_evidence.main( + ["--user-id", user_id, "--output", str(artifact_path)], + ) + assert exit_code == 0 + assert artifact_path.exists() + + written_payload = json.loads(artifact_path.read_text(encoding="utf-8")) + assert written_payload["artifact_version"] == quality_evidence.ARTIFACT_VERSION + assert written_payload["artifact_path"] == str(artifact_path) + assert written_payload["user_id"] == user_id + assert written_payload["dashboard"] == dashboard_payload["dashboard"] diff --git a/tests/integration/test_memory_review_api.py b/tests/integration/test_memory_review_api.py new file mode 100644 index 0000000..c411713 --- /dev/null +++ b/tests/integration/test_memory_review_api.py @@ -0,0 +1,1030 @@ +from __future__ import annotations + +import json +from datetime import UTC, datetime +from typing import Any +from urllib.parse import urlencode +from uuid import UUID, uuid4 + +import anyio + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.contracts import MemoryCandidateInput +from alicebot_api.db import user_connection +from alicebot_api.memory import admit_memory_candidate +from alicebot_api.store import ContinuityStore + + +def invoke_request( + method: str, + path: str, + *, + query_params: dict[str, str] | None = None, + payload: dict[str, Any] | None = None, +) -> tuple[int, dict[str, Any]]: + messages: list[dict[str, object]] = [] + encoded_body = b"" if payload is None else json.dumps(payload).encode() + request_received = False + + async def receive() -> dict[str, object]: + nonlocal request_received + if request_received: + return {"type": "http.disconnect"} + + request_received = True + return {"type": "http.request", "body": encoded_body, "more_body": False} + + async def send(message: dict[str, object]) -> None: + messages.append(message) + + query_string = urlencode(query_params or {}).encode() + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": query_string, + "headers": [(b"content-type", b"application/json")], + "client": ("testclient", 50000), + "server": ("testserver", 80), + "root_path": "", + } + + anyio.run(main_module.app, scope, receive, send) + + start_message = next(message for message in messages if message["type"] == "http.response.start") + body = b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + return start_message["status"], json.loads(body) + + +def seed_review_memories(database_url: str) -> dict[str, str]: + user_id = uuid4() + + with user_connection(database_url, user_id) as conn: + store = ContinuityStore(conn) + store.create_user(user_id, "reviewer@example.com", "Reviewer") + thread = store.create_thread("Memory review thread") + session = store.create_session(thread["id"], status="active") + event_ids = [ + store.append_event(thread["id"], session["id"], "message.user", {"text": "likes black coffee"})["id"], + store.append_event(thread["id"], session["id"], "message.user", {"text": "likes salty snacks"})["id"], + store.append_event(thread["id"], session["id"], "message.user", {"text": "reads science fiction"})["id"], + store.append_event(thread["id"], session["id"], "message.user", {"text": "enjoys hiking"})["id"], + store.append_event(thread["id"], session["id"], "message.user", {"text": "forget the snack preference"})["id"], + store.append_event(thread["id"], session["id"], "message.user", {"text": "actually likes oat milk"})["id"], + ] + + coffee = admit_memory_candidate( + store, + user_id=user_id, + candidate=MemoryCandidateInput( + memory_key="user.preference.coffee", + value={"likes": "black"}, + source_event_ids=(event_ids[0],), + ), + ) + snack = admit_memory_candidate( + store, + user_id=user_id, + candidate=MemoryCandidateInput( + memory_key="user.preference.snack", + value={"likes": "chips"}, + source_event_ids=(event_ids[1],), + ), + ) + book = admit_memory_candidate( + store, + user_id=user_id, + candidate=MemoryCandidateInput( + memory_key="user.preference.book", + value={"genre": "science fiction"}, + source_event_ids=(event_ids[2],), + ), + ) + hobby = admit_memory_candidate( + store, + user_id=user_id, + candidate=MemoryCandidateInput( + memory_key="user.preference.hobby", + value={"likes": "hiking"}, + source_event_ids=(event_ids[3],), + ), + ) + admit_memory_candidate( + store, + user_id=user_id, + candidate=MemoryCandidateInput( + memory_key="user.preference.snack", + value=None, + source_event_ids=(event_ids[4],), + delete_requested=True, + ), + ) + admit_memory_candidate( + store, + user_id=user_id, + candidate=MemoryCandidateInput( + memory_key="user.preference.coffee", + value={"likes": "oat milk"}, + source_event_ids=(event_ids[5],), + ), + ) + + return { + "user_id": str(user_id), + "coffee_memory_id": coffee.memory["id"], + "snack_memory_id": snack.memory["id"], + "book_memory_id": book.memory["id"], + "hobby_memory_id": hobby.memory["id"], + "coffee_add_event_id": str(event_ids[0]), + "coffee_update_event_id": str(event_ids[5]), + "book_add_event_id": str(event_ids[2]), + "hobby_add_event_id": str(event_ids[3]), + "snack_delete_event_id": str(event_ids[4]), + } + + +def seed_review_queue_state(database_url: str) -> dict[str, str]: + seeded = seed_review_memories(database_url) + + with user_connection(database_url, UUID(seeded["user_id"])) as conn: + store = ContinuityStore(conn) + store.create_memory_review_label( + memory_id=UUID(seeded["hobby_memory_id"]), + label="correct", + note="Already reviewed.", + ) + store.create_memory_review_label( + memory_id=UUID(seeded["snack_memory_id"]), + label="outdated", + note="Deleted memory remains part of evaluation counts only.", + ) + + return seeded + + +def seed_memory_evaluation_state(database_url: str) -> dict[str, str]: + seeded = seed_review_memories(database_url) + + with user_connection(database_url, UUID(seeded["user_id"])) as conn: + store = ContinuityStore(conn) + store.create_memory_review_label( + memory_id=UUID(seeded["coffee_memory_id"]), + label="correct", + note="Matches the latest coffee preference.", + ) + store.create_memory_review_label( + memory_id=UUID(seeded["coffee_memory_id"]), + label="insufficient_evidence", + note="One source event is still a thin basis.", + ) + store.create_memory_review_label( + memory_id=UUID(seeded["snack_memory_id"]), + label="outdated", + note="The deleted snack preference is superseded.", + ) + + return seeded + + +def seed_review_queue_priority_state(database_url: str) -> dict[str, str]: + user_id = uuid4() + + with user_connection(database_url, user_id) as conn: + store = ContinuityStore(conn) + store.create_user(user_id, "priority@example.com", "Priority Reviewer") + thread = store.create_thread("Queue priority thread") + session = store.create_session(thread["id"], status="active") + event_ids = [ + store.append_event(thread["id"], session["id"], "message.user", {"text": "oldest"})["id"], + store.append_event(thread["id"], session["id"], "message.user", {"text": "middle"})["id"], + store.append_event(thread["id"], session["id"], "message.user", {"text": "newest"})["id"], + ] + + oldest = admit_memory_candidate( + store, + user_id=user_id, + candidate=MemoryCandidateInput( + memory_key="user.priority.oldest", + value={"rank": "oldest"}, + source_event_ids=(event_ids[0],), + confirmation_status="contested", + confidence=0.9, + valid_to=datetime(2026, 3, 1, 0, 0, tzinfo=UTC), + ), + ) + middle = admit_memory_candidate( + store, + user_id=user_id, + candidate=MemoryCandidateInput( + memory_key="user.priority.middle", + value={"rank": "middle"}, + source_event_ids=(event_ids[1],), + confirmation_status="confirmed", + confidence=0.95, + ), + ) + newest = admit_memory_candidate( + store, + user_id=user_id, + candidate=MemoryCandidateInput( + memory_key="user.priority.newest", + value={"rank": "newest"}, + source_event_ids=(event_ids[2],), + confirmation_status="confirmed", + confidence=0.2, + ), + ) + + return { + "user_id": str(user_id), + "oldest_memory_id": oldest.memory["id"], + "middle_memory_id": middle.memory["id"], + "newest_memory_id": newest.memory["id"], + } + + +def test_list_memories_endpoint_returns_filtered_memories_with_deterministic_order( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_review_memories(migrated_database_urls["app"]) + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + status_code, payload = invoke_request( + "GET", + "/v0/memories", + query_params={ + "user_id": seeded["user_id"], + "status": "active", + "limit": "2", + }, + ) + + assert status_code == 200 + assert [item["id"] for item in payload["items"]] == [ + seeded["coffee_memory_id"], + seeded["hobby_memory_id"], + ] + assert payload["items"][0]["status"] == "active" + assert payload["items"][0]["value"] == {"likes": "oat milk"} + assert payload["items"][0]["source_event_ids"] == [seeded["coffee_update_event_id"]] + assert payload["items"][0]["memory_type"] == "preference" + assert payload["items"][0]["confirmation_status"] == "unconfirmed" + assert payload["items"][0]["trust_class"] == "deterministic" + assert payload["items"][0]["promotion_eligibility"] == "promotable" + assert payload["items"][0]["evidence_count"] is None + assert payload["items"][0]["independent_source_count"] is None + assert payload["items"][0]["extracted_by_model"] is None + assert payload["items"][0]["trust_reason"] is None + assert payload["items"][0]["confidence"] is None + assert payload["items"][0]["salience"] is None + assert payload["items"][0]["valid_from"] is None + assert payload["items"][0]["valid_to"] is None + assert payload["items"][0]["last_confirmed_at"] is None + assert payload["summary"] == { + "status": "active", + "limit": 2, + "returned_count": 2, + "total_count": 3, + "has_more": True, + "order": ["updated_at_desc", "created_at_desc", "id_desc"], + } + + deleted_status, deleted_payload = invoke_request( + "GET", + "/v0/memories", + query_params={ + "user_id": seeded["user_id"], + "status": "deleted", + "limit": "5", + }, + ) + + assert deleted_status == 200 + assert deleted_payload["items"] == [ + { + "id": seeded["snack_memory_id"], + "memory_key": "user.preference.snack", + "value": {"likes": "chips"}, + "status": "deleted", + "source_event_ids": [seeded["snack_delete_event_id"]], + "memory_type": "preference", + "confidence": None, + "salience": None, + "confirmation_status": "unconfirmed", + "trust_class": "deterministic", + "promotion_eligibility": "promotable", + "evidence_count": None, + "independent_source_count": None, + "extracted_by_model": None, + "trust_reason": None, + "valid_from": None, + "valid_to": None, + "last_confirmed_at": None, + "created_at": deleted_payload["items"][0]["created_at"], + "updated_at": deleted_payload["items"][0]["updated_at"], + "deleted_at": deleted_payload["items"][0]["deleted_at"], + } + ] + assert deleted_payload["summary"] == { + "status": "deleted", + "limit": 5, + "returned_count": 1, + "total_count": 1, + "has_more": False, + "order": ["updated_at_desc", "created_at_desc", "id_desc"], + } + + +def test_memory_review_endpoints_return_current_memory_and_revision_history( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_review_memories(migrated_database_urls["app"]) + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + memory_status, memory_payload = invoke_request( + "GET", + f"/v0/memories/{seeded['coffee_memory_id']}", + query_params={"user_id": seeded["user_id"]}, + ) + revisions_status, revisions_payload = invoke_request( + "GET", + f"/v0/memories/{seeded['coffee_memory_id']}/revisions", + query_params={"user_id": seeded["user_id"], "limit": "5"}, + ) + + assert memory_status == 200 + assert memory_payload["memory"]["id"] == seeded["coffee_memory_id"] + assert memory_payload["memory"]["memory_key"] == "user.preference.coffee" + assert memory_payload["memory"]["status"] == "active" + assert memory_payload["memory"]["value"] == {"likes": "oat milk"} + assert memory_payload["memory"]["source_event_ids"] == [seeded["coffee_update_event_id"]] + assert memory_payload["memory"]["memory_type"] == "preference" + assert memory_payload["memory"]["confidence"] is None + assert memory_payload["memory"]["salience"] is None + assert memory_payload["memory"]["confirmation_status"] == "unconfirmed" + assert memory_payload["memory"]["valid_from"] is None + assert memory_payload["memory"]["valid_to"] is None + assert memory_payload["memory"]["last_confirmed_at"] is None + + assert revisions_status == 200 + assert [item["sequence_no"] for item in revisions_payload["items"]] == [1, 2] + assert [item["action"] for item in revisions_payload["items"]] == ["ADD", "UPDATE"] + assert revisions_payload["items"][0]["new_value"] == {"likes": "black"} + assert revisions_payload["items"][0]["source_event_ids"] == [seeded["coffee_add_event_id"]] + assert revisions_payload["items"][1]["previous_value"] == {"likes": "black"} + assert revisions_payload["items"][1]["new_value"] == {"likes": "oat milk"} + assert revisions_payload["items"][1]["source_event_ids"] == [seeded["coffee_update_event_id"]] + assert revisions_payload["summary"] == { + "memory_id": seeded["coffee_memory_id"], + "limit": 5, + "returned_count": 2, + "total_count": 2, + "has_more": False, + "order": ["sequence_no_asc"], + } + + +def test_memory_review_endpoints_roundtrip_non_default_typed_metadata( + migrated_database_urls, + monkeypatch, +) -> None: + user_id = uuid4() + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + store.create_user(user_id, "typed@example.com", "Typed Reviewer") + thread = store.create_thread("Typed metadata thread") + session = store.create_session(thread["id"], status="active") + source_event_id = store.append_event( + thread["id"], + session["id"], + "message.user", + {"text": "Morning standup prep is my preferred daily routine."}, + )["id"] + + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + admit_status, admit_payload = invoke_request( + "POST", + "/v0/memories/admit", + payload={ + "user_id": str(user_id), + "memory_key": "user.routine.morning_prep", + "value": {"window": "07:30", "activity": "standup prep"}, + "source_event_ids": [str(source_event_id)], + "memory_type": "routine", + "confidence": 0.92, + "salience": 0.73, + "confirmation_status": "confirmed", + "valid_from": "2026-03-01T07:30:00Z", + "valid_to": "2026-12-31T07:30:00Z", + "last_confirmed_at": "2026-03-20T09:00:00Z", + }, + ) + + assert admit_status == 200 + assert admit_payload["decision"] == "ADD" + admitted_memory_id = admit_payload["memory"]["id"] + + list_status, list_payload = invoke_request( + "GET", + "/v0/memories", + query_params={"user_id": str(user_id), "status": "active", "limit": "10"}, + ) + detail_status, detail_payload = invoke_request( + "GET", + f"/v0/memories/{admitted_memory_id}", + query_params={"user_id": str(user_id)}, + ) + + assert list_status == 200 + assert detail_status == 200 + listed_memory = next(item for item in list_payload["items"] if item["id"] == admitted_memory_id) + detailed_memory = detail_payload["memory"] + + for payload in (listed_memory, detailed_memory): + assert payload["memory_type"] == "routine" + assert payload["confidence"] == 0.92 + assert payload["salience"] == 0.73 + assert payload["confirmation_status"] == "confirmed" + assert payload["valid_from"].startswith("2026-03-01T07:30:00") + assert payload["valid_to"].startswith("2026-12-31T07:30:00") + assert payload["last_confirmed_at"].startswith("2026-03-20T09:00:00") + + +def test_memory_admit_endpoint_scopes_same_memory_key_by_thread_profile( + migrated_database_urls, + monkeypatch, +) -> None: + user_id = uuid4() + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + store.create_user(user_id, "profiled-admit@example.com", "Profiled Admit") + assistant_thread = store.create_thread("Assistant memory thread") + coach_thread = store.create_thread("Coach memory thread", agent_profile_id="coach_default") + assistant_session = store.create_session(assistant_thread["id"], status="active") + coach_session = store.create_session(coach_thread["id"], status="active") + assistant_event_id = store.append_event( + assistant_thread["id"], + assistant_session["id"], + "message.user", + {"text": "assistant profile memory evidence"}, + )["id"] + coach_event_id = store.append_event( + coach_thread["id"], + coach_session["id"], + "message.user", + {"text": "coach profile memory evidence"}, + )["id"] + + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + assistant_status, assistant_payload = invoke_request( + "POST", + "/v0/memories/admit", + payload={ + "user_id": str(user_id), + "memory_key": "user.preference.profiled.coffee", + "value": {"likes": "black"}, + "source_event_ids": [str(assistant_event_id)], + }, + ) + coach_add_status, coach_add_payload = invoke_request( + "POST", + "/v0/memories/admit", + payload={ + "user_id": str(user_id), + "memory_key": "user.preference.profiled.coffee", + "value": {"likes": "oat milk"}, + "source_event_ids": [str(coach_event_id)], + }, + ) + coach_update_status, coach_update_payload = invoke_request( + "POST", + "/v0/memories/admit", + payload={ + "user_id": str(user_id), + "memory_key": "user.preference.profiled.coffee", + "value": {"likes": "cortado"}, + "source_event_ids": [str(coach_event_id)], + }, + ) + + assert assistant_status == 200 + assert assistant_payload["decision"] == "ADD" + assert coach_add_status == 200 + assert coach_add_payload["decision"] == "ADD" + assert coach_update_status == 200 + assert coach_update_payload["decision"] == "UPDATE" + assert assistant_payload["memory"]["id"] != coach_add_payload["memory"]["id"] + assert coach_update_payload["memory"]["id"] == coach_add_payload["memory"]["id"] + + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + assistant_memory = store.get_memory(UUID(assistant_payload["memory"]["id"])) + coach_memory = store.get_memory(UUID(coach_add_payload["memory"]["id"])) + + assert assistant_memory["agent_profile_id"] == "assistant_default" + assert assistant_memory["value"] == {"likes": "black"} + assert coach_memory["agent_profile_id"] == "coach_default" + assert coach_memory["value"] == {"likes": "cortado"} + + +def test_memory_admit_endpoint_rejects_mixed_profile_source_events( + migrated_database_urls, + monkeypatch, +) -> None: + user_id = uuid4() + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + store.create_user(user_id, "mixed-profile@example.com", "Mixed Profile") + assistant_thread = store.create_thread("Assistant mixed thread") + coach_thread = store.create_thread("Coach mixed thread", agent_profile_id="coach_default") + assistant_session = store.create_session(assistant_thread["id"], status="active") + coach_session = store.create_session(coach_thread["id"], status="active") + assistant_event_id = store.append_event( + assistant_thread["id"], + assistant_session["id"], + "message.user", + {"text": "assistant profile evidence"}, + )["id"] + coach_event_id = store.append_event( + coach_thread["id"], + coach_session["id"], + "message.user", + {"text": "coach profile evidence"}, + )["id"] + + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + status_code, payload = invoke_request( + "POST", + "/v0/memories/admit", + payload={ + "user_id": str(user_id), + "memory_key": "user.preference.profiled.invalid", + "value": {"likes": "espresso"}, + "source_event_ids": [str(assistant_event_id), str(coach_event_id)], + }, + ) + + assert status_code == 400 + assert payload == { + "detail": "source_event_ids must all belong to threads with the same agent_profile_id" + } + + +def test_memory_admit_endpoint_rejects_explicit_agent_profile_mismatch( + migrated_database_urls, + monkeypatch, +) -> None: + user_id = uuid4() + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + store.create_user(user_id, "mismatch-profile@example.com", "Mismatch Profile") + assistant_thread = store.create_thread("Assistant mismatch thread") + assistant_session = store.create_session(assistant_thread["id"], status="active") + assistant_event_id = store.append_event( + assistant_thread["id"], + assistant_session["id"], + "message.user", + {"text": "assistant profile mismatch evidence"}, + )["id"] + + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + status_code, payload = invoke_request( + "POST", + "/v0/memories/admit", + payload={ + "user_id": str(user_id), + "memory_key": "user.preference.profiled.mismatch", + "value": {"likes": "espresso"}, + "source_event_ids": [str(assistant_event_id)], + "agent_profile_id": "coach_default", + }, + ) + + assert status_code == 400 + assert payload == { + "detail": "agent_profile_id must match the profile resolved from source_event_ids" + } + + +def test_memory_admit_endpoint_rejects_unknown_agent_profile_id( + migrated_database_urls, + monkeypatch, +) -> None: + user_id = uuid4() + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + store.create_user(user_id, "unknown-profile@example.com", "Unknown Profile") + assistant_thread = store.create_thread("Assistant unknown-profile thread") + assistant_session = store.create_session(assistant_thread["id"], status="active") + assistant_event_id = store.append_event( + assistant_thread["id"], + assistant_session["id"], + "message.user", + {"text": "assistant profile unknown evidence"}, + )["id"] + + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + status_code, payload = invoke_request( + "POST", + "/v0/memories/admit", + payload={ + "user_id": str(user_id), + "memory_key": "user.preference.profiled.unknown", + "value": {"likes": "espresso"}, + "source_event_ids": [str(assistant_event_id)], + "agent_profile_id": "unknown_profile", + }, + ) + + assert status_code == 400 + assert payload == { + "detail": "agent_profile_id must reference an existing profile: unknown_profile" + } + + +def test_memory_review_endpoints_enforce_per_user_isolation_and_not_found_behavior( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_review_memories(migrated_database_urls["app"]) + intruder_id = uuid4() + with user_connection(migrated_database_urls["app"], intruder_id) as conn: + ContinuityStore(conn).create_user(intruder_id, "intruder@example.com", "Intruder") + + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + list_status, list_payload = invoke_request( + "GET", + "/v0/memories", + query_params={"user_id": str(intruder_id), "status": "all", "limit": "10"}, + ) + memory_status, memory_payload = invoke_request( + "GET", + f"/v0/memories/{seeded['coffee_memory_id']}", + query_params={"user_id": str(intruder_id)}, + ) + revisions_status, revisions_payload = invoke_request( + "GET", + f"/v0/memories/{seeded['coffee_memory_id']}/revisions", + query_params={"user_id": str(intruder_id), "limit": "10"}, + ) + + assert list_status == 200 + assert list_payload == { + "items": [], + "summary": { + "status": "all", + "limit": 10, + "returned_count": 0, + "total_count": 0, + "has_more": False, + "order": ["updated_at_desc", "created_at_desc", "id_desc"], + }, + } + assert memory_status == 404 + assert memory_payload == { + "detail": f"memory {seeded['coffee_memory_id']} was not found", + } + assert revisions_status == 404 + assert revisions_payload == { + "detail": f"memory {seeded['coffee_memory_id']} was not found", + } + + +def test_memory_review_queue_endpoint_returns_only_active_unlabeled_memories_in_deterministic_order( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_review_queue_state(migrated_database_urls["app"]) + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + status_code, payload = invoke_request( + "GET", + "/v0/memories/review-queue", + query_params={ + "user_id": seeded["user_id"], + "limit": "2", + }, + ) + + assert status_code == 200 + assert payload == { + "items": [ + { + "id": seeded["coffee_memory_id"], + "memory_key": "user.preference.coffee", + "value": {"likes": "oat milk"}, + "status": "active", + "source_event_ids": [seeded["coffee_update_event_id"]], + "memory_type": "preference", + "confidence": None, + "salience": None, + "confirmation_status": "unconfirmed", + "trust_class": "deterministic", + "promotion_eligibility": "promotable", + "evidence_count": None, + "independent_source_count": None, + "extracted_by_model": None, + "trust_reason": None, + "valid_from": None, + "valid_to": None, + "last_confirmed_at": None, + "is_high_risk": True, + "is_stale_truth": False, + "is_promotable": True, + "queue_priority_mode": "recent_first", + "priority_reason": "recent_first", + "created_at": payload["items"][0]["created_at"], + "updated_at": payload["items"][0]["updated_at"], + }, + { + "id": seeded["book_memory_id"], + "memory_key": "user.preference.book", + "value": {"genre": "science fiction"}, + "status": "active", + "source_event_ids": [seeded["book_add_event_id"]], + "memory_type": "preference", + "confidence": None, + "salience": None, + "confirmation_status": "unconfirmed", + "trust_class": "deterministic", + "promotion_eligibility": "promotable", + "evidence_count": None, + "independent_source_count": None, + "extracted_by_model": None, + "trust_reason": None, + "valid_from": None, + "valid_to": None, + "last_confirmed_at": None, + "is_high_risk": True, + "is_stale_truth": False, + "is_promotable": True, + "queue_priority_mode": "recent_first", + "priority_reason": "recent_first", + "created_at": payload["items"][1]["created_at"], + "updated_at": payload["items"][1]["updated_at"], + }, + ], + "summary": { + "memory_status": "active", + "review_state": "unlabeled", + "priority_mode": "recent_first", + "available_priority_modes": [ + "oldest_first", + "recent_first", + "high_risk_first", + "stale_truth_first", + ], + "limit": 2, + "returned_count": 2, + "total_count": 2, + "has_more": False, + "order": ["updated_at_desc", "created_at_desc", "id_desc"], + }, + } + + +def test_memory_review_queue_endpoint_supports_all_priority_modes( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_review_queue_priority_state(migrated_database_urls["app"]) + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + oldest_status, oldest_payload = invoke_request( + "GET", + "/v0/memories/review-queue", + query_params={ + "user_id": seeded["user_id"], + "limit": "3", + "priority_mode": "oldest_first", + }, + ) + recent_status, recent_payload = invoke_request( + "GET", + "/v0/memories/review-queue", + query_params={ + "user_id": seeded["user_id"], + "limit": "3", + "priority_mode": "recent_first", + }, + ) + high_risk_status, high_risk_payload = invoke_request( + "GET", + "/v0/memories/review-queue", + query_params={ + "user_id": seeded["user_id"], + "limit": "3", + "priority_mode": "high_risk_first", + }, + ) + stale_truth_status, stale_truth_payload = invoke_request( + "GET", + "/v0/memories/review-queue", + query_params={ + "user_id": seeded["user_id"], + "limit": "3", + "priority_mode": "stale_truth_first", + }, + ) + + assert oldest_status == 200 + assert recent_status == 200 + assert high_risk_status == 200 + assert stale_truth_status == 200 + + assert [item["id"] for item in oldest_payload["items"]] == [ + seeded["oldest_memory_id"], + seeded["middle_memory_id"], + seeded["newest_memory_id"], + ] + assert [item["id"] for item in recent_payload["items"]] == [ + seeded["newest_memory_id"], + seeded["middle_memory_id"], + seeded["oldest_memory_id"], + ] + assert [item["id"] for item in high_risk_payload["items"]] == [ + seeded["newest_memory_id"], + seeded["oldest_memory_id"], + seeded["middle_memory_id"], + ] + assert [item["id"] for item in stale_truth_payload["items"]] == [ + seeded["oldest_memory_id"], + seeded["newest_memory_id"], + seeded["middle_memory_id"], + ] + assert oldest_payload["summary"]["priority_mode"] == "oldest_first" + assert recent_payload["summary"]["priority_mode"] == "recent_first" + assert high_risk_payload["summary"]["priority_mode"] == "high_risk_first" + assert stale_truth_payload["summary"]["priority_mode"] == "stale_truth_first" + assert stale_truth_payload["items"][0]["is_stale_truth"] is True + + +def test_memory_evaluation_summary_endpoint_returns_explicit_consistent_counts( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_memory_evaluation_state(migrated_database_urls["app"]) + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + status_code, payload = invoke_request( + "GET", + "/v0/memories/evaluation-summary", + query_params={"user_id": seeded["user_id"]}, + ) + + assert status_code == 200 + assert payload == { + "summary": { + "total_memory_count": 4, + "active_memory_count": 3, + "deleted_memory_count": 1, + "labeled_memory_count": 2, + "unlabeled_memory_count": 2, + "total_label_row_count": 3, + "label_row_counts_by_value": { + "correct": 1, + "incorrect": 0, + "outdated": 1, + "insufficient_evidence": 1, + }, + "label_value_order": [ + "correct", + "incorrect", + "outdated", + "insufficient_evidence", + ], + } + } + + +def test_memory_review_queue_and_evaluation_summary_enforce_per_user_isolation( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_memory_evaluation_state(migrated_database_urls["app"]) + intruder_id = uuid4() + with user_connection(migrated_database_urls["app"], intruder_id) as conn: + ContinuityStore(conn).create_user(intruder_id, "intruder@example.com", "Intruder") + + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + queue_status, queue_payload = invoke_request( + "GET", + "/v0/memories/review-queue", + query_params={"user_id": str(intruder_id), "limit": "10"}, + ) + summary_status, summary_payload = invoke_request( + "GET", + "/v0/memories/evaluation-summary", + query_params={"user_id": str(intruder_id)}, + ) + + assert seeded["user_id"] != str(intruder_id) + assert queue_status == 200 + assert queue_payload == { + "items": [], + "summary": { + "memory_status": "active", + "review_state": "unlabeled", + "priority_mode": "recent_first", + "available_priority_modes": [ + "oldest_first", + "recent_first", + "high_risk_first", + "stale_truth_first", + ], + "limit": 10, + "returned_count": 0, + "total_count": 0, + "has_more": False, + "order": ["updated_at_desc", "created_at_desc", "id_desc"], + }, + } + assert summary_status == 200 + assert summary_payload == { + "summary": { + "total_memory_count": 0, + "active_memory_count": 0, + "deleted_memory_count": 0, + "labeled_memory_count": 0, + "unlabeled_memory_count": 0, + "total_label_row_count": 0, + "label_row_counts_by_value": { + "correct": 0, + "incorrect": 0, + "outdated": 0, + "insufficient_evidence": 0, + }, + "label_value_order": [ + "correct", + "incorrect", + "outdated", + "insufficient_evidence", + ], + } + } diff --git a/tests/integration/test_memory_review_labels_api.py b/tests/integration/test_memory_review_labels_api.py new file mode 100644 index 0000000..1b184e8 --- /dev/null +++ b/tests/integration/test_memory_review_labels_api.py @@ -0,0 +1,333 @@ +from __future__ import annotations + +import json +from typing import Any +from urllib.parse import urlencode +from uuid import UUID, uuid4 + +import anyio +import psycopg +import pytest + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.contracts import MemoryCandidateInput +from alicebot_api.db import user_connection +from alicebot_api.memory import admit_memory_candidate +from alicebot_api.store import ContinuityStore + + +def invoke_request( + method: str, + path: str, + *, + query_params: dict[str, str] | None = None, + payload: dict[str, Any] | None = None, +) -> tuple[int, dict[str, Any]]: + messages: list[dict[str, object]] = [] + encoded_body = b"" if payload is None else json.dumps(payload).encode() + request_received = False + + async def receive() -> dict[str, object]: + nonlocal request_received + if request_received: + return {"type": "http.disconnect"} + + request_received = True + return {"type": "http.request", "body": encoded_body, "more_body": False} + + async def send(message: dict[str, object]) -> None: + messages.append(message) + + query_string = urlencode(query_params or {}).encode() + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": query_string, + "headers": [(b"content-type", b"application/json")], + "client": ("testclient", 50000), + "server": ("testserver", 80), + "root_path": "", + } + + anyio.run(main_module.app, scope, receive, send) + + start_message = next(message for message in messages if message["type"] == "http.response.start") + body = b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + return start_message["status"], json.loads(body) + + +def seed_memory_for_review_labels(database_url: str) -> dict[str, str]: + user_id = uuid4() + + with user_connection(database_url, user_id) as conn: + store = ContinuityStore(conn) + store.create_user(user_id, "reviewer@example.com", "Reviewer") + thread = store.create_thread("Memory review labels thread") + session = store.create_session(thread["id"], status="active") + event = store.append_event( + thread["id"], + session["id"], + "message.user", + {"text": "likes oat milk in coffee"}, + ) + decision = admit_memory_candidate( + store, + user_id=user_id, + candidate=MemoryCandidateInput( + memory_key="user.preference.coffee", + value={"likes": "oat milk"}, + source_event_ids=(event["id"],), + ), + ) + + assert decision.memory is not None + return { + "user_id": str(user_id), + "memory_id": decision.memory["id"], + } + + +def seed_intruder(database_url: str) -> UUID: + intruder_id = uuid4() + with user_connection(database_url, intruder_id) as conn: + ContinuityStore(conn).create_user(intruder_id, "intruder@example.com", "Intruder") + return intruder_id + + +def test_memory_review_label_endpoints_create_and_list_labels_with_stable_summary_counts( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_memory_for_review_labels(migrated_database_urls["app"]) + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + first_status, first_payload = invoke_request( + "POST", + f"/v0/memories/{seeded['memory_id']}/labels", + payload={ + "user_id": seeded["user_id"], + "label": "correct", + "note": "Matches the latest admitted evidence.", + }, + ) + second_status, second_payload = invoke_request( + "POST", + f"/v0/memories/{seeded['memory_id']}/labels", + payload={ + "user_id": seeded["user_id"], + "label": "outdated", + "note": None, + }, + ) + list_status, list_payload = invoke_request( + "GET", + f"/v0/memories/{seeded['memory_id']}/labels", + query_params={"user_id": seeded["user_id"]}, + ) + + assert first_status == 201 + assert first_payload["label"]["memory_id"] == seeded["memory_id"] + assert first_payload["label"]["reviewer_user_id"] == seeded["user_id"] + assert first_payload["label"]["label"] == "correct" + assert first_payload["label"]["note"] == "Matches the latest admitted evidence." + assert first_payload["summary"] == { + "memory_id": seeded["memory_id"], + "total_count": 1, + "counts_by_label": { + "correct": 1, + "incorrect": 0, + "outdated": 0, + "insufficient_evidence": 0, + }, + "order": ["created_at_asc", "id_asc"], + } + + assert second_status == 201 + assert second_payload["label"]["label"] == "outdated" + assert second_payload["label"]["note"] is None + assert second_payload["summary"] == { + "memory_id": seeded["memory_id"], + "total_count": 2, + "counts_by_label": { + "correct": 1, + "incorrect": 0, + "outdated": 1, + "insufficient_evidence": 0, + }, + "order": ["created_at_asc", "id_asc"], + } + + assert list_status == 200 + assert [item["id"] for item in list_payload["items"]] == [ + first_payload["label"]["id"], + second_payload["label"]["id"], + ] + assert list_payload["summary"] == second_payload["summary"] + + +def test_memory_review_label_listing_uses_deterministic_created_at_then_id_order( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_memory_for_review_labels(migrated_database_urls["app"]) + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + with user_connection(migrated_database_urls["app"], UUID(seeded["user_id"])) as conn: + store = ContinuityStore(conn) + created_labels = [ + store.create_memory_review_label( + memory_id=UUID(seeded["memory_id"]), + label="incorrect", + note="Conflicts with the source event.", + ), + store.create_memory_review_label( + memory_id=UUID(seeded["memory_id"]), + label="insufficient_evidence", + note="The evidence is too weak.", + ), + store.create_memory_review_label( + memory_id=UUID(seeded["memory_id"]), + label="outdated", + note="Superseded by newer behavior.", + ), + ] + + status_code, payload = invoke_request( + "GET", + f"/v0/memories/{seeded['memory_id']}/labels", + query_params={"user_id": seeded["user_id"]}, + ) + + expected_ids = [ + str(label["id"]) + for label in sorted( + created_labels, + key=lambda label: (label["created_at"], label["id"]), + ) + ] + + assert status_code == 200 + assert [item["id"] for item in payload["items"]] == expected_ids + assert payload["summary"] == { + "memory_id": seeded["memory_id"], + "total_count": 3, + "counts_by_label": { + "correct": 0, + "incorrect": 1, + "outdated": 1, + "insufficient_evidence": 1, + }, + "order": ["created_at_asc", "id_asc"], + } + + +def test_memory_review_label_list_returns_empty_items_and_zero_filled_summary_for_unlabeled_memory( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_memory_for_review_labels(migrated_database_urls["app"]) + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + status_code, payload = invoke_request( + "GET", + f"/v0/memories/{seeded['memory_id']}/labels", + query_params={"user_id": seeded["user_id"]}, + ) + + assert status_code == 200 + assert payload == { + "items": [], + "summary": { + "memory_id": seeded["memory_id"], + "total_count": 0, + "counts_by_label": { + "correct": 0, + "incorrect": 0, + "outdated": 0, + "insufficient_evidence": 0, + }, + "order": ["created_at_asc", "id_asc"], + }, + } + + +def test_memory_review_labels_reject_update_and_delete_at_database_level(migrated_database_urls) -> None: + seeded = seed_memory_for_review_labels(migrated_database_urls["app"]) + + with user_connection(migrated_database_urls["app"], UUID(seeded["user_id"])) as conn: + label = ContinuityStore(conn).create_memory_review_label( + memory_id=UUID(seeded["memory_id"]), + label="correct", + note="Initial review label.", + ) + + with psycopg.connect(migrated_database_urls["admin"]) as conn: + with pytest.raises(psycopg.Error, match="append-only"): + with conn.cursor() as cur: + cur.execute( + "UPDATE memory_review_labels SET label = 'incorrect' WHERE id = %s", + (label["id"],), + ) + + with psycopg.connect(migrated_database_urls["admin"]) as conn: + with pytest.raises(psycopg.Error, match="append-only"): + with conn.cursor() as cur: + cur.execute( + "DELETE FROM memory_review_labels WHERE id = %s", + (label["id"],), + ) + + +def test_memory_review_label_endpoints_enforce_per_user_isolation_and_not_found_behavior( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_memory_for_review_labels(migrated_database_urls["app"]) + intruder_id = seed_intruder(migrated_database_urls["app"]) + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + create_status, create_payload = invoke_request( + "POST", + f"/v0/memories/{seeded['memory_id']}/labels", + payload={ + "user_id": str(intruder_id), + "label": "incorrect", + "note": "Should not be able to label another user's memory.", + }, + ) + list_status, list_payload = invoke_request( + "GET", + f"/v0/memories/{seeded['memory_id']}/labels", + query_params={"user_id": str(intruder_id)}, + ) + + assert create_status == 404 + assert create_payload == {"detail": f"memory {seeded['memory_id']} was not found"} + assert list_status == 404 + assert list_payload == {"detail": f"memory {seeded['memory_id']} was not found"} diff --git a/tests/integration/test_migrations.py b/tests/integration/test_migrations.py new file mode 100644 index 0000000..a05d746 --- /dev/null +++ b/tests/integration/test_migrations.py @@ -0,0 +1,1324 @@ +from __future__ import annotations + +from alembic import command +import psycopg + +from alicebot_api.migrations import make_alembic_config + + +def test_tool_execution_task_step_linkage_migration_backfills_existing_rows(database_urls): + config = make_alembic_config(database_urls["admin"]) + user_id = "00000000-0000-0000-0000-000000000001" + thread_id = "00000000-0000-0000-0000-000000000002" + trace_id = "00000000-0000-0000-0000-000000000003" + tool_id = "00000000-0000-0000-0000-000000000004" + approval_id = "00000000-0000-0000-0000-000000000005" + task_id = "00000000-0000-0000-0000-000000000006" + task_step_id = "00000000-0000-0000-0000-000000000007" + execution_id = "00000000-0000-0000-0000-000000000008" + + command.upgrade(config, "20260313_0020") + + with psycopg.connect(database_urls["admin"]) as conn: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO users (id, email, display_name) + VALUES (%s, 'migration@example.com', 'Migration User') + """, + (user_id,), + ) + cur.execute( + """ + INSERT INTO threads (id, user_id, title) + VALUES (%s, %s, 'Migration Thread') + """, + (thread_id, user_id), + ) + cur.execute( + """ + INSERT INTO traces ( + id, + user_id, + thread_id, + kind, + compiler_version, + status, + limits + ) + VALUES ( + %s, + %s, + %s, + 'migration.seed', + 'v0', + 'completed', + '{}'::jsonb + ) + """, + (trace_id, user_id, thread_id), + ) + cur.execute( + """ + INSERT INTO tools ( + id, + user_id, + tool_key, + name, + description, + version, + metadata_version, + active, + tags, + action_hints, + scope_hints, + domain_hints, + risk_hints, + metadata + ) + VALUES ( + %s, + %s, + 'proxy.echo', + 'Proxy Echo', + 'Seed tool for migration coverage', + '1.0.0', + 'tool_metadata_v0', + TRUE, + '[]'::jsonb, + '[]'::jsonb, + '[]'::jsonb, + '[]'::jsonb, + '[]'::jsonb, + '{}'::jsonb + ) + """, + (tool_id, user_id), + ) + cur.execute( + """ + INSERT INTO approvals ( + id, + user_id, + thread_id, + tool_id, + task_step_id, + status, + request, + tool, + routing, + routing_trace_id, + resolved_at, + resolved_by_user_id + ) + VALUES ( + %s, + %s, + %s, + %s, + NULL, + 'approved', + '{"action":"echo"}'::jsonb, + '{"id":"tool"}'::jsonb, + '{"decision":"approval_required"}'::jsonb, + %s, + now(), + %s + ) + """, + (approval_id, user_id, thread_id, tool_id, trace_id, user_id), + ) + cur.execute( + """ + INSERT INTO tasks ( + id, + user_id, + thread_id, + tool_id, + status, + request, + tool, + latest_approval_id, + latest_execution_id + ) + VALUES ( + %s, + %s, + %s, + %s, + 'approved', + '{"action":"echo"}'::jsonb, + '{"id":"tool"}'::jsonb, + %s, + NULL + ) + """, + (task_id, user_id, thread_id, tool_id, approval_id), + ) + cur.execute( + """ + INSERT INTO task_steps ( + id, + user_id, + task_id, + sequence_no, + kind, + status, + request, + outcome, + trace_id, + trace_kind + ) + VALUES ( + %s, + %s, + %s, + 1, + 'governed_request', + 'approved', + '{"action":"echo"}'::jsonb, + '{"routing_decision":"approval_required","approval_id":"00000000-0000-0000-0000-000000000005","approval_status":"approved","execution_id":null,"execution_status":null,"blocked_reason":null}'::jsonb, + %s, + 'migration.seed' + ) + """, + (task_step_id, user_id, task_id, trace_id), + ) + cur.execute( + """ + INSERT INTO tool_executions ( + id, + user_id, + approval_id, + thread_id, + tool_id, + trace_id, + request_event_id, + result_event_id, + status, + handler_key, + request, + tool, + result + ) + VALUES ( + %s, + %s, + %s, + %s, + %s, + %s, + NULL, + NULL, + 'blocked', + NULL, + '{"action":"echo"}'::jsonb, + '{"id":"tool"}'::jsonb, + '{"blocked_reason":"seed"}'::jsonb + ) + """, + (execution_id, user_id, approval_id, thread_id, tool_id, trace_id), + ) + conn.commit() + + command.upgrade(config, "head") + + with psycopg.connect(database_urls["admin"]) as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT task_step_id + FROM tool_executions + WHERE id = %s + """, + (execution_id,), + ) + row = cur.fetchone() + assert row is not None + assert str(row[0]) == task_step_id + cur.execute( + """ + SELECT is_nullable + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'tool_executions' + AND column_name = 'task_step_id' + """ + ) + assert cur.fetchone() == ("NO",) + + +def test_gmail_account_credentials_migration_round_trip_preserves_tokens(database_urls): + config = make_alembic_config(database_urls["admin"]) + user_id = "00000000-0000-0000-0000-000000000101" + gmail_account_id = "00000000-0000-0000-0000-000000000102" + + command.upgrade(config, "20260316_0026") + + with psycopg.connect(database_urls["admin"]) as conn: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO users (id, email, display_name) + VALUES (%s, 'gmail-migration@example.com', 'Gmail Migration User') + """, + (user_id,), + ) + cur.execute( + """ + INSERT INTO gmail_accounts ( + id, + user_id, + provider_account_id, + email_address, + display_name, + scope, + access_token + ) + VALUES ( + %s, + %s, + 'acct-migration-001', + 'owner@gmail.example', + 'Owner', + 'https://www.googleapis.com/auth/gmail.readonly', + 'token-before-hardening' + ) + """, + (gmail_account_id, user_id), + ) + conn.commit() + + command.upgrade(config, "20260316_0027") + + with psycopg.connect(database_urls["admin"]) as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT column_name + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'gmail_accounts' + AND column_name = 'access_token' + """ + ) + assert cur.fetchone() is None + cur.execute( + """ + SELECT + auth_kind, + credential_blob ->> 'credential_kind', + credential_blob ->> 'access_token' + FROM gmail_account_credentials + WHERE gmail_account_id = %s + """, + (gmail_account_id,), + ) + assert cur.fetchone() == ( + "oauth_access_token", + "gmail_oauth_access_token_v1", + "token-before-hardening", + ) + + command.downgrade(config, "20260316_0026") + + with psycopg.connect(database_urls["admin"]) as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT column_name + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'gmail_accounts' + AND column_name = 'access_token' + """ + ) + assert cur.fetchone() == ("access_token",) + cur.execute( + """ + SELECT access_token + FROM gmail_accounts + WHERE id = %s + """, + (gmail_account_id,), + ) + assert cur.fetchone() == ("token-before-hardening",) + cur.execute("SELECT to_regclass('public.gmail_account_credentials')") + assert cur.fetchone() == (None,) + + +def test_gmail_refresh_token_lifecycle_migration_round_trip_preserves_downgrade_compatibility( + database_urls, +): + config = make_alembic_config(database_urls["admin"]) + user_id = "00000000-0000-0000-0000-000000000201" + gmail_account_id = "00000000-0000-0000-0000-000000000202" + + command.upgrade(config, "20260316_0027") + + with psycopg.connect(database_urls["admin"]) as conn: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO users (id, email, display_name) + VALUES (%s, 'gmail-refresh@example.com', 'Gmail Refresh User') + """, + (user_id,), + ) + cur.execute( + """ + INSERT INTO gmail_accounts ( + id, + user_id, + provider_account_id, + email_address, + display_name, + scope + ) + VALUES ( + %s, + %s, + 'acct-refresh-001', + 'owner@gmail.example', + 'Owner', + 'https://www.googleapis.com/auth/gmail.readonly' + ) + """, + (gmail_account_id, user_id), + ) + cur.execute( + """ + INSERT INTO gmail_account_credentials ( + gmail_account_id, + user_id, + auth_kind, + credential_blob + ) + VALUES ( + %s, + %s, + 'oauth_access_token', + jsonb_build_object( + 'credential_kind', 'gmail_oauth_access_token_v1', + 'access_token', 'token-before-refresh-lifecycle' + ) + ) + """, + (gmail_account_id, user_id), + ) + conn.commit() + + command.upgrade(config, "20260316_0028") + + with psycopg.connect(database_urls["admin"]) as conn: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE gmail_account_credentials + SET credential_blob = jsonb_build_object( + 'credential_kind', 'gmail_oauth_refresh_token_v2', + 'access_token', 'token-after-refresh', + 'refresh_token', 'refresh-001', + 'client_id', 'client-001', + 'client_secret', 'secret-001', + 'access_token_expires_at', '2030-01-01T00:05:00+00:00' + ) + WHERE gmail_account_id = %s + """, + (gmail_account_id,), + ) + cur.execute( + """ + SELECT + credential_blob ->> 'credential_kind', + credential_blob ->> 'access_token', + credential_blob ->> 'refresh_token' + FROM gmail_account_credentials + WHERE gmail_account_id = %s + """, + (gmail_account_id,), + ) + assert cur.fetchone() == ( + "gmail_oauth_refresh_token_v2", + "token-after-refresh", + "refresh-001", + ) + conn.commit() + + command.downgrade(config, "20260316_0027") + + with psycopg.connect(database_urls["admin"]) as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT + credential_blob ->> 'credential_kind', + credential_blob ->> 'access_token', + credential_blob ? 'refresh_token' + FROM gmail_account_credentials + WHERE gmail_account_id = %s + """, + (gmail_account_id,), + ) + assert cur.fetchone() == ( + "gmail_oauth_access_token_v1", + "token-after-refresh", + False, + ) + + +def test_gmail_external_secret_manager_migration_round_trip_preserves_legacy_transition_rows( + database_urls, +): + config = make_alembic_config(database_urls["admin"]) + user_id = "00000000-0000-0000-0000-000000000301" + gmail_account_id = "00000000-0000-0000-0000-000000000302" + + command.upgrade(config, "20260316_0028") + + with psycopg.connect(database_urls["admin"]) as conn: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO users (id, email, display_name) + VALUES (%s, 'gmail-secret-manager@example.com', 'Gmail Secret Manager User') + """, + (user_id,), + ) + cur.execute( + """ + INSERT INTO gmail_accounts ( + id, + user_id, + provider_account_id, + email_address, + display_name, + scope + ) + VALUES ( + %s, + %s, + 'acct-secret-manager-001', + 'owner@gmail.example', + 'Owner', + 'https://www.googleapis.com/auth/gmail.readonly' + ) + """, + (gmail_account_id, user_id), + ) + cur.execute( + """ + INSERT INTO gmail_account_credentials ( + gmail_account_id, + user_id, + auth_kind, + credential_blob + ) + VALUES ( + %s, + %s, + 'oauth_access_token', + jsonb_build_object( + 'credential_kind', 'gmail_oauth_refresh_token_v2', + 'access_token', 'token-before-externalization', + 'refresh_token', 'refresh-001', + 'client_id', 'client-001', + 'client_secret', 'secret-001', + 'access_token_expires_at', '2030-01-01T00:05:00+00:00' + ) + ) + """, + (gmail_account_id, user_id), + ) + conn.commit() + + command.upgrade(config, "20260316_0029") + + with psycopg.connect(database_urls["admin"]) as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT + credential_kind, + secret_manager_kind, + secret_ref, + credential_blob ->> 'access_token' + FROM gmail_account_credentials + WHERE gmail_account_id = %s + """, + (gmail_account_id,), + ) + assert cur.fetchone() == ( + "gmail_oauth_refresh_token_v2", + "legacy_db_v0", + None, + "token-before-externalization", + ) + + command.downgrade(config, "20260316_0028") + + with psycopg.connect(database_urls["admin"]) as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT + credential_blob ->> 'credential_kind', + credential_blob ->> 'access_token', + credential_blob ->> 'refresh_token' + FROM gmail_account_credentials + WHERE gmail_account_id = %s + """, + (gmail_account_id,), + ) + assert cur.fetchone() == ( + "gmail_oauth_refresh_token_v2", + "token-before-externalization", + "refresh-001", + ) + + +def test_calendar_account_migration_round_trip_preserves_table_shape(database_urls): + config = make_alembic_config(database_urls["admin"]) + user_id = "00000000-0000-0000-0000-000000000401" + calendar_account_id = "00000000-0000-0000-0000-000000000402" + + command.upgrade(config, "20260316_0029") + command.upgrade(config, "20260319_0030") + + with psycopg.connect(database_urls["admin"]) as conn: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO users (id, email, display_name) + VALUES (%s, 'calendar-migration@example.com', 'Calendar Migration User') + """, + (user_id,), + ) + cur.execute( + """ + INSERT INTO calendar_accounts ( + id, + user_id, + provider_account_id, + email_address, + display_name, + scope + ) + VALUES ( + %s, + %s, + 'acct-calendar-001', + 'owner@gmail.example', + 'Owner', + 'https://www.googleapis.com/auth/calendar.readonly' + ) + """, + (calendar_account_id, user_id), + ) + cur.execute( + """ + INSERT INTO calendar_account_credentials ( + calendar_account_id, + user_id, + auth_kind, + credential_kind, + secret_manager_kind, + secret_ref, + credential_blob + ) + VALUES ( + %s, + %s, + 'oauth_access_token', + 'calendar_oauth_access_token_v1', + 'file_v1', + 'users/00000000-0000-0000-0000-000000000401/calendar-account-credentials/cred.json', + NULL + ) + """, + (calendar_account_id, user_id), + ) + conn.commit() + + with psycopg.connect(database_urls["admin"]) as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT + auth_kind, + credential_kind, + secret_manager_kind, + secret_ref, + credential_blob IS NULL + FROM calendar_account_credentials + WHERE calendar_account_id = %s + """, + (calendar_account_id,), + ) + assert cur.fetchone() == ( + "oauth_access_token", + "calendar_oauth_access_token_v1", + "file_v1", + "users/00000000-0000-0000-0000-000000000401/calendar-account-credentials/cred.json", + True, + ) + + command.downgrade(config, "20260316_0029") + + with psycopg.connect(database_urls["admin"]) as conn: + with conn.cursor() as cur: + cur.execute("SELECT to_regclass('public.calendar_account_credentials')") + assert cur.fetchone() == (None,) + cur.execute("SELECT to_regclass('public.calendar_accounts')") + assert cur.fetchone() == (None,) + + +def test_migrations_upgrade_and_downgrade(database_urls): + config = make_alembic_config(database_urls["admin"]) + + command.upgrade(config, "head") + + with psycopg.connect(database_urls["admin"]) as conn: + with conn.cursor() as cur: + cur.execute("SELECT to_regclass('public.users')") + assert cur.fetchone()[0] == "users" + cur.execute("SELECT to_regclass('public.threads')") + assert cur.fetchone()[0] == "threads" + cur.execute("SELECT to_regclass('public.sessions')") + assert cur.fetchone()[0] == "sessions" + cur.execute("SELECT to_regclass('public.events')") + assert cur.fetchone()[0] == "events" + cur.execute("SELECT to_regclass('public.memories')") + assert cur.fetchone()[0] == "memories" + cur.execute( + """ + SELECT column_name + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'memories' + AND column_name IN ( + 'memory_type', + 'confidence', + 'salience', + 'confirmation_status', + 'trust_class', + 'promotion_eligibility', + 'evidence_count', + 'independent_source_count', + 'extracted_by_model', + 'trust_reason', + 'valid_from', + 'valid_to', + 'last_confirmed_at' + ) + ORDER BY column_name + """ + ) + assert cur.fetchall() == [ + ("confidence",), + ("confirmation_status",), + ("evidence_count",), + ("extracted_by_model",), + ("independent_source_count",), + ("last_confirmed_at",), + ("memory_type",), + ("promotion_eligibility",), + ("salience",), + ("trust_class",), + ("trust_reason",), + ("valid_from",), + ("valid_to",), + ] + cur.execute("SELECT to_regclass('public.memory_revisions')") + assert cur.fetchone()[0] == "memory_revisions" + cur.execute("SELECT to_regclass('public.memory_review_labels')") + assert cur.fetchone()[0] == "memory_review_labels" + cur.execute("SELECT to_regclass('public.entities')") + assert cur.fetchone()[0] == "entities" + cur.execute("SELECT to_regclass('public.entity_edges')") + assert cur.fetchone()[0] == "entity_edges" + cur.execute("SELECT to_regclass('public.embedding_configs')") + assert cur.fetchone()[0] == "embedding_configs" + cur.execute("SELECT to_regclass('public.memory_embeddings')") + assert cur.fetchone()[0] == "memory_embeddings" + cur.execute("SELECT to_regclass('public.consents')") + assert cur.fetchone()[0] == "consents" + cur.execute("SELECT to_regclass('public.policies')") + assert cur.fetchone()[0] == "policies" + cur.execute("SELECT to_regclass('public.tools')") + assert cur.fetchone()[0] == "tools" + cur.execute("SELECT to_regclass('public.approvals')") + assert cur.fetchone()[0] == "approvals" + cur.execute( + """ + SELECT column_name + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'approvals' + AND column_name = 'task_step_id' + """ + ) + assert cur.fetchall() == [("task_step_id",)] + cur.execute("SELECT to_regclass('public.tasks')") + assert cur.fetchone()[0] == "tasks" + cur.execute("SELECT to_regclass('public.task_workspaces')") + assert cur.fetchone()[0] == "task_workspaces" + cur.execute("SELECT to_regclass('public.task_artifacts')") + assert cur.fetchone()[0] == "task_artifacts" + cur.execute("SELECT to_regclass('public.task_artifact_chunks')") + assert cur.fetchone()[0] == "task_artifact_chunks" + cur.execute("SELECT to_regclass('public.task_artifact_chunk_embeddings')") + assert cur.fetchone()[0] == "task_artifact_chunk_embeddings" + cur.execute("SELECT to_regclass('public.task_steps')") + assert cur.fetchone()[0] == "task_steps" + cur.execute( + """ + SELECT column_name + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'task_steps' + AND column_name IN ( + 'parent_step_id', + 'source_approval_id', + 'source_execution_id' + ) + ORDER BY column_name + """ + ) + assert cur.fetchall() == [ + ("parent_step_id",), + ("source_approval_id",), + ("source_execution_id",), + ] + cur.execute("SELECT to_regclass('public.tool_executions')") + assert cur.fetchone()[0] == "tool_executions" + cur.execute( + """ + SELECT column_name + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'tool_executions' + AND column_name = 'task_step_id' + """ + ) + assert cur.fetchall() == [("task_step_id",)] + cur.execute("SELECT to_regclass('public.execution_budgets')") + assert cur.fetchone()[0] == "execution_budgets" + cur.execute( + """ + SELECT column_name + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'execution_budgets' + AND column_name IN ( + 'status', + 'deactivated_at', + 'superseded_by_budget_id', + 'supersedes_budget_id' + ) + ORDER BY column_name + """ + ) + assert cur.fetchall() == [ + ("deactivated_at",), + ("status",), + ("superseded_by_budget_id",), + ("supersedes_budget_id",), + ] + cur.execute( + """ + SELECT c.relname, c.relrowsecurity, c.relforcerowsecurity + FROM pg_class AS c + JOIN pg_namespace AS n + ON n.oid = c.relnamespace + WHERE n.nspname = 'public' + AND c.relname IN ( + 'users', + 'threads', + 'sessions', + 'events', + 'memories', + 'memory_revisions', + 'memory_review_labels', + 'entities', + 'entity_edges', + 'embedding_configs', + 'memory_embeddings', + 'consents', + 'policies', + 'tools', + 'approvals', + 'tasks', + 'task_workspaces', + 'task_artifacts', + 'task_artifact_chunks', + 'task_artifact_chunk_embeddings', + 'task_steps', + 'execution_budgets', + 'tool_executions' + ) + ORDER BY c.relname + """ + ) + assert cur.fetchall() == [ + ("approvals", True, True), + ("consents", True, True), + ("embedding_configs", True, True), + ("entities", True, True), + ("entity_edges", True, True), + ("events", True, True), + ("execution_budgets", True, True), + ("memories", True, True), + ("memory_embeddings", True, True), + ("memory_review_labels", True, True), + ("memory_revisions", True, True), + ("policies", True, True), + ("sessions", True, True), + ("task_artifact_chunk_embeddings", True, True), + ("task_artifact_chunks", True, True), + ("task_artifacts", True, True), + ("task_steps", True, True), + ("task_workspaces", True, True), + ("tasks", True, True), + ("threads", True, True), + ("tool_executions", True, True), + ("tools", True, True), + ("users", True, True), + ] + cur.execute( + """ + SELECT tgname + FROM pg_trigger + WHERE tgrelid = 'events'::regclass + AND NOT tgisinternal + """ + ) + assert cur.fetchall() == [("events_append_only",)] + cur.execute( + """ + SELECT tgname + FROM pg_trigger + WHERE tgrelid = 'memory_revisions'::regclass + AND NOT tgisinternal + """ + ) + assert cur.fetchall() == [("memory_revisions_append_only",)] + cur.execute( + """ + SELECT tgname + FROM pg_trigger + WHERE tgrelid = 'memory_review_labels'::regclass + AND NOT tgisinternal + """ + ) + assert cur.fetchall() == [("memory_review_labels_append_only",)] + cur.execute( + """ + SELECT + has_table_privilege('alicebot_app', 'users', 'UPDATE'), + has_table_privilege('alicebot_app', 'threads', 'UPDATE'), + has_table_privilege('alicebot_app', 'sessions', 'UPDATE'), + has_table_privilege('alicebot_app', 'memories', 'UPDATE'), + has_table_privilege('alicebot_app', 'memory_revisions', 'UPDATE'), + has_table_privilege('alicebot_app', 'memory_revisions', 'DELETE'), + has_table_privilege('alicebot_app', 'memory_review_labels', 'UPDATE'), + has_table_privilege('alicebot_app', 'memory_review_labels', 'DELETE'), + has_table_privilege('alicebot_app', 'entities', 'UPDATE'), + has_table_privilege('alicebot_app', 'entities', 'DELETE'), + has_table_privilege('alicebot_app', 'entity_edges', 'UPDATE'), + has_table_privilege('alicebot_app', 'entity_edges', 'DELETE'), + has_table_privilege('alicebot_app', 'embedding_configs', 'UPDATE'), + has_table_privilege('alicebot_app', 'embedding_configs', 'DELETE'), + has_table_privilege('alicebot_app', 'memory_embeddings', 'UPDATE'), + has_table_privilege('alicebot_app', 'memory_embeddings', 'DELETE'), + has_table_privilege('alicebot_app', 'consents', 'UPDATE'), + has_table_privilege('alicebot_app', 'consents', 'DELETE'), + has_table_privilege('alicebot_app', 'policies', 'UPDATE'), + has_table_privilege('alicebot_app', 'policies', 'DELETE'), + has_table_privilege('alicebot_app', 'tools', 'UPDATE'), + has_table_privilege('alicebot_app', 'tools', 'DELETE'), + has_table_privilege('alicebot_app', 'approvals', 'UPDATE'), + has_table_privilege('alicebot_app', 'approvals', 'DELETE'), + has_table_privilege('alicebot_app', 'tasks', 'UPDATE'), + has_table_privilege('alicebot_app', 'tasks', 'DELETE'), + has_table_privilege('alicebot_app', 'task_workspaces', 'UPDATE'), + has_table_privilege('alicebot_app', 'task_workspaces', 'DELETE'), + has_table_privilege('alicebot_app', 'task_artifacts', 'UPDATE'), + has_table_privilege('alicebot_app', 'task_artifacts', 'DELETE'), + has_table_privilege('alicebot_app', 'task_artifact_chunks', 'UPDATE'), + has_table_privilege('alicebot_app', 'task_artifact_chunks', 'DELETE'), + has_table_privilege('alicebot_app', 'task_artifact_chunk_embeddings', 'UPDATE'), + has_table_privilege('alicebot_app', 'task_artifact_chunk_embeddings', 'DELETE'), + has_table_privilege('alicebot_app', 'task_steps', 'UPDATE'), + has_table_privilege('alicebot_app', 'task_steps', 'DELETE'), + has_table_privilege('alicebot_app', 'execution_budgets', 'UPDATE'), + has_table_privilege('alicebot_app', 'execution_budgets', 'DELETE'), + has_table_privilege('alicebot_app', 'tool_executions', 'UPDATE'), + has_table_privilege('alicebot_app', 'tool_executions', 'DELETE') + """ + ) + assert cur.fetchone() == ( + False, + False, + False, + True, + False, + False, + False, + False, + False, + False, + False, + False, + False, + False, + True, + False, + True, + False, + False, + False, + False, + False, + True, + False, + True, + False, + False, + False, + True, + False, + False, + False, + True, + False, + True, + False, + True, + False, + False, + False, + ) + + command.downgrade(config, "20260314_0024") + + with psycopg.connect(database_urls["admin"]) as conn: + with conn.cursor() as cur: + cur.execute("SELECT to_regclass('public.task_artifact_chunk_embeddings')") + assert cur.fetchone()[0] is None + cur.execute("SELECT to_regclass('public.task_artifact_chunks')") + assert cur.fetchone()[0] == "task_artifact_chunks" + cur.execute("SELECT to_regclass('public.task_artifacts')") + assert cur.fetchone()[0] == "task_artifacts" + cur.execute("SELECT to_regclass('public.task_workspaces')") + assert cur.fetchone()[0] == "task_workspaces" + + command.downgrade(config, "20260313_0021") + + with psycopg.connect(database_urls["admin"]) as conn: + with conn.cursor() as cur: + cur.execute("SELECT to_regclass('public.task_artifact_chunks')") + assert cur.fetchone()[0] is None + cur.execute("SELECT to_regclass('public.task_artifact_chunk_embeddings')") + assert cur.fetchone()[0] is None + cur.execute("SELECT to_regclass('public.task_artifacts')") + assert cur.fetchone()[0] is None + cur.execute("SELECT to_regclass('public.task_workspaces')") + assert cur.fetchone()[0] is None + cur.execute( + """ + SELECT column_name + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'approvals' + AND column_name = 'task_step_id' + """ + ) + assert cur.fetchall() == [("task_step_id",)] + + command.downgrade(config, "20260313_0018") + + with psycopg.connect(database_urls["admin"]) as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT column_name + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'approvals' + AND column_name = 'task_step_id' + """ + ) + assert cur.fetchall() == [] + cur.execute("SELECT to_regclass('public.task_steps')") + assert cur.fetchone()[0] == "task_steps" + cur.execute( + """ + SELECT column_name + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'tool_executions' + AND column_name = 'task_step_id' + """ + ) + assert cur.fetchall() == [] + cur.execute( + """ + SELECT column_name + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'task_steps' + AND column_name IN ( + 'parent_step_id', + 'source_approval_id', + 'source_execution_id' + ) + ORDER BY column_name + """ + ) + assert cur.fetchall() == [] + cur.execute("SELECT to_regclass('public.tasks')") + assert cur.fetchone()[0] == "tasks" + + command.downgrade(config, "20260313_0017") + + with psycopg.connect(database_urls["admin"]) as conn: + with conn.cursor() as cur: + cur.execute("SELECT to_regclass('public.task_steps')") + assert cur.fetchone()[0] is None + cur.execute("SELECT to_regclass('public.tasks')") + assert cur.fetchone()[0] == "tasks" + + command.downgrade(config, "20260313_0014") + + with psycopg.connect(database_urls["admin"]) as conn: + with conn.cursor() as cur: + cur.execute("SELECT to_regclass('public.execution_budgets')") + assert cur.fetchone()[0] == "execution_budgets" + cur.execute("SELECT to_regclass('public.tasks')") + assert cur.fetchone()[0] is None + cur.execute("SELECT to_regclass('public.task_steps')") + assert cur.fetchone()[0] is None + cur.execute( + """ + SELECT column_name + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'execution_budgets' + AND column_name IN ( + 'status', + 'deactivated_at', + 'superseded_by_budget_id', + 'supersedes_budget_id' + ) + ORDER BY column_name + """ + ) + assert cur.fetchall() == [] + cur.execute( + "SELECT has_table_privilege('alicebot_app', 'execution_budgets', 'UPDATE')" + ) + assert cur.fetchone()[0] is False + + command.downgrade(config, "20260313_0013") + + with psycopg.connect(database_urls["admin"]) as conn: + with conn.cursor() as cur: + cur.execute("SELECT to_regclass('public.execution_budgets')") + assert cur.fetchone()[0] is None + cur.execute("SELECT to_regclass('public.tool_executions')") + assert cur.fetchone()[0] == "tool_executions" + + command.downgrade(config, "20260312_0012") + + with psycopg.connect(database_urls["admin"]) as conn: + with conn.cursor() as cur: + cur.execute("SELECT to_regclass('public.tool_executions')") + assert cur.fetchone()[0] is None + cur.execute("SELECT to_regclass('public.approvals')") + assert cur.fetchone()[0] == "approvals" + + command.downgrade(config, "20260312_0011") + + with psycopg.connect(database_urls["admin"]) as conn: + with conn.cursor() as cur: + cur.execute("SELECT to_regclass('public.approvals')") + assert cur.fetchone()[0] == "approvals" + cur.execute( + """ + SELECT + has_table_privilege('alicebot_app', 'approvals', 'UPDATE'), + EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'approvals' + AND column_name = 'resolved_at' + ), + EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'approvals' + AND column_name = 'resolved_by_user_id' + ) + """ + ) + assert cur.fetchone() == ( + False, + False, + False, + ) + + command.downgrade(config, "20260312_0010") + + with psycopg.connect(database_urls["admin"]) as conn: + with conn.cursor() as cur: + cur.execute("SELECT to_regclass('public.approvals')") + assert cur.fetchone()[0] is None + cur.execute("SELECT to_regclass('public.tools')") + assert cur.fetchone()[0] == "tools" + cur.execute("SELECT to_regclass('public.consents')") + assert cur.fetchone()[0] == "consents" + cur.execute("SELECT to_regclass('public.policies')") + assert cur.fetchone()[0] == "policies" + + command.downgrade(config, "20260312_0009") + + with psycopg.connect(database_urls["admin"]) as conn: + with conn.cursor() as cur: + cur.execute("SELECT to_regclass('public.approvals')") + assert cur.fetchone()[0] is None + cur.execute("SELECT to_regclass('public.tools')") + assert cur.fetchone()[0] is None + cur.execute("SELECT to_regclass('public.consents')") + assert cur.fetchone()[0] == "consents" + cur.execute("SELECT to_regclass('public.policies')") + assert cur.fetchone()[0] == "policies" + + command.downgrade(config, "20260312_0008") + + with psycopg.connect(database_urls["admin"]) as conn: + with conn.cursor() as cur: + cur.execute("SELECT to_regclass('public.consents')") + assert cur.fetchone()[0] is None + cur.execute("SELECT to_regclass('public.policies')") + assert cur.fetchone()[0] is None + cur.execute("SELECT to_regclass('public.tools')") + assert cur.fetchone()[0] is None + cur.execute("SELECT to_regclass('public.approvals')") + assert cur.fetchone()[0] is None + cur.execute("SELECT to_regclass('public.embedding_configs')") + assert cur.fetchone()[0] == "embedding_configs" + cur.execute("SELECT to_regclass('public.memory_embeddings')") + assert cur.fetchone()[0] == "memory_embeddings" + + command.downgrade(config, "20260312_0007") + + with psycopg.connect(database_urls["admin"]) as conn: + with conn.cursor() as cur: + cur.execute("SELECT to_regclass('public.embedding_configs')") + assert cur.fetchone()[0] is None + cur.execute("SELECT to_regclass('public.memory_embeddings')") + assert cur.fetchone()[0] is None + cur.execute("SELECT to_regclass('public.consents')") + assert cur.fetchone()[0] is None + cur.execute("SELECT to_regclass('public.policies')") + assert cur.fetchone()[0] is None + cur.execute("SELECT to_regclass('public.tools')") + assert cur.fetchone()[0] is None + cur.execute("SELECT to_regclass('public.approvals')") + assert cur.fetchone()[0] is None + cur.execute("SELECT to_regclass('public.memories')") + assert cur.fetchone()[0] == "memories" + cur.execute( + """ + SELECT column_name + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'memories' + AND column_name IN ( + 'memory_type', + 'confidence', + 'salience', + 'confirmation_status', + 'valid_from', + 'valid_to', + 'last_confirmed_at' + ) + ORDER BY column_name + """ + ) + assert cur.fetchall() == [] + cur.execute("SELECT to_regclass('public.entity_edges')") + assert cur.fetchone()[0] == "entity_edges" + + command.downgrade(config, "20260311_0003") + + with psycopg.connect(database_urls["admin"]) as conn: + with conn.cursor() as cur: + cur.execute("SELECT to_regclass('public.memories')") + assert cur.fetchone()[0] is None + cur.execute("SELECT to_regclass('public.memory_revisions')") + assert cur.fetchone()[0] is None + cur.execute("SELECT to_regclass('public.memory_review_labels')") + assert cur.fetchone()[0] is None + cur.execute("SELECT to_regclass('public.entities')") + assert cur.fetchone()[0] is None + cur.execute("SELECT to_regclass('public.entity_edges')") + assert cur.fetchone()[0] is None + cur.execute( + """ + SELECT + has_table_privilege('alicebot_app', 'users', 'UPDATE'), + has_table_privilege('alicebot_app', 'threads', 'UPDATE'), + has_table_privilege('alicebot_app', 'sessions', 'UPDATE') + """ + ) + # Revision 20260310_0001 already leaves the runtime role without UPDATE + # access, so downgrading from head must preserve that same privilege floor. + assert cur.fetchone() == (False, False, False) + + command.downgrade(config, "20260310_0001") + + command.downgrade(config, "base") + + with psycopg.connect(database_urls["admin"]) as conn: + with conn.cursor() as cur: + cur.execute("SELECT to_regclass('public.users')") + assert cur.fetchone()[0] is None + cur.execute("SELECT to_regclass('public.threads')") + assert cur.fetchone()[0] is None + cur.execute("SELECT to_regclass('public.sessions')") + assert cur.fetchone()[0] is None + cur.execute("SELECT to_regclass('public.events')") + assert cur.fetchone()[0] is None + cur.execute("SELECT to_regclass('public.memories')") + assert cur.fetchone()[0] is None + cur.execute("SELECT to_regclass('public.memory_revisions')") + assert cur.fetchone()[0] is None + cur.execute("SELECT to_regclass('public.memory_review_labels')") + assert cur.fetchone()[0] is None + cur.execute("SELECT to_regclass('public.entities')") + assert cur.fetchone()[0] is None + cur.execute("SELECT to_regclass('public.entity_edges')") + assert cur.fetchone()[0] is None + cur.execute("SELECT to_regclass('public.embedding_configs')") + assert cur.fetchone()[0] is None + cur.execute("SELECT to_regclass('public.memory_embeddings')") + assert cur.fetchone()[0] is None + cur.execute("SELECT to_regclass('public.consents')") + assert cur.fetchone()[0] is None + cur.execute("SELECT to_regclass('public.policies')") + assert cur.fetchone()[0] is None + cur.execute("SELECT to_regclass('public.tools')") + assert cur.fetchone()[0] is None + cur.execute( + """ + SELECT extname + FROM pg_extension + WHERE extname IN ('pgcrypto', 'vector') + ORDER BY extname + """ + ) + assert [row[0] for row in cur.fetchall()] == ["pgcrypto", "vector"] diff --git a/tests/integration/test_mvp_acceptance_suite.py b/tests/integration/test_mvp_acceptance_suite.py new file mode 100644 index 0000000..87aa315 --- /dev/null +++ b/tests/integration/test_mvp_acceptance_suite.py @@ -0,0 +1,451 @@ +from __future__ import annotations + +import json +import os +from typing import Any +from uuid import UUID, uuid4 + +import apps.api.src.alicebot_api.main as main_module +import alicebot_api.response_generation as response_generation_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.db import user_connection +from alicebot_api.store import ContinuityStore +import tests.integration.test_continuity_api as continuity_api +import tests.integration.test_context_compile as context_compile_api +import tests.integration.test_explicit_signal_capture_api as explicit_signal_capture_api +import tests.integration.test_memory_admission as memory_admission_api +import tests.integration.test_mvp_magnesium_reorder_flow as magnesium_flow_api +import tests.integration.test_proxy_execution_api as proxy_execution_api +import tests.integration.test_responses_api as responses_api +import tests.integration.test_traces_api as traces_api + + +INDUCED_FAILURE_ENV = "MVP_ACCEPTANCE_INDUCED_FAILURE_SCENARIO" + + +def _extract_context_payload_from_model_request(request: Any) -> dict[str, Any]: + for section in request.prompt.sections: + if section.name == "context": + return json.loads(section.content) + raise AssertionError("model request did not include a context section") + + +def _get_memory_by_key(context_payload: dict[str, Any], memory_key: str) -> dict[str, Any]: + return next(memory for memory in context_payload["memories"] if memory["memory_key"] == memory_key) + + +def _assert_not_induced_failure(scenario: str) -> None: + requested_scenario = os.getenv(INDUCED_FAILURE_ENV, "").strip() + if requested_scenario == scenario: + raise AssertionError( + f"induced failure requested for scenario '{scenario}' via {INDUCED_FAILURE_ENV}" + ) + + +def _seed_capture_to_resumption_acceptance(database_url: str) -> dict[str, UUID]: + user_id = uuid4() + + with user_connection(database_url, user_id) as conn: + store = ContinuityStore(conn) + store.create_user(user_id, "owner@example.com", "Owner") + thread = store.create_thread("Capture to resumption acceptance") + session = store.create_session(thread["id"], status="active") + preference_event = store.append_event( + thread["id"], + session["id"], + "message.user", + {"text": "I like black coffee."}, + )["id"] + commitment_event = store.append_event( + thread["id"], + session["id"], + "message.user", + {"text": "Remind me to submit tax forms."}, + )["id"] + store.append_event( + thread["id"], + session["id"], + "message.assistant", + {"text": "Noted."}, + ) + + return { + "user_id": user_id, + "thread_id": thread["id"], + "preference_event_id": preference_event, + "commitment_event_id": commitment_event, + } + + +def test_acceptance_explicit_signal_capture_flows_into_resumption_brief( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = _seed_capture_to_resumption_acceptance(migrated_database_urls["app"]) + preference_memory_key = explicit_signal_capture_api.build_preference_memory_key("black coffee") + commitment_memory_key = explicit_signal_capture_api.build_commitment_memory_key("submit tax forms") + + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + preference_capture_status, preference_capture_payload = explicit_signal_capture_api.invoke_request( + "POST", + "/v0/memories/capture-explicit-signals", + payload={ + "user_id": str(seeded["user_id"]), + "source_event_id": str(seeded["preference_event_id"]), + }, + ) + commitment_capture_status, commitment_capture_payload = explicit_signal_capture_api.invoke_request( + "POST", + "/v0/memories/capture-explicit-signals", + payload={ + "user_id": str(seeded["user_id"]), + "source_event_id": str(seeded["commitment_event_id"]), + }, + ) + + assert preference_capture_status == 200 + assert preference_capture_payload["preferences"]["candidates"] == [ + { + "memory_key": preference_memory_key, + "value": { + "kind": "explicit_preference", + "preference": "like", + "text": "black coffee", + }, + "source_event_ids": [str(seeded["preference_event_id"])], + "delete_requested": False, + "pattern": "i_like", + "subject_text": "black coffee", + } + ] + assert preference_capture_payload["preferences"]["admissions"][0]["decision"] == "ADD" + assert preference_capture_payload["summary"] == { + "source_event_id": str(seeded["preference_event_id"]), + "source_event_kind": "message.user", + "candidate_count": 1, + "admission_count": 1, + "persisted_change_count": 1, + "noop_count": 0, + "open_loop_created_count": 0, + "open_loop_noop_count": 0, + "preference_candidate_count": 1, + "preference_admission_count": 1, + "commitment_candidate_count": 0, + "commitment_admission_count": 0, + } + + assert commitment_capture_status == 200 + assert commitment_capture_payload["commitments"]["candidates"] == [ + { + "memory_key": commitment_memory_key, + "value": { + "kind": "explicit_commitment", + "text": "submit tax forms", + }, + "source_event_ids": [str(seeded["commitment_event_id"])], + "delete_requested": False, + "pattern": "remind_me_to", + "commitment_text": "submit tax forms", + "open_loop_title": "Remember to submit tax forms", + } + ] + assert commitment_capture_payload["commitments"]["admissions"][0]["decision"] == "ADD" + assert commitment_capture_payload["commitments"]["admissions"][0]["open_loop"]["decision"] == "CREATED" + assert commitment_capture_payload["summary"] == { + "source_event_id": str(seeded["commitment_event_id"]), + "source_event_kind": "message.user", + "candidate_count": 1, + "admission_count": 1, + "persisted_change_count": 1, + "noop_count": 0, + "open_loop_created_count": 1, + "open_loop_noop_count": 0, + "preference_candidate_count": 0, + "preference_admission_count": 0, + "commitment_candidate_count": 1, + "commitment_admission_count": 1, + } + + created_open_loop = commitment_capture_payload["commitments"]["admissions"][0]["open_loop"]["open_loop"] + commitment_memory = commitment_capture_payload["commitments"]["admissions"][0]["memory"] + assert created_open_loop is not None + assert commitment_memory is not None + + brief_status, brief_payload = continuity_api.invoke_request( + "GET", + f"/v0/threads/{seeded['thread_id']}/resumption-brief", + query_params={ + "user_id": str(seeded["user_id"]), + "max_events": "10", + "max_open_loops": "5", + "max_memories": "5", + }, + ) + + assert brief_status == 200 + brief = brief_payload["brief"] + assert brief["thread"]["id"] == str(seeded["thread_id"]) + assert brief["open_loops"]["summary"] == { + "limit": 5, + "returned_count": 1, + "total_count": 1, + "order": ["opened_at_desc", "created_at_desc", "id_desc"], + } + assert brief["open_loops"]["items"] == [ + { + "id": created_open_loop["id"], + "memory_id": commitment_memory["id"], + "title": "Remember to submit tax forms", + "status": "open", + "opened_at": created_open_loop["opened_at"], + "due_at": None, + "resolved_at": None, + "resolution_note": None, + "created_at": created_open_loop["created_at"], + "updated_at": created_open_loop["updated_at"], + } + ] + + assert brief["memory_highlights"]["summary"] == { + "limit": 5, + "returned_count": 2, + "total_count": 2, + "order": ["updated_at_asc", "created_at_asc", "id_asc"], + } + assert [item["memory_key"] for item in brief["memory_highlights"]["items"]] == [ + preference_memory_key, + commitment_memory_key, + ] + memory_highlights_by_key = { + item["memory_key"]: item for item in brief["memory_highlights"]["items"] + } + assert memory_highlights_by_key[preference_memory_key]["value"] == { + "kind": "explicit_preference", + "preference": "like", + "text": "black coffee", + } + assert memory_highlights_by_key[preference_memory_key]["source_event_ids"] == [ + str(seeded["preference_event_id"]) + ] + assert memory_highlights_by_key[commitment_memory_key]["value"] == { + "kind": "explicit_commitment", + "text": "submit tax forms", + } + assert memory_highlights_by_key[commitment_memory_key]["source_event_ids"] == [ + str(seeded["commitment_event_id"]) + ] + + _assert_not_induced_failure("capture_resumption") + + +def test_acceptance_response_path_uses_admitted_memory_and_preference_correction( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = responses_api.seed_response_thread(migrated_database_urls["app"]) + captured_context_payloads: list[dict[str, Any]] = [] + + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + model_provider="openai_responses", + model_name="gpt-5-mini", + model_api_key="test-key", + ), + ) + + def fake_invoke_model(*, settings, request): + del settings + context_payload = _extract_context_payload_from_model_request(request) + captured_context_payloads.append(context_payload) + coffee_memory = _get_memory_by_key(context_payload, "user.preference.coffee") + likes_value = coffee_memory["value"]["likes"] + return response_generation_module.ModelInvocationResponse( + provider="openai_responses", + model="gpt-5-mini", + response_id="resp_acceptance", + finish_reason="completed", + output_text=f"You prefer {likes_value}.", + usage={"input_tokens": 14, "output_tokens": 6, "total_tokens": 20}, + ) + + monkeypatch.setattr(response_generation_module, "invoke_model", fake_invoke_model) + + first_admit_status, first_admit_payload = memory_admission_api.invoke_admit_memory( + { + "user_id": str(seeded["user_id"]), + "memory_key": "user.preference.coffee", + "value": {"likes": "oat milk latte"}, + "source_event_ids": [str(seeded["prior_event_id"])], + } + ) + assert first_admit_status == 200 + assert first_admit_payload["decision"] == "UPDATE" + + first_response_status, first_response_payload = responses_api.invoke_generate_response( + { + "user_id": str(seeded["user_id"]), + "thread_id": str(seeded["thread_id"]), + "message": "What should I use in coffee?", + } + ) + assert first_response_status == 200 + assert first_response_payload["assistant"]["text"] == "You prefer oat milk latte." + assert first_response_payload["trace"]["compile_trace_event_count"] > 0 + first_context_memory = _get_memory_by_key( + captured_context_payloads[-1], + "user.preference.coffee", + ) + assert first_context_memory["value"] == {"likes": "oat milk latte"} + assert first_context_memory["source_event_ids"] == [str(seeded["prior_event_id"])] + + second_admit_status, second_admit_payload = memory_admission_api.invoke_admit_memory( + { + "user_id": str(seeded["user_id"]), + "memory_key": "user.preference.coffee", + "value": {"likes": "almond milk"}, + "source_event_ids": [str(seeded["prior_event_id"])], + } + ) + assert second_admit_status == 200 + assert second_admit_payload["decision"] == "UPDATE" + + compile_status, compile_payload = context_compile_api.invoke_compile_context( + { + "user_id": str(seeded["user_id"]), + "thread_id": str(seeded["thread_id"]), + } + ) + assert compile_status == 200 + compiled_memory = _get_memory_by_key( + compile_payload["context_pack"], + "user.preference.coffee", + ) + assert compiled_memory["value"] == {"likes": "almond milk"} + assert compiled_memory["source_event_ids"] == [str(seeded["prior_event_id"])] + + second_response_status, second_response_payload = responses_api.invoke_generate_response( + { + "user_id": str(seeded["user_id"]), + "thread_id": str(seeded["thread_id"]), + "message": "Confirm the corrected coffee preference.", + } + ) + assert second_response_status == 200 + assert second_response_payload["assistant"]["text"] == "You prefer almond milk." + assert second_response_payload["trace"]["response_trace_event_count"] == 2 + second_context_memory = _get_memory_by_key( + captured_context_payloads[-1], + "user.preference.coffee", + ) + assert second_context_memory["value"] == {"likes": "almond milk"} + + _assert_not_induced_failure("response_memory") + + +def test_acceptance_approval_lifecycle_resolution_execution_and_trace_availability( + migrated_database_urls, + monkeypatch, +) -> None: + owner = proxy_execution_api.seed_user(migrated_database_urls["app"], email="owner@example.com") + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + + tool_id = proxy_execution_api.create_tool_and_policy( + migrated_database_urls["app"], + user_id=owner["user_id"], + tool_key="proxy.echo", + ) + create_status, create_payload = proxy_execution_api.create_pending_approval( + user_id=owner["user_id"], + thread_id=owner["thread_id"], + tool_id=tool_id, + ) + assert create_status == 200 + assert create_payload["decision"] == "approval_required" + assert create_payload["approval"]["status"] == "pending" + assert create_payload["task"]["latest_approval_id"] == create_payload["approval"]["id"] + + approve_status, approve_payload = proxy_execution_api.invoke_request( + "POST", + f"/v0/approvals/{create_payload['approval']['id']}/approve", + payload={"user_id": str(owner["user_id"])}, + ) + assert approve_status == 200 + assert approve_payload["approval"]["status"] == "approved" + + execute_status, execute_payload = proxy_execution_api.invoke_request( + "POST", + f"/v0/approvals/{create_payload['approval']['id']}/execute", + payload={"user_id": str(owner["user_id"])}, + ) + assert execute_status == 200 + assert execute_payload["approval"]["id"] == create_payload["approval"]["id"] + assert execute_payload["approval"]["status"] == "approved" + assert execute_payload["result"]["status"] == "completed" + assert isinstance(execute_payload["events"]["request_event_id"], str) + assert isinstance(execute_payload["events"]["result_event_id"], str) + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + store = ContinuityStore(conn) + tasks = store.list_tasks() + task_steps = store.list_task_steps_for_task(tasks[0]["id"]) + tool_executions = store.list_tool_executions() + + assert len(tasks) == 1 + assert len(task_steps) == 1 + assert len(tool_executions) == 1 + assert tasks[0]["latest_approval_id"] == UUID(create_payload["approval"]["id"]) + assert tasks[0]["latest_execution_id"] == tool_executions[0]["id"] + assert tool_executions[0]["approval_id"] == UUID(create_payload["approval"]["id"]) + assert tool_executions[0]["task_step_id"] == task_steps[0]["id"] + + trace_list_status, trace_list_payload = traces_api.invoke_request( + "GET", + "/v0/traces", + query_params={"user_id": str(owner["user_id"])}, + ) + assert trace_list_status == 200 + + trace_ids = {item["id"] for item in trace_list_payload["items"]} + approval_trace_id = create_payload["trace"]["trace_id"] + execution_trace_id = execute_payload["trace"]["trace_id"] + assert approval_trace_id in trace_ids + assert execution_trace_id in trace_ids + + trace_detail_status, trace_detail_payload = traces_api.invoke_request( + "GET", + f"/v0/traces/{execution_trace_id}", + query_params={"user_id": str(owner["user_id"])}, + ) + trace_events_status, trace_events_payload = traces_api.invoke_request( + "GET", + f"/v0/traces/{execution_trace_id}/events", + query_params={"user_id": str(owner["user_id"])}, + ) + assert trace_detail_status == 200 + assert trace_detail_payload["trace"]["kind"] == "tool.proxy.execute" + assert trace_events_status == 200 + assert trace_events_payload["summary"]["total_count"] >= 1 + event_kinds = [event["kind"] for event in trace_events_payload["items"]] + assert "tool.proxy.execute.request" in event_kinds + assert "tool.proxy.execute.summary" in event_kinds + + _assert_not_induced_failure("approval_execution") + + +def test_acceptance_canonical_magnesium_reorder_flow_with_memory_write_back_evidence( + migrated_database_urls, + monkeypatch, +) -> None: + magnesium_flow_api.test_mvp_magnesium_reorder_flow_proves_ship_gate_evidence( + migrated_database_urls, + monkeypatch, + ) + _assert_not_induced_failure("magnesium_reorder") diff --git a/tests/integration/test_mvp_magnesium_reorder_flow.py b/tests/integration/test_mvp_magnesium_reorder_flow.py new file mode 100644 index 0000000..d7aa2e0 --- /dev/null +++ b/tests/integration/test_mvp_magnesium_reorder_flow.py @@ -0,0 +1,244 @@ +from __future__ import annotations + +import json +from typing import Any +from urllib.parse import urlencode +from uuid import UUID, uuid4 + +import anyio + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.db import user_connection +from alicebot_api.store import ContinuityStore + + +def invoke_request( + method: str, + path: str, + *, + query_params: dict[str, str] | None = None, + payload: dict[str, Any] | None = None, +) -> tuple[int, dict[str, Any]]: + messages: list[dict[str, object]] = [] + encoded_body = b"" if payload is None else json.dumps(payload).encode() + request_received = False + + async def receive() -> dict[str, object]: + nonlocal request_received + if request_received: + return {"type": "http.disconnect"} + + request_received = True + return {"type": "http.request", "body": encoded_body, "more_body": False} + + async def send(message: dict[str, object]) -> None: + messages.append(message) + + query_string = urlencode(query_params or {}).encode() + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": query_string, + "headers": [(b"content-type", b"application/json")], + "client": ("testclient", 50000), + "server": ("testserver", 80), + "root_path": "", + } + + anyio.run(main_module.app, scope, receive, send) + + start_message = next(message for message in messages if message["type"] == "http.response.start") + body = b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + return start_message["status"], json.loads(body) + + +def seed_user(database_url: str) -> dict[str, UUID]: + user_id = uuid4() + + with user_connection(database_url, user_id) as conn: + store = ContinuityStore(conn) + store.create_user(user_id, "owner@example.com", "Owner") + thread = store.create_thread("Magnesium reorder thread") + + return { + "user_id": user_id, + "thread_id": thread["id"], + } + + +def create_magnesium_tool_and_policy(database_url: str, *, user_id: UUID) -> UUID: + with user_connection(database_url, user_id) as conn: + store = ContinuityStore(conn) + tool = store.create_tool( + tool_key="proxy.echo", + name="Merchant Proxy", + description="Deterministic proxy tool", + version="1.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["proxy", "commerce"], + action_hints=["place_order"], + scope_hints=["supplements"], + domain_hints=["ecommerce"], + risk_hints=["purchase"], + metadata={"transport": "proxy"}, + ) + store.create_policy( + name="Require approval for supplement orders", + action="place_order", + scope="supplements", + effect="require_approval", + priority=10, + active=True, + conditions={"tool_key": "proxy.echo"}, + required_consents=[], + ) + + return tool["id"] + + +def test_mvp_magnesium_reorder_flow_proves_ship_gate_evidence( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_user(migrated_database_urls["app"]) + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + tool_id = create_magnesium_tool_and_policy( + migrated_database_urls["app"], + user_id=owner["user_id"], + ) + + create_status, create_payload = invoke_request( + "POST", + "/v0/approvals/requests", + payload={ + "user_id": str(owner["user_id"]), + "thread_id": str(owner["thread_id"]), + "tool_id": str(tool_id), + "action": "place_order", + "scope": "supplements", + "domain_hint": "ecommerce", + "risk_hint": "purchase", + "attributes": { + "merchant": "Thorne", + "item": "Magnesium Bisglycinate", + "quantity": "1", + "package": "90 capsules", + }, + }, + ) + assert create_status == 200 + assert create_payload["approval"]["status"] == "pending" + + approve_status, approve_payload = invoke_request( + "POST", + f"/v0/approvals/{create_payload['approval']['id']}/approve", + payload={"user_id": str(owner["user_id"])}, + ) + assert approve_status == 200 + assert approve_payload["approval"]["status"] == "approved" + + execute_status, execute_payload = invoke_request( + "POST", + f"/v0/approvals/{create_payload['approval']['id']}/execute", + payload={"user_id": str(owner["user_id"])}, + ) + assert execute_status == 200 + assert execute_payload["approval"]["id"] == create_payload["approval"]["id"] + assert execute_payload["result"]["status"] == "completed" + assert execute_payload["events"] is not None + + result_event_id = execute_payload["events"]["result_event_id"] + request_event_id = execute_payload["events"]["request_event_id"] + assert isinstance(result_event_id, str) + assert isinstance(request_event_id, str) + + add_status, add_payload = invoke_request( + "POST", + "/v0/memories/admit", + payload={ + "user_id": str(owner["user_id"]), + "memory_key": "user.preference.supplement.magnesium_reorder", + "value": { + "merchant": "Thorne", + "item": "Magnesium Bisglycinate", + "quantity": "1", + "package": "90 capsules", + }, + "source_event_ids": [result_event_id, request_event_id], + "delete_requested": False, + }, + ) + assert add_status == 200 + assert add_payload["decision"] == "ADD" + assert add_payload["memory"] is not None + assert add_payload["revision"] is not None + + update_status, update_payload = invoke_request( + "POST", + "/v0/memories/admit", + payload={ + "user_id": str(owner["user_id"]), + "memory_key": "user.preference.supplement.magnesium_reorder", + "value": { + "merchant": "Thorne", + "item": "Magnesium Bisglycinate", + "quantity": "2", + "package": "90 capsules", + }, + "source_event_ids": [result_event_id], + "delete_requested": False, + }, + ) + assert update_status == 200 + assert update_payload["decision"] == "UPDATE" + + memory_id = UUID(update_payload["memory"]["id"]) + + list_status, list_payload = invoke_request( + "GET", + "/v0/memories", + query_params={"user_id": str(owner["user_id"]), "status": "all", "limit": "10"}, + ) + revisions_status, revisions_payload = invoke_request( + "GET", + f"/v0/memories/{memory_id}/revisions", + query_params={"user_id": str(owner["user_id"]), "limit": "10"}, + ) + + assert list_status == 200 + assert revisions_status == 200 + assert list_payload["items"][0]["id"] == str(memory_id) + assert list_payload["items"][0]["memory_key"] == "user.preference.supplement.magnesium_reorder" + assert list_payload["items"][0]["value"]["quantity"] == "2" + assert list_payload["items"][0]["source_event_ids"] == [result_event_id] + + assert [revision["action"] for revision in revisions_payload["items"]] == ["ADD", "UPDATE"] + assert revisions_payload["items"][0]["source_event_ids"] == [result_event_id, request_event_id] + assert revisions_payload["items"][1]["source_event_ids"] == [result_event_id] + assert revisions_payload["items"][0]["new_value"]["quantity"] == "1" + assert revisions_payload["items"][1]["new_value"]["quantity"] == "2" + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + store = ContinuityStore(conn) + memories = store.list_memories() + stored_revisions = store.list_memory_revisions(memory_id) + + assert len(memories) == 1 + assert memories[0]["memory_key"] == "user.preference.supplement.magnesium_reorder" + assert memories[0]["source_event_ids"] == [result_event_id] + assert [revision["action"] for revision in stored_revisions] == ["ADD", "UPDATE"] diff --git a/tests/integration/test_mvp_readiness_gates.py b/tests/integration/test_mvp_readiness_gates.py new file mode 100644 index 0000000..0d96bd7 --- /dev/null +++ b/tests/integration/test_mvp_readiness_gates.py @@ -0,0 +1,342 @@ +from __future__ import annotations + +import contextlib +from types import SimpleNamespace +from uuid import UUID, uuid4 + +import scripts.run_mvp_readiness_gates as mvp_readiness_alias +import scripts.run_phase2_readiness_gates as readiness_gates + + +def test_latency_p95_calculation_is_deterministic() -> None: + durations = [0.42, 0.31, 0.55, 0.29, 0.48, 0.61, 0.50, 0.33, 0.45, 0.58] + + p95 = readiness_gates.calculate_p95_seconds(durations) + + assert p95 == 0.61 + + +def test_cache_reuse_ratio_requires_cached_telemetry_for_all_samples() -> None: + ratio = readiness_gates.calculate_cache_reuse_ratio( + [ + { + "input_tokens": 100, + "output_tokens": 20, + "total_tokens": 120, + "cached_input_tokens": 80, + }, + { + "input_tokens": 100, + "output_tokens": 20, + "total_tokens": 120, + }, + ] + ) + + assert ratio is None + + +def test_cache_reuse_gate_enforces_threshold_math() -> None: + gate = readiness_gates._evaluate_cache_reuse_gate( + [ + { + "input_tokens": 100, + "output_tokens": 20, + "total_tokens": 120, + "cached_input_tokens": 50, + }, + { + "input_tokens": 100, + "output_tokens": 20, + "total_tokens": 120, + "cached_input_tokens": 50, + }, + ] + ) + + assert gate.gate == readiness_gates.CACHE_GATE_NAME + assert gate.status == "FAIL" + assert gate.measured == "cache_reuse_ratio=0.500000" + assert gate.threshold == "cache_reuse_ratio >= 0.70" + + +def test_memory_quality_gate_posture_alignment() -> None: + pass_gate = readiness_gates._evaluate_memory_quality_gate( + { + "label_row_counts_by_value": {"correct": 17, "incorrect": 3}, + "unlabeled_memory_count": 0, + } + ) + fail_gate = readiness_gates._evaluate_memory_quality_gate( + { + "label_row_counts_by_value": {"correct": 15, "incorrect": 5}, + "unlabeled_memory_count": 0, + } + ) + blocked_gate = readiness_gates._evaluate_memory_quality_gate( + { + "label_row_counts_by_value": {"correct": 9, "incorrect": 1}, + "unlabeled_memory_count": 0, + } + ) + + assert pass_gate.status == "PASS" + assert "posture=on_track" in pass_gate.measured + assert fail_gate.status == "FAIL" + assert "posture=needs_review" in fail_gate.measured + assert blocked_gate.status == "BLOCKED" + assert "posture=insufficient_evidence" in blocked_gate.measured + assert pass_gate.threshold == "precision > 0.80 and adjudicated_sample >= 20" + + +def test_memory_quality_gate_rejects_precision_boundary_at_point_80() -> None: + boundary_gate = readiness_gates._evaluate_memory_quality_gate( + { + "label_row_counts_by_value": {"correct": 16, "incorrect": 4}, + "unlabeled_memory_count": 0, + } + ) + + assert boundary_gate.status == "FAIL" + assert "precision=0.800000" in boundary_gate.measured + assert "posture=needs_review" in boundary_gate.measured + + +def test_memory_quality_gate_blocks_when_sample_is_below_20_even_with_perfect_precision() -> None: + blocked_gate = readiness_gates._evaluate_memory_quality_gate( + { + "label_row_counts_by_value": {"correct": 19, "incorrect": 0}, + "unlabeled_memory_count": 0, + } + ) + + assert blocked_gate.status == "BLOCKED" + assert "adjudicated_sample=19" in blocked_gate.measured + assert "posture=insufficient_evidence" in blocked_gate.measured + + +def test_memory_capture_message_profiles_are_deterministic() -> None: + on_track_messages = readiness_gates._build_memory_capture_messages(profile="on_track") + needs_review_messages = readiness_gates._build_memory_capture_messages(profile="needs_review") + insufficient_messages = readiness_gates._build_memory_capture_messages(profile="insufficient_evidence") + + assert len(on_track_messages) == 20 + assert len(set(on_track_messages)) == 20 + + assert len(needs_review_messages) == 20 + assert len(set(needs_review_messages)) == 16 + assert needs_review_messages[-4:] == needs_review_messages[:4] + + assert len(insufficient_messages) == 10 + assert len(set(insufficient_messages)) == 9 + assert insufficient_messages[-1] == insufficient_messages[0] + + +def test_capture_adjudication_maps_admission_decisions_to_memory_review_labels() -> None: + add_memory_id = str(uuid4()) + noop_memory_id = str(uuid4()) + + adjudications = readiness_gates._adjudicate_capture_admissions( + [ + {"decision": "ADD", "memory": {"id": add_memory_id}}, + {"decision": "NOOP", "memory": {"id": noop_memory_id}}, + ] + ) + + assert adjudications[0].memory_id == UUID(add_memory_id) + assert adjudications[0].label == "correct" + assert adjudications[1].label == "incorrect" + + +def test_run_readiness_gates_routes_memory_profiles_through_capture_derived_path(monkeypatch) -> None: + monkeypatch.setattr( + readiness_gates, + "_run_acceptance_suite_gate", + lambda *, induce_failure: readiness_gates.GateResult( + gate=readiness_gates.ACCEPTANCE_GATE_NAME, + status="PASS", + measured="exit_code=0", + threshold="exit_code == 0", + detail=f"induce_failure={induce_failure}", + ), + ) + + @contextlib.contextmanager + def fake_database_context(): + yield {"admin": "postgresql://admin/db", "app": "postgresql://app/db"} + + monkeypatch.setattr(readiness_gates, "_temporary_database_urls", fake_database_context) + monkeypatch.setattr(readiness_gates, "make_alembic_config", lambda _: object()) + monkeypatch.setattr(readiness_gates.command, "upgrade", lambda *_args: None) + monkeypatch.setattr( + readiness_gates, + "_seed_probe_state", + lambda _database_url: { + "user_id": uuid4(), + "thread_id": uuid4(), + "session_id": uuid4(), + "source_event_id": uuid4(), + }, + ) + monkeypatch.setattr( + readiness_gates, + "_run_response_probes", + lambda **_kwargs: readiness_gates.ProbeRun( + durations_seconds=[0.1] * readiness_gates.PROBE_CALL_COUNT, + usages=[ + { + "input_tokens": 100, + "output_tokens": 20, + "total_tokens": 120, + "cached_input_tokens": 80, + } + for _ in range(readiness_gates.PROBE_CALL_COUNT) + ], + ), + ) + + captured_profiles: list[str] = [] + + def fake_capture_and_adjudicate(**kwargs) -> None: # noqa: ANN003 + captured_profiles.append(kwargs["profile"]) + + monkeypatch.setattr( + readiness_gates, + "_capture_and_adjudicate_memory_quality_sample", + fake_capture_and_adjudicate, + ) + + def fake_fetch_memory_summary(*, settings, user_id): # noqa: ANN001 + del settings + del user_id + profile = captured_profiles[-1] + if profile == "on_track": + return { + "label_row_counts_by_value": {"correct": 20, "incorrect": 0}, + "unlabeled_memory_count": 0, + } + if profile == "needs_review": + return { + "label_row_counts_by_value": {"correct": 16, "incorrect": 4}, + "unlabeled_memory_count": 0, + } + return { + "label_row_counts_by_value": {"correct": 9, "incorrect": 1}, + "unlabeled_memory_count": 0, + } + + monkeypatch.setattr(readiness_gates, "_fetch_memory_summary", fake_fetch_memory_summary) + + scenarios = [ + (None, "on_track", "PASS", "posture=on_track"), + ("memory_needs_review", "needs_review", "FAIL", "posture=needs_review"), + ("memory_insufficient", "insufficient_evidence", "BLOCKED", "posture=insufficient_evidence"), + ] + for induced_gate, expected_profile, expected_status, expected_posture in scenarios: + gates = readiness_gates.run_readiness_gates(induce_gate=induced_gate) + memory_gate = next(gate for gate in gates if gate.gate == readiness_gates.MEMORY_GATE_NAME) + assert captured_profiles[-1] == expected_profile + assert memory_gate.status == expected_status + assert expected_posture in memory_gate.measured + + +def test_exit_code_is_non_zero_when_any_gate_is_failed_or_blocked() -> None: + pass_only = [ + readiness_gates.GateResult( + gate="acceptance_suite", + status="PASS", + measured="exit_code=0", + threshold="exit_code == 0", + detail="ok", + ) + ] + with_failure = [ + *pass_only, + readiness_gates.GateResult( + gate="latency_p95", + status="FAIL", + measured="p95_seconds=5.200000", + threshold="p95_seconds < 5.0", + detail="probe_count=8", + ), + ] + with_blocked = [ + *pass_only, + readiness_gates.GateResult( + gate="cache_reuse", + status="BLOCKED", + measured="cache_reuse_ratio=unavailable", + threshold="cache_reuse_ratio >= 0.70", + detail="missing telemetry", + ), + ] + + assert readiness_gates.exit_code_for_gate_results(pass_only) == 0 + assert readiness_gates.exit_code_for_gate_results(with_failure) == 1 + assert readiness_gates.exit_code_for_gate_results(with_blocked) == 1 + + +def test_run_readiness_gates_returns_blocked_when_probe_setup_fails(monkeypatch) -> None: + monkeypatch.setattr( + readiness_gates, + "_run_acceptance_suite_gate", + lambda *, induce_failure: readiness_gates.GateResult( + gate=readiness_gates.ACCEPTANCE_GATE_NAME, + status="PASS", + measured="exit_code=0", + threshold="exit_code == 0", + detail=f"induce_failure={induce_failure}", + ), + ) + + @contextlib.contextmanager + def broken_database_context(): + raise RuntimeError("probe setup unavailable") + yield + + monkeypatch.setattr(readiness_gates, "_temporary_database_urls", broken_database_context) + + gates = readiness_gates.run_readiness_gates() + + assert [gate.gate for gate in gates] == [ + readiness_gates.ACCEPTANCE_GATE_NAME, + readiness_gates.LATENCY_GATE_NAME, + readiness_gates.CACHE_GATE_NAME, + readiness_gates.MEMORY_GATE_NAME, + ] + assert gates[0].status == "PASS" + assert gates[1].status == "BLOCKED" + assert gates[2].status == "BLOCKED" + assert gates[3].status == "BLOCKED" + assert "probe setup unavailable" in gates[1].detail + + +def test_mvp_readiness_alias_forwards_to_phase2_entrypoint(monkeypatch, capsys) -> None: + forwarded_args = ["--induce-gate", "cache_fail"] + captured: dict[str, object] = {} + + def fake_run(command, *, cwd, check): # noqa: ANN001 + captured["command"] = command + captured["cwd"] = cwd + captured["check"] = check + return SimpleNamespace(returncode=19) + + monkeypatch.setattr(mvp_readiness_alias, "_resolve_python_executable", lambda: "/usr/bin/python3") + monkeypatch.setattr( + mvp_readiness_alias.sys, + "argv", + ["scripts/run_mvp_readiness_gates.py", *forwarded_args], + ) + monkeypatch.setattr(mvp_readiness_alias.subprocess, "run", fake_run) + + exit_code = mvp_readiness_alias.main() + output = capsys.readouterr().out + + assert exit_code == 19 + assert ( + captured["command"] + == ["/usr/bin/python3", str(mvp_readiness_alias.TARGET_SCRIPT), *forwarded_args] + ) + assert captured["cwd"] == mvp_readiness_alias.ROOT_DIR + assert captured["check"] is False + assert "MVP readiness compatibility alias -> scripts/run_phase2_readiness_gates.py" in output diff --git a/tests/integration/test_mvp_validation_matrix.py b/tests/integration/test_mvp_validation_matrix.py new file mode 100644 index 0000000..8c59361 --- /dev/null +++ b/tests/integration/test_mvp_validation_matrix.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +import scripts.run_mvp_validation_matrix as mvp_validation_alias +import scripts.run_phase2_validation_matrix as validation_matrix + + +def _always_pass_executor(command: tuple[str, ...], cwd: Path) -> int: + del cwd + if "-c" in command and "Induced validation-matrix failure" in command[-1]: + return validation_matrix.INDUCED_FAILURE_EXIT_CODE + return 0 + + +def test_matrix_sequence_contains_readiness_backend_and_web_surfaces() -> None: + steps = validation_matrix.build_validation_matrix_steps(python_executable="/usr/bin/python3") + + assert [step.step for step in steps] == [ + validation_matrix.STEP_CONTROL_DOC_TRUTH, + validation_matrix.STEP_GATE_CONTRACT_TESTS, + validation_matrix.STEP_READINESS_GATES, + validation_matrix.STEP_BACKEND_MATRIX, + validation_matrix.STEP_WEB_MATRIX, + ] + + control_doc_truth = steps[0] + assert control_doc_truth.command == ("/usr/bin/python3", "scripts/check_control_doc_truth.py") + + gate_contract_tests = steps[1] + assert gate_contract_tests.command == ( + "/usr/bin/python3", + "-m", + "pytest", + "-q", + *validation_matrix.GATE_CONTRACT_TEST_FILES, + ) + + readiness = steps[2] + assert readiness.command == ("/usr/bin/python3", "scripts/run_phase2_readiness_gates.py") + + backend = steps[3] + assert backend.command == ( + "/usr/bin/python3", + "-m", + "pytest", + "-q", + *validation_matrix.BACKEND_INTEGRATION_TEST_FILES, + ) + + web = steps[4] + assert web.command == ( + "npm", + "--prefix", + str(validation_matrix.WEB_DIR), + "run", + "test:mvp:validation-matrix", + ) + assert web.coverage == ", ".join(validation_matrix.WEB_OPERATOR_SURFACES) + + +def test_exit_code_contract_is_zero_only_when_all_steps_pass() -> None: + all_pass = [ + validation_matrix.MatrixStepResult( + step=validation_matrix.STEP_CONTROL_DOC_TRUTH, + status="PASS", + exit_code=0, + duration_seconds=0.100, + command=("python3", "scripts/check_control_doc_truth.py"), + coverage="control doc truth markers", + induced_failure=False, + ), + validation_matrix.MatrixStepResult( + step=validation_matrix.STEP_GATE_CONTRACT_TESTS, + status="PASS", + exit_code=0, + duration_seconds=0.200, + command=("python3", "-m", "pytest", "-q", "tests/integration/test_mvp_readiness_gates.py"), + coverage="gate contracts", + induced_failure=False, + ), + validation_matrix.MatrixStepResult( + step=validation_matrix.STEP_READINESS_GATES, + status="PASS", + exit_code=0, + duration_seconds=0.120, + command=("python3", "scripts/run_phase2_readiness_gates.py"), + coverage="acceptance_suite, latency_p95, cache_reuse, memory_quality", + induced_failure=False, + ), + validation_matrix.MatrixStepResult( + step=validation_matrix.STEP_BACKEND_MATRIX, + status="PASS", + exit_code=0, + duration_seconds=9.321, + command=("python3", "-m", "pytest", "-q"), + coverage="backend seams", + induced_failure=False, + ), + ] + with_failure = [ + *all_pass, + validation_matrix.MatrixStepResult( + step=validation_matrix.STEP_WEB_MATRIX, + status="FAIL", + exit_code=1, + duration_seconds=4.210, + command=("npm", "--prefix", "apps/web", "run", "test:mvp:validation-matrix"), + coverage="/chat, /approvals", + induced_failure=False, + ), + ] + + assert validation_matrix.exit_code_for_step_results(all_pass) == 0 + assert validation_matrix.exit_code_for_step_results(with_failure) == 1 + + +def test_induced_step_failure_reports_explicit_failing_step(capsys) -> None: + results = validation_matrix.run_validation_matrix( + induce_step=validation_matrix.STEP_GATE_CONTRACT_TESTS, + execute_command=_always_pass_executor, + ) + validation_matrix._print_step_results(results) + output = capsys.readouterr().out + + assert len(results) == 5 + assert [result.step for result in results] == [ + validation_matrix.STEP_CONTROL_DOC_TRUTH, + validation_matrix.STEP_GATE_CONTRACT_TESTS, + validation_matrix.STEP_READINESS_GATES, + validation_matrix.STEP_BACKEND_MATRIX, + validation_matrix.STEP_WEB_MATRIX, + ] + assert results[0].status == "PASS" + assert results[1].status == "FAIL" + assert results[1].exit_code == validation_matrix.INDUCED_FAILURE_EXIT_CODE + assert results[1].induced_failure is True + assert all(result.status == "PASS" for result in results[2:]) + + assert "Phase 2 validation matrix results:" in output + assert " - gate_contract_tests: FAIL" in output + assert "induced_failure: true" in output + assert "Failing steps: gate_contract_tests" in output + assert validation_matrix.exit_code_for_step_results(results) == 1 + + +def test_mvp_validation_alias_forwards_to_phase2_entrypoint(monkeypatch, capsys) -> None: + forwarded_args = ["--induce-step", validation_matrix.STEP_GATE_CONTRACT_TESTS] + captured: dict[str, object] = {} + + def fake_run(command, *, cwd, check): # noqa: ANN001 + captured["command"] = command + captured["cwd"] = cwd + captured["check"] = check + return SimpleNamespace(returncode=29) + + monkeypatch.setattr(mvp_validation_alias, "_resolve_python_executable", lambda: "/usr/bin/python3") + monkeypatch.setattr( + mvp_validation_alias.sys, + "argv", + ["scripts/run_mvp_validation_matrix.py", *forwarded_args], + ) + monkeypatch.setattr(mvp_validation_alias.subprocess, "run", fake_run) + + exit_code = mvp_validation_alias.main() + output = capsys.readouterr().out + + assert exit_code == 29 + assert ( + captured["command"] + == ["/usr/bin/python3", str(mvp_validation_alias.TARGET_SCRIPT), *forwarded_args] + ) + assert captured["cwd"] == mvp_validation_alias.ROOT_DIR + assert captured["check"] is False + assert "MVP validation matrix compatibility alias -> scripts/run_phase2_validation_matrix.py" in output diff --git a/tests/integration/test_open_loops_api.py b/tests/integration/test_open_loops_api.py new file mode 100644 index 0000000..ad4f827 --- /dev/null +++ b/tests/integration/test_open_loops_api.py @@ -0,0 +1,394 @@ +from __future__ import annotations + +import json +from datetime import UTC, datetime +from typing import Any +from urllib.parse import urlencode +from uuid import UUID, uuid4 + +import anyio + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.contracts import MemoryCandidateInput +from alicebot_api.db import user_connection +from alicebot_api.memory import admit_memory_candidate +from alicebot_api.store import ContinuityStore + + +def invoke_request( + method: str, + path: str, + *, + query_params: dict[str, str] | None = None, + payload: dict[str, Any] | None = None, +) -> tuple[int, dict[str, Any]]: + messages: list[dict[str, object]] = [] + encoded_body = b"" if payload is None else json.dumps(payload).encode() + request_received = False + + async def receive() -> dict[str, object]: + nonlocal request_received + if request_received: + return {"type": "http.disconnect"} + + request_received = True + return {"type": "http.request", "body": encoded_body, "more_body": False} + + async def send(message: dict[str, object]) -> None: + messages.append(message) + + query_string = urlencode(query_params or {}).encode() + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": query_string, + "headers": [(b"content-type", b"application/json")], + "client": ("testclient", 50000), + "server": ("testserver", 80), + "root_path": "", + } + + anyio.run(main_module.app, scope, receive, send) + + start_message = next(message for message in messages if message["type"] == "http.response.start") + body = b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + return start_message["status"], json.loads(body) + + +def seed_user_with_memory(database_url: str) -> dict[str, object]: + user_id = uuid4() + + with user_connection(database_url, user_id) as conn: + store = ContinuityStore(conn) + store.create_user(user_id, "owner@example.com", "Owner") + thread = store.create_thread("Open-loop thread") + session = store.create_session(thread["id"], status="active") + source_event_id = store.append_event( + thread["id"], + session["id"], + "message.user", + {"text": "Remember to confirm reorder details."}, + )["id"] + decision = admit_memory_candidate( + store, + user_id=user_id, + candidate=MemoryCandidateInput( + memory_key="user.preference.coffee", + value={"likes": "oat milk"}, + source_event_ids=(source_event_id,), + ), + ) + + assert decision.memory is not None + return { + "user_id": user_id, + "thread_id": thread["id"], + "source_event_id": source_event_id, + "memory_id": UUID(decision.memory["id"]), + } + + +def test_open_loop_endpoints_create_list_detail_and_isolation( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_user_with_memory(migrated_database_urls["app"]) + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + create_status, create_payload = invoke_request( + "POST", + "/v0/open-loops", + payload={ + "user_id": str(seeded["user_id"]), + "memory_id": str(seeded["memory_id"]), + "title": "Confirm order details before submission", + "due_at": "2026-03-28T09:00:00+00:00", + }, + ) + + assert create_status == 201 + assert create_payload["open_loop"]["status"] == "open" + assert create_payload["open_loop"]["memory_id"] == str(seeded["memory_id"]) + + open_loop_id = create_payload["open_loop"]["id"] + list_status, list_payload = invoke_request( + "GET", + "/v0/open-loops", + query_params={"user_id": str(seeded["user_id"]), "status": "open", "limit": "10"}, + ) + detail_status, detail_payload = invoke_request( + "GET", + f"/v0/open-loops/{open_loop_id}", + query_params={"user_id": str(seeded["user_id"])}, + ) + + assert list_status == 200 + assert [item["id"] for item in list_payload["items"]] == [open_loop_id] + assert list_payload["summary"]["order"] == ["opened_at_desc", "created_at_desc", "id_desc"] + assert detail_status == 200 + assert detail_payload["open_loop"]["id"] == open_loop_id + + intruder_id = uuid4() + with user_connection(migrated_database_urls["app"], intruder_id) as conn: + ContinuityStore(conn).create_user(intruder_id, "intruder@example.com", "Intruder") + + intruder_status, intruder_payload = invoke_request( + "GET", + "/v0/open-loops", + query_params={"user_id": str(intruder_id), "status": "open", "limit": "10"}, + ) + assert intruder_status == 200 + assert intruder_payload["items"] == [] + assert intruder_payload["summary"]["total_count"] == 0 + + intruder_detail_status, intruder_detail_payload = invoke_request( + "GET", + f"/v0/open-loops/{open_loop_id}", + query_params={"user_id": str(intruder_id)}, + ) + assert intruder_detail_status == 404 + assert "was not found" in intruder_detail_payload["detail"] + + intruder_mutation_status, intruder_mutation_payload = invoke_request( + "POST", + f"/v0/open-loops/{open_loop_id}/status", + payload={ + "user_id": str(intruder_id), + "status": "resolved", + "resolution_note": "Unauthorized user should not mutate this row.", + }, + ) + assert intruder_mutation_status == 404 + assert "was not found" in intruder_mutation_payload["detail"] + + +def test_open_loop_status_endpoint_rejects_invalid_values_and_persists_audit_fields( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_user_with_memory(migrated_database_urls["app"]) + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + create_status, create_payload = invoke_request( + "POST", + "/v0/open-loops", + payload={ + "user_id": str(seeded["user_id"]), + "memory_id": str(seeded["memory_id"]), + "title": "Confirm order details before submission", + }, + ) + assert create_status == 201 + open_loop_id = create_payload["open_loop"]["id"] + + invalid_status, invalid_payload = invoke_request( + "POST", + f"/v0/open-loops/{open_loop_id}/status", + payload={ + "user_id": str(seeded["user_id"]), + "status": "pending_review", + }, + ) + assert invalid_status == 400 + assert invalid_payload == {"detail": "status must be one of: open, resolved, dismissed"} + + resolve_status, resolve_payload = invoke_request( + "POST", + f"/v0/open-loops/{open_loop_id}/status", + payload={ + "user_id": str(seeded["user_id"]), + "status": "resolved", + "resolution_note": "Resolved after checking the latest cart.", + }, + ) + assert resolve_status == 200 + assert resolve_payload["open_loop"]["status"] == "resolved" + assert resolve_payload["open_loop"]["resolved_at"] is not None + assert ( + resolve_payload["open_loop"]["resolution_note"] + == "Resolved after checking the latest cart." + ) + + repeat_status, repeat_payload = invoke_request( + "POST", + f"/v0/open-loops/{open_loop_id}/status", + payload={ + "user_id": str(seeded["user_id"]), + "status": "dismissed", + }, + ) + assert repeat_status == 400 + assert repeat_payload == {"detail": "open loop status can only transition from open"} + + +def test_open_loop_status_endpoint_supports_open_to_dismissed_with_audit_fields( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_user_with_memory(migrated_database_urls["app"]) + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + create_status, create_payload = invoke_request( + "POST", + "/v0/open-loops", + payload={ + "user_id": str(seeded["user_id"]), + "memory_id": str(seeded["memory_id"]), + "title": "Dismiss this candidate after confirming no action is needed", + }, + ) + assert create_status == 201 + open_loop_id = create_payload["open_loop"]["id"] + + dismiss_status, dismiss_payload = invoke_request( + "POST", + f"/v0/open-loops/{open_loop_id}/status", + payload={ + "user_id": str(seeded["user_id"]), + "status": "dismissed", + "resolution_note": "No follow-up required after manual verification.", + }, + ) + + assert dismiss_status == 200 + assert dismiss_payload["open_loop"]["status"] == "dismissed" + assert dismiss_payload["open_loop"]["resolved_at"] is not None + assert ( + dismiss_payload["open_loop"]["resolution_note"] + == "No follow-up required after manual verification." + ) + + +def test_memory_admission_can_create_open_loop_when_requested( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_user_with_memory(migrated_database_urls["app"]) + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + admit_status, admit_payload = invoke_request( + "POST", + "/v0/memories/admit", + payload={ + "user_id": str(seeded["user_id"]), + "memory_key": "user.preference.delivery.window", + "value": {"window": "weekday_morning"}, + "source_event_ids": [str(seeded["source_event_id"])], + "open_loop": { + "title": "Reconfirm delivery window before next order", + "due_at": "2026-03-29T09:00:00+00:00", + }, + }, + ) + + assert admit_status == 200 + assert admit_payload["decision"] == "ADD" + assert admit_payload["open_loop"]["title"] == "Reconfirm delivery window before next order" + assert admit_payload["open_loop"]["status"] == "open" + assert admit_payload["open_loop"]["memory_id"] == admit_payload["memory"]["id"] + + list_status, list_payload = invoke_request( + "GET", + "/v0/open-loops", + query_params={"user_id": str(seeded["user_id"]), "status": "open", "limit": "10"}, + ) + assert list_status == 200 + assert any( + item["title"] == "Reconfirm delivery window before next order" + for item in list_payload["items"] + ) + + +def test_context_compile_includes_bounded_open_loop_slice_when_present( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_user_with_memory(migrated_database_urls["app"]) + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + with user_connection(migrated_database_urls["app"], seeded["user_id"]) as conn: + store = ContinuityStore(conn) + store.create_open_loop( + memory_id=seeded["memory_id"], + title="Older open loop", + status="open", + opened_at=datetime(2026, 3, 23, 8, 0, tzinfo=UTC), + due_at=None, + resolved_at=None, + resolution_note=None, + ) + store.create_open_loop( + memory_id=seeded["memory_id"], + title="Newer open loop", + status="open", + opened_at=datetime(2026, 3, 23, 9, 0, tzinfo=UTC), + due_at=None, + resolved_at=None, + resolution_note=None, + ) + + compile_status, compile_payload = invoke_request( + "POST", + "/v0/context/compile", + payload={ + "user_id": str(seeded["user_id"]), + "thread_id": str(seeded["thread_id"]), + "max_sessions": 3, + "max_events": 8, + "max_memories": 1, + "max_entities": 5, + "max_entity_edges": 10, + }, + ) + + assert compile_status == 200 + assert compile_payload["context_pack"]["open_loops"] == [ + { + "id": compile_payload["context_pack"]["open_loops"][0]["id"], + "memory_id": str(seeded["memory_id"]), + "title": "Newer open loop", + "status": "open", + "opened_at": "2026-03-23T09:00:00+00:00", + "due_at": None, + "resolved_at": None, + "resolution_note": None, + "created_at": compile_payload["context_pack"]["open_loops"][0]["created_at"], + "updated_at": compile_payload["context_pack"]["open_loops"][0]["updated_at"], + } + ] + assert compile_payload["context_pack"]["open_loop_summary"] == { + "candidate_count": 2, + "included_count": 1, + "excluded_limit_count": 1, + "order": ["opened_at_desc", "created_at_desc", "id_desc"], + } diff --git a/tests/integration/test_openclaw_import.py b/tests/integration/test_openclaw_import.py new file mode 100644 index 0000000..e32be65 --- /dev/null +++ b/tests/integration/test_openclaw_import.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from pathlib import Path +from uuid import UUID, uuid4 + +import pytest + +from alicebot_api.continuity_recall import query_continuity_recall +from alicebot_api.continuity_resumption import compile_continuity_resumption_brief +from alicebot_api.contracts import ContinuityRecallQueryInput, ContinuityResumptionBriefRequestInput +from alicebot_api.db import user_connection +from alicebot_api.openclaw_import import import_openclaw_source +from alicebot_api.store import ContinuityStore + + +REPO_ROOT = Path(__file__).resolve().parents[2] +OPENCLAW_FIXTURE_PATH = REPO_ROOT / "fixtures" / "openclaw" / "workspace_v1.json" +OPENCLAW_DIRECTORY_FIXTURE_PATH = REPO_ROOT / "fixtures" / "openclaw" / "workspace_dir_v1" +THREAD_ID = UUID("cccccccc-cccc-4ccc-8ccc-cccccccccccc") + + +def seed_user(database_url: str, *, email: str) -> UUID: + user_id = uuid4() + with user_connection(database_url, user_id) as conn: + ContinuityStore(conn).create_user(user_id, email, email.split("@", 1)[0].title()) + return user_id + + +@pytest.mark.parametrize( + ("source_path", "expected_fixture_id", "expected_workspace_id", "expected_total_candidates", "expected_imported_count"), + [ + ( + OPENCLAW_FIXTURE_PATH, + "openclaw-s36-workspace-v1", + "openclaw-workspace-demo-001", + 5, + 4, + ), + ( + OPENCLAW_DIRECTORY_FIXTURE_PATH, + "openclaw-s39-workspace-dir-v1", + "openclaw-workspace-dir-demo-001", + 4, + 3, + ), + ], +) +def test_openclaw_import_supports_recall_resumption_and_idempotent_dedupe( + migrated_database_urls, + source_path: Path, + expected_fixture_id: str, + expected_workspace_id: str, + expected_total_candidates: int, + expected_imported_count: int, +) -> None: + user_id = seed_user( + migrated_database_urls["app"], + email=f"openclaw-import-{expected_fixture_id}@example.com", + ) + + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + + first_import = import_openclaw_source( + store, + user_id=user_id, + source=source_path, + ) + + assert first_import["status"] == "ok" + assert first_import["fixture_id"] == expected_fixture_id + assert first_import["workspace_id"] == expected_workspace_id + assert first_import["total_candidates"] == expected_total_candidates + assert first_import["imported_count"] == expected_imported_count + assert first_import["skipped_duplicates"] == 1 + assert first_import["provenance_source_kind"] == "openclaw_import" + assert first_import["provenance_source_label"] == "OpenClaw" + + recall = query_continuity_recall( + store, + user_id=user_id, + request=ContinuityRecallQueryInput( + thread_id=THREAD_ID, + project="Alice Public Core", + limit=20, + ), + ) + + assert recall["summary"]["returned_count"] == expected_imported_count + assert all(item["provenance"]["source_kind"] == "openclaw_import" for item in recall["items"]) + assert all(item["provenance"]["source_label"] == "OpenClaw" for item in recall["items"]) + assert all( + item["provenance"].get("openclaw_workspace_id") == expected_workspace_id + for item in recall["items"] + ) + + resumption = compile_continuity_resumption_brief( + store, + user_id=user_id, + request=ContinuityResumptionBriefRequestInput( + thread_id=THREAD_ID, + max_recent_changes=10, + max_open_loops=10, + ), + ) + + brief = resumption["brief"] + assert brief["last_decision"]["item"] is not None + assert brief["last_decision"]["item"]["provenance"]["source_kind"] == "openclaw_import" + assert brief["last_decision"]["item"]["provenance"]["source_label"] == "OpenClaw" + assert brief["next_action"]["item"] is not None + assert brief["next_action"]["item"]["provenance"]["source_kind"] == "openclaw_import" + assert brief["next_action"]["item"]["provenance"]["source_label"] == "OpenClaw" + + second_import = import_openclaw_source( + store, + user_id=user_id, + source=source_path, + ) + + assert second_import["status"] == "noop" + assert second_import["total_candidates"] == expected_total_candidates + assert second_import["imported_count"] == 0 + assert second_import["skipped_duplicates"] == expected_total_candidates diff --git a/tests/integration/test_openclaw_mcp_integration.py b/tests/integration/test_openclaw_mcp_integration.py new file mode 100644 index 0000000..1f9a384 --- /dev/null +++ b/tests/integration/test_openclaw_mcp_integration.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +import subprocess +import sys +from typing import Any +from uuid import UUID, uuid4 + +from alicebot_api.db import user_connection +from alicebot_api.openclaw_import import import_openclaw_source +from alicebot_api.store import ContinuityStore + + +REPO_ROOT = Path(__file__).resolve().parents[2] +OPENCLAW_FIXTURE_PATH = REPO_ROOT / "fixtures" / "openclaw" / "workspace_v1.json" +THREAD_ID = UUID("cccccccc-cccc-4ccc-8ccc-cccccccccccc") + + +def seed_user(database_url: str, *, email: str) -> UUID: + user_id = uuid4() + with user_connection(database_url, user_id) as conn: + ContinuityStore(conn).create_user(user_id, email, email.split("@", 1)[0].title()) + return user_id + + +def build_runtime_env(*, database_url: str, user_id: UUID) -> dict[str, str]: + env = os.environ.copy() + env["DATABASE_URL"] = database_url + env["ALICEBOT_AUTH_USER_ID"] = str(user_id) + pythonpath_entries = [str(REPO_ROOT / "apps" / "api" / "src"), str(REPO_ROOT / "workers")] + existing_pythonpath = env.get("PYTHONPATH") + if existing_pythonpath: + pythonpath_entries.append(existing_pythonpath) + env["PYTHONPATH"] = os.pathsep.join(pythonpath_entries) + return env + + +def _write_mcp_message(stream, payload: dict[str, object]) -> None: + encoded = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8") + stream.write(f"Content-Length: {len(encoded)}\r\n\r\n".encode("ascii")) + stream.write(encoded) + stream.flush() + + +def _read_mcp_message(stream) -> dict[str, object]: + headers: dict[str, str] = {} + while True: + line = stream.readline() + if line == b"": + raise RuntimeError("MCP server closed stdout unexpectedly") + if line in {b"\r\n", b"\n"}: + break + decoded = line.decode("utf-8").strip() + key, value = decoded.split(":", 1) + headers[key.strip().lower()] = value.strip() + + content_length = int(headers["content-length"]) + body = stream.read(content_length) + return json.loads(body.decode("utf-8")) + + +class MCPClient: + def __init__(self, process: subprocess.Popen[bytes]) -> None: + self.process = process + self._next_id = 1 + + def request(self, method: str, params: dict[str, object] | None = None) -> dict[str, object]: + request_id = self._next_id + self._next_id += 1 + payload: dict[str, object] = {"jsonrpc": "2.0", "id": request_id, "method": method} + if params is not None: + payload["params"] = params + assert self.process.stdin is not None + _write_mcp_message(self.process.stdin, payload) + assert self.process.stdout is not None + response = _read_mcp_message(self.process.stdout) + assert response.get("id") == request_id + return response + + def notify(self, method: str, params: dict[str, object] | None = None) -> None: + payload: dict[str, object] = {"jsonrpc": "2.0", "method": method} + if params is not None: + payload["params"] = params + assert self.process.stdin is not None + _write_mcp_message(self.process.stdin, payload) + + def close(self) -> None: + if self.process.poll() is None: + self.process.terminate() + try: + self.process.wait(timeout=5) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.wait(timeout=5) + + +def start_mcp_client(*, database_url: str, user_id: UUID) -> MCPClient: + env = build_runtime_env(database_url=database_url, user_id=user_id) + process = subprocess.Popen( + [sys.executable, "-m", "alicebot_api.mcp_server"], + cwd=REPO_ROOT, + env=env, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=False, + ) + + client = MCPClient(process=process) + initialize = client.request( + "initialize", + params={ + "protocolVersion": "2024-11-05", + "clientInfo": {"name": "pytest-openclaw-mcp", "version": "1.0"}, + "capabilities": {}, + }, + ) + assert initialize["result"]["protocolVersion"] == "2024-11-05" + client.notify("notifications/initialized", {}) + return client + + +def _call_tool(client: MCPClient, *, name: str, arguments: dict[str, object]) -> dict[str, Any]: + response = client.request("tools/call", params={"name": name, "arguments": arguments}) + assert "error" not in response + result = response["result"] + assert result["isError"] is False + return result["structuredContent"] + + +def test_openclaw_imported_data_is_usable_from_shipped_mcp_recall_and_resume_tools( + migrated_database_urls, +) -> None: + user_id = seed_user(migrated_database_urls["app"], email="openclaw-mcp@example.com") + + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + summary = import_openclaw_source( + store, + user_id=user_id, + source=OPENCLAW_FIXTURE_PATH, + ) + assert summary["imported_count"] == 4 + assert summary["provenance_source_label"] == "OpenClaw" + + client = start_mcp_client(database_url=migrated_database_urls["app"], user_id=user_id) + try: + recall_payload = _call_tool( + client, + name="alice_recall", + arguments={ + "thread_id": str(THREAD_ID), + "project": "Alice Public Core", + "query": "MCP tool surface", + "limit": 20, + }, + ) + resume_payload = _call_tool( + client, + name="alice_resume", + arguments={ + "thread_id": str(THREAD_ID), + "max_recent_changes": 10, + "max_open_loops": 10, + }, + ) + finally: + client.close() + + assert recall_payload["summary"]["returned_count"] >= 1 + assert any(item["provenance"]["source_kind"] == "openclaw_import" for item in recall_payload["items"]) + assert any(item["provenance"].get("source_label") == "OpenClaw" for item in recall_payload["items"]) + + brief = resume_payload["brief"] + assert brief["last_decision"]["item"] is not None + assert brief["last_decision"]["item"]["provenance"]["source_kind"] == "openclaw_import" + assert brief["last_decision"]["item"]["provenance"]["source_label"] == "OpenClaw" + assert brief["next_action"]["item"] is not None + assert brief["next_action"]["item"]["provenance"]["source_kind"] == "openclaw_import" + assert brief["next_action"]["item"]["provenance"]["source_label"] == "OpenClaw" diff --git a/tests/integration/test_openclaw_one_command_demo.py b/tests/integration/test_openclaw_one_command_demo.py new file mode 100644 index 0000000..a664a9c --- /dev/null +++ b/tests/integration/test_openclaw_one_command_demo.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import json +from pathlib import Path +import subprocess +import sys + + +REPO_ROOT = Path(__file__).resolve().parents[2] +DEMO_SCRIPT = REPO_ROOT / "scripts" / "use_alice_with_openclaw.py" + + +def test_openclaw_one_command_demo_runs_import_recall_resume_with_idempotent_replay(migrated_database_urls) -> None: + completed = subprocess.run( + [ + sys.executable, + str(DEMO_SCRIPT), + "--database-url", + migrated_database_urls["app"], + "--display-name", + "OpenClaw Demo Test User", + ], + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=True, + ) + + payload = json.loads(completed.stdout) + assert payload["status"] == "pass" + assert payload["import"]["first"]["status"] == "ok" + assert payload["import"]["second"]["status"] == "noop" + assert payload["after"]["recall_returned_count"] >= 1 + assert payload["after"]["resume_last_decision_source_kind"] == "openclaw_import" + assert payload["after"]["resume_next_action_source_kind"] == "openclaw_import" + assert payload["after"]["resume_last_decision_source_label"] == "OpenClaw" + assert payload["after"]["resume_next_action_source_label"] == "OpenClaw" + assert payload["after"]["recall_source_labels"] == ["OpenClaw"] + assert all(payload["checks"].values()) diff --git a/tests/integration/test_phase10_beta_hardening_launch_api.py b/tests/integration/test_phase10_beta_hardening_launch_api.py new file mode 100644 index 0000000..ad08bec --- /dev/null +++ b/tests/integration/test_phase10_beta_hardening_launch_api.py @@ -0,0 +1,605 @@ +from __future__ import annotations + +import hashlib +import json +from typing import Any +from urllib.parse import urlencode + +import anyio +import psycopg +from psycopg.rows import dict_row + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings + + +def invoke_request( + method: str, + path: str, + *, + query_params: dict[str, str] | None = None, + payload: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, +) -> tuple[int, dict[str, Any]]: + messages: list[dict[str, object]] = [] + encoded_body = b"" if payload is None else json.dumps(payload).encode() + request_received = False + + async def receive() -> dict[str, object]: + nonlocal request_received + if request_received: + return {"type": "http.disconnect"} + + request_received = True + return {"type": "http.request", "body": encoded_body, "more_body": False} + + async def send(message: dict[str, object]) -> None: + messages.append(message) + + query_string = urlencode(query_params or {}).encode() + request_headers = [(b"content-type", b"application/json")] + for key, value in (headers or {}).items(): + request_headers.append((key.lower().encode(), value.encode())) + + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": query_string, + "headers": request_headers, + "client": ("testclient", 50000), + "server": ("testserver", 80), + "root_path": "", + } + + anyio.run(main_module.app, scope, receive, send) + + start_message = next(message for message in messages if message["type"] == "http.response.start") + body = b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + return start_message["status"], json.loads(body) + + +def auth_header(session_token: str) -> dict[str, str]: + return {"authorization": f"Bearer {session_token}"} + + +def _configure_settings(migrated_database_urls, monkeypatch, **overrides: object) -> None: + settings_kwargs: dict[str, object] = { + "app_env": "test", + "database_url": migrated_database_urls["app"], + "magic_link_ttl_seconds": 600, + "auth_session_ttl_seconds": 3600, + "device_link_ttl_seconds": 600, + "telegram_link_ttl_seconds": 600, + "telegram_bot_username": "alicebot", + "telegram_webhook_secret": "", + "telegram_bot_token": "", + "hosted_chat_rate_limit_window_seconds": 60, + "hosted_chat_rate_limit_max_requests": 20, + "hosted_scheduler_rate_limit_window_seconds": 300, + "hosted_scheduler_rate_limit_max_requests": 20, + "hosted_abuse_window_seconds": 600, + "hosted_abuse_block_threshold": 5, + "hosted_rate_limits_enabled_by_default": True, + "hosted_abuse_controls_enabled_by_default": True, + } + settings_kwargs.update(overrides) + + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(**settings_kwargs), + ) + + +def _bootstrap_workspace_session(email: str) -> tuple[str, str]: + start_status, start_payload = invoke_request( + "POST", + "/v1/auth/magic-link/start", + payload={"email": email}, + ) + assert start_status == 200 + + verify_status, verify_payload = invoke_request( + "POST", + "/v1/auth/magic-link/verify", + payload={ + "challenge_token": start_payload["challenge"]["challenge_token"], + "device_label": "P10-S5 Device", + "device_key": f"device-{email}", + }, + ) + assert verify_status == 200 + session_token = verify_payload["session_token"] + + create_workspace_status, create_workspace_payload = invoke_request( + "POST", + "/v1/workspaces", + payload={"name": "P10-S5 Workspace"}, + headers=auth_header(session_token), + ) + assert create_workspace_status == 201 + workspace_id = create_workspace_payload["workspace"]["id"] + + bootstrap_status, bootstrap_payload = invoke_request( + "POST", + "/v1/workspaces/bootstrap", + payload={"workspace_id": workspace_id}, + headers=auth_header(session_token), + ) + assert bootstrap_status == 200 + assert bootstrap_payload["workspace"]["bootstrap_status"] == "ready" + return session_token, workspace_id + + +def _link_telegram_chat( + *, + session_token: str, + workspace_id: str, + chat_id: int, + user_id: int, + username: str, +) -> None: + start_status, start_payload = invoke_request( + "POST", + "/v1/channels/telegram/link/start", + payload={"workspace_id": workspace_id}, + headers=auth_header(session_token), + ) + assert start_status == 200 + challenge_token = start_payload["challenge"]["challenge_token"] + link_code = start_payload["challenge"]["link_code"] + + webhook_status, webhook_payload = invoke_request( + "POST", + "/v1/channels/telegram/webhook", + payload={ + "update_id": 990001, + "message": { + "message_id": 890001, + "date": 1710000000, + "chat": {"id": chat_id, "type": "private"}, + "from": {"id": user_id, "username": username}, + "text": f"/link {link_code}", + }, + }, + ) + assert webhook_status == 200 + assert webhook_payload["ingest"]["link_status"] == "confirmed" + + confirm_status, confirm_payload = invoke_request( + "POST", + "/v1/channels/telegram/link/confirm", + payload={"challenge_token": challenge_token}, + headers=auth_header(session_token), + ) + assert confirm_status == 201 + assert confirm_payload["identity"]["status"] == "linked" + + +def _ingest_message( + *, + update_id: int, + message_id: int, + chat_id: int, + user_id: int, + username: str, + text: str, +) -> str: + webhook_status, webhook_payload = invoke_request( + "POST", + "/v1/channels/telegram/webhook", + payload={ + "update_id": update_id, + "message": { + "message_id": message_id, + "date": 1710001000 + update_id, + "chat": {"id": chat_id, "type": "private"}, + "from": {"id": user_id, "username": username}, + "text": text, + }, + }, + ) + assert webhook_status == 200 + assert webhook_payload["ingest"]["route_status"] == "resolved" + return webhook_payload["ingest"]["message"]["id"] + + +def _handle_message(*, session_token: str, message_id: str) -> tuple[int, dict[str, Any]]: + return invoke_request( + "POST", + f"/v1/channels/telegram/messages/{message_id}/handle", + payload={}, + headers=auth_header(session_token), + ) + + +def _promote_session_to_operator(migrated_database_urls, *, session_token: str) -> str: + token_hash = hashlib.sha256(session_token.encode("utf-8")).hexdigest() + with psycopg.connect(migrated_database_urls["app"], row_factory=dict_row) as conn: + with conn.transaction(): + with conn.cursor() as cur: + cur.execute( + """ + SELECT user_account_id + FROM auth_sessions + WHERE session_token_hash = %s + LIMIT 1 + """, + (token_hash,), + ) + session = cur.fetchone() + assert session is not None + user_account_id = session["user_account_id"] + + cur.execute( + """ + INSERT INTO beta_cohorts (cohort_key, description) + VALUES ('p10-ops', 'Phase 10 hosted beta operator cohort') + ON CONFLICT (cohort_key) DO NOTHING + """, + ) + cur.execute( + """ + UPDATE user_accounts + SET beta_cohort_key = 'p10-ops' + WHERE id = %s + """, + (user_account_id,), + ) + return str(user_account_id) + + +def _workspace_support_snapshot(migrated_database_urls, *, workspace_id: str) -> dict[str, Any]: + with psycopg.connect(migrated_database_urls["app"], row_factory=dict_row) as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT support_status, + onboarding_error_count, + onboarding_last_error_code, + onboarding_last_error_detail, + onboarding_last_error_at, + incident_evidence + FROM workspaces + WHERE id = %s + """, + (workspace_id,), + ) + row = cur.fetchone() + assert row is not None + return { + "support_status": row["support_status"], + "onboarding_error_count": int(row["onboarding_error_count"]), + "onboarding_last_error_code": row["onboarding_last_error_code"], + "onboarding_last_error_detail": row["onboarding_last_error_detail"], + "onboarding_last_error_at": row["onboarding_last_error_at"], + "incident_evidence": row["incident_evidence"], + } + + +def test_phase10_s5_admin_endpoints_require_operator_authorization( + migrated_database_urls, + monkeypatch, +) -> None: + _configure_settings(migrated_database_urls, monkeypatch) + session_token, _workspace_id = _bootstrap_workspace_session("p10s5-non-operator@example.com") + + status, payload = invoke_request( + "GET", + "/v1/admin/hosted/overview", + headers=auth_header(session_token), + ) + assert status == 403 + assert "hosted_admin_operator" in payload["detail"] + + +def test_phase10_s5_admin_endpoints_expose_hosted_visibility( + migrated_database_urls, + monkeypatch, +) -> None: + _configure_settings(migrated_database_urls, monkeypatch) + session_token, workspace_id = _bootstrap_workspace_session("p10s5-admin@example.com") + _promote_session_to_operator(migrated_database_urls, session_token=session_token) + _link_telegram_chat( + session_token=session_token, + workspace_id=workspace_id, + chat_id=995001, + user_id=895001, + username="p10s5admin", + ) + + message_id = _ingest_message( + update_id=995101, + message_id=895101, + chat_id=995001, + user_id=895001, + username="p10s5admin", + text="Decision: confirm hosted admin visibility", + ) + handle_status, handle_payload = _handle_message(session_token=session_token, message_id=message_id) + assert handle_status == 200 + assert handle_payload["intent"]["status"] == "handled" + + overview_status, overview_payload = invoke_request( + "GET", + "/v1/admin/hosted/overview", + headers=auth_header(session_token), + ) + assert overview_status == 200 + assert overview_payload["workspaces"]["total_count"] >= 1 + + workspaces_status, workspaces_payload = invoke_request( + "GET", + "/v1/admin/hosted/workspaces", + headers=auth_header(session_token), + ) + assert workspaces_status == 200 + assert workspaces_payload["summary"]["returned_count"] >= 1 + + delivery_status, delivery_payload = invoke_request( + "GET", + "/v1/admin/hosted/delivery-receipts", + headers=auth_header(session_token), + ) + assert delivery_status == 200 + assert delivery_payload["summary"]["returned_count"] >= 1 + + incidents_status, incidents_payload = invoke_request( + "GET", + "/v1/admin/hosted/incidents", + query_params={"status": "all"}, + headers=auth_header(session_token), + ) + assert incidents_status == 200 + assert "items" in incidents_payload + + rollout_status, rollout_payload = invoke_request( + "GET", + "/v1/admin/hosted/rollout-flags", + headers=auth_header(session_token), + ) + assert rollout_status == 200 + assert any(item["flag_key"] == "hosted_chat_handle_enabled" for item in rollout_payload["items"]) + + analytics_status, analytics_payload = invoke_request( + "GET", + "/v1/admin/hosted/analytics", + headers=auth_header(session_token), + ) + assert analytics_status == 200 + assert analytics_payload["analytics"]["total_events"] >= 1 + + rate_limits_status, rate_limits_payload = invoke_request( + "GET", + "/v1/admin/hosted/rate-limits", + headers=auth_header(session_token), + ) + assert rate_limits_status == 200 + assert "summary" in rate_limits_payload + assert "items" in rate_limits_payload + + +def test_phase10_s5_rollout_flag_blocks_chat_handle_deterministically( + migrated_database_urls, + monkeypatch, +) -> None: + _configure_settings(migrated_database_urls, monkeypatch) + session_token, workspace_id = _bootstrap_workspace_session("p10s5-rollout@example.com") + _promote_session_to_operator(migrated_database_urls, session_token=session_token) + _link_telegram_chat( + session_token=session_token, + workspace_id=workspace_id, + chat_id=995002, + user_id=895002, + username="p10s5rollout", + ) + + patch_status, patch_payload = invoke_request( + "PATCH", + "/v1/admin/hosted/rollout-flags", + payload={ + "updates": [ + { + "flag_key": "hosted_chat_handle_enabled", + "enabled": False, + "cohort_key": "p10-ops", + } + ] + }, + headers=auth_header(session_token), + ) + assert patch_status == 200 + assert any( + item["flag_key"] == "hosted_chat_handle_enabled" and item["enabled"] is False + for item in patch_payload["items"] + ) + + message_id = _ingest_message( + update_id=995201, + message_id=895201, + chat_id=995002, + user_id=895002, + username="p10s5rollout", + text="Decision: this should be rollout blocked", + ) + handle_status, handle_payload = _handle_message(session_token=session_token, message_id=message_id) + + assert handle_status == 403 + assert handle_payload["detail"]["code"] == "hosted_rollout_blocked" + + analytics_status, analytics_payload = invoke_request( + "GET", + "/v1/admin/hosted/analytics", + headers=auth_header(session_token), + ) + assert analytics_status == 200 + assert analytics_payload["analytics"]["status_counts"].get("blocked_rollout", 0) >= 1 + + +def test_phase10_s5_rate_limit_and_abuse_controls_block_deterministically( + migrated_database_urls, + monkeypatch, +) -> None: + _configure_settings( + migrated_database_urls, + monkeypatch, + hosted_chat_rate_limit_window_seconds=600, + hosted_chat_rate_limit_max_requests=1, + hosted_abuse_window_seconds=600, + hosted_abuse_block_threshold=1, + ) + session_token, workspace_id = _bootstrap_workspace_session("p10s5-ratelimit@example.com") + _link_telegram_chat( + session_token=session_token, + workspace_id=workspace_id, + chat_id=995003, + user_id=895003, + username="p10s5ratelimit", + ) + + first_message_id = _ingest_message( + update_id=995301, + message_id=895301, + chat_id=995003, + user_id=895003, + username="p10s5ratelimit", + text="Decision: first handle should pass", + ) + first_status, _first_payload = _handle_message(session_token=session_token, message_id=first_message_id) + assert first_status == 200 + + second_message_id = _ingest_message( + update_id=995302, + message_id=895302, + chat_id=995003, + user_id=895003, + username="p10s5ratelimit", + text="Decision: second handle should rate limit", + ) + second_status, second_payload = _handle_message(session_token=session_token, message_id=second_message_id) + assert second_status == 429 + assert second_payload["detail"]["code"] == "hosted_rate_limit_exceeded" + + third_message_id = _ingest_message( + update_id=995303, + message_id=895303, + chat_id=995003, + user_id=895003, + username="p10s5ratelimit", + text="Decision: third handle should abuse block", + ) + third_status, third_payload = _handle_message(session_token=session_token, message_id=third_message_id) + assert third_status == 429 + assert third_payload["detail"]["code"] == "hosted_abuse_limit_exceeded" + + _promote_session_to_operator(migrated_database_urls, session_token=session_token) + rate_limits_status, rate_limits_payload = invoke_request( + "GET", + "/v1/admin/hosted/rate-limits", + headers=auth_header(session_token), + ) + assert rate_limits_status == 200 + observed_statuses = {item["status"] for item in rate_limits_payload["items"]} + assert "rate_limited" in observed_statuses + assert "abuse_blocked" in observed_statuses + + +def test_phase10_s5_bootstrap_conflict_surfaces_onboarding_support_visibility( + migrated_database_urls, + monkeypatch, +) -> None: + _configure_settings(migrated_database_urls, monkeypatch) + session_token, workspace_id = _bootstrap_workspace_session("p10s5-onboarding@example.com") + + duplicate_bootstrap_status, duplicate_bootstrap_payload = invoke_request( + "POST", + "/v1/workspaces/bootstrap", + payload={"workspace_id": workspace_id}, + headers=auth_header(session_token), + ) + assert duplicate_bootstrap_status == 409 + assert "already complete" in duplicate_bootstrap_payload["detail"] + + _promote_session_to_operator(migrated_database_urls, session_token=session_token) + workspaces_status, workspaces_payload = invoke_request( + "GET", + "/v1/admin/hosted/workspaces", + headers=auth_header(session_token), + ) + assert workspaces_status == 200 + workspace_item = next(item for item in workspaces_payload["items"] if item["id"] == workspace_id) + assert workspace_item["support_status"] == "needs_attention" + assert workspace_item["onboarding_error_count"] >= 1 + assert workspace_item["onboarding_last_error_code"] == "bootstrap_conflict" + + incidents_status, incidents_payload = invoke_request( + "GET", + "/v1/admin/hosted/incidents", + query_params={"status": "open", "workspace_id": workspace_id}, + headers=auth_header(session_token), + ) + assert incidents_status == 200 + assert any( + item["source"] == "workspace_onboarding" and item["code"] == "bootstrap_conflict" + for item in incidents_payload["items"] + ) + + +def test_phase10_s5_rollout_patch_rejects_non_hosted_flag_keys( + migrated_database_urls, + monkeypatch, +) -> None: + _configure_settings(migrated_database_urls, monkeypatch) + session_token, _workspace_id = _bootstrap_workspace_session("p10s5-rollout-scope@example.com") + _promote_session_to_operator(migrated_database_urls, session_token=session_token) + + patch_status, patch_payload = invoke_request( + "PATCH", + "/v1/admin/hosted/rollout-flags", + payload={ + "updates": [ + { + "flag_key": "calendar_ingest_enabled", + "enabled": False, + "cohort_key": "p10-ops", + } + ] + }, + headers=auth_header(session_token), + ) + assert patch_status == 400 + assert "must start with 'hosted_'" in patch_payload["detail"] + + +def test_phase10_s5_bootstrap_not_found_does_not_mutate_foreign_workspace_support_evidence( + migrated_database_urls, + monkeypatch, +) -> None: + _configure_settings(migrated_database_urls, monkeypatch) + owner_session_token, owner_workspace_id = _bootstrap_workspace_session("p10s5-owner@example.com") + intruder_session_token, _intruder_workspace_id = _bootstrap_workspace_session("p10s5-intruder@example.com") + + before = _workspace_support_snapshot(migrated_database_urls, workspace_id=owner_workspace_id) + status, payload = invoke_request( + "POST", + "/v1/workspaces/bootstrap", + payload={"workspace_id": owner_workspace_id}, + headers=auth_header(intruder_session_token), + ) + assert status == 404 + assert owner_workspace_id in payload["detail"] + + after = _workspace_support_snapshot(migrated_database_urls, workspace_id=owner_workspace_id) + assert after == before + + # Keep the owner token referenced so both sessions are exercised intentionally. + assert owner_session_token != intruder_session_token diff --git a/tests/integration/test_phase10_chat_continuity_approvals_api.py b/tests/integration/test_phase10_chat_continuity_approvals_api.py new file mode 100644 index 0000000..9c0d4a1 --- /dev/null +++ b/tests/integration/test_phase10_chat_continuity_approvals_api.py @@ -0,0 +1,792 @@ +from __future__ import annotations + +import json +from typing import Any +from urllib.parse import urlencode +from uuid import UUID, uuid4 + +import anyio +import psycopg + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings + + +def invoke_request( + method: str, + path: str, + *, + query_params: dict[str, str] | None = None, + payload: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, +) -> tuple[int, dict[str, Any]]: + messages: list[dict[str, object]] = [] + encoded_body = b"" if payload is None else json.dumps(payload).encode() + request_received = False + + async def receive() -> dict[str, object]: + nonlocal request_received + if request_received: + return {"type": "http.disconnect"} + + request_received = True + return {"type": "http.request", "body": encoded_body, "more_body": False} + + async def send(message: dict[str, object]) -> None: + messages.append(message) + + query_string = urlencode(query_params or {}).encode() + request_headers = [(b"content-type", b"application/json")] + for key, value in (headers or {}).items(): + request_headers.append((key.lower().encode(), value.encode())) + + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": query_string, + "headers": request_headers, + "client": ("testclient", 50000), + "server": ("testserver", 80), + "root_path": "", + } + + anyio.run(main_module.app, scope, receive, send) + + start_message = next(message for message in messages if message["type"] == "http.response.start") + body = b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + return start_message["status"], json.loads(body) + + +def auth_header(session_token: str) -> dict[str, str]: + return {"authorization": f"Bearer {session_token}"} + + +def _configure_settings(migrated_database_urls, monkeypatch) -> None: + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + magic_link_ttl_seconds=600, + auth_session_ttl_seconds=3600, + device_link_ttl_seconds=600, + telegram_link_ttl_seconds=600, + telegram_bot_username="alicebot", + telegram_webhook_secret="", + telegram_bot_token="", + ), + ) + + +def _bootstrap_workspace_session(email: str) -> tuple[str, str]: + start_status, start_payload = invoke_request( + "POST", + "/v1/auth/magic-link/start", + payload={"email": email}, + ) + assert start_status == 200 + + verify_status, verify_payload = invoke_request( + "POST", + "/v1/auth/magic-link/verify", + payload={ + "challenge_token": start_payload["challenge"]["challenge_token"], + "device_label": "P10-S3 Device", + "device_key": f"device-{email}", + }, + ) + assert verify_status == 200 + session_token = verify_payload["session_token"] + + create_workspace_status, create_workspace_payload = invoke_request( + "POST", + "/v1/workspaces", + payload={"name": "P10-S3 Workspace"}, + headers=auth_header(session_token), + ) + assert create_workspace_status == 201 + workspace_id = create_workspace_payload["workspace"]["id"] + + bootstrap_status, bootstrap_payload = invoke_request( + "POST", + "/v1/workspaces/bootstrap", + payload={"workspace_id": workspace_id}, + headers=auth_header(session_token), + ) + assert bootstrap_status == 200 + assert bootstrap_payload["workspace"]["bootstrap_status"] == "ready" + return session_token, workspace_id + + +def _link_telegram_chat( + *, + session_token: str, + workspace_id: str, + chat_id: int, + user_id: int, + username: str, +) -> None: + start_status, start_payload = invoke_request( + "POST", + "/v1/channels/telegram/link/start", + payload={"workspace_id": workspace_id}, + headers=auth_header(session_token), + ) + assert start_status == 200 + challenge_token = start_payload["challenge"]["challenge_token"] + link_code = start_payload["challenge"]["link_code"] + + webhook_status, webhook_payload = invoke_request( + "POST", + "/v1/channels/telegram/webhook", + payload={ + "update_id": 910001, + "message": { + "message_id": 710001, + "date": 1710000000, + "chat": {"id": chat_id, "type": "private"}, + "from": {"id": user_id, "username": username}, + "text": f"/link {link_code}", + }, + }, + ) + assert webhook_status == 200 + assert webhook_payload["ingest"]["link_status"] == "confirmed" + + confirm_status, confirm_payload = invoke_request( + "POST", + "/v1/channels/telegram/link/confirm", + payload={"challenge_token": challenge_token}, + headers=auth_header(session_token), + ) + assert confirm_status == 201 + assert confirm_payload["identity"]["status"] == "linked" + + +def _ingest_message( + *, + update_id: int, + message_id: int, + chat_id: int, + user_id: int, + username: str, + text: str, +) -> str: + webhook_status, webhook_payload = invoke_request( + "POST", + "/v1/channels/telegram/webhook", + payload={ + "update_id": update_id, + "message": { + "message_id": message_id, + "date": 1710001000 + update_id, + "chat": {"id": chat_id, "type": "private"}, + "from": {"id": user_id, "username": username}, + "text": text, + }, + }, + ) + assert webhook_status == 200 + assert webhook_payload["ingest"]["route_status"] == "resolved" + return webhook_payload["ingest"]["message"]["id"] + + +def _handle_message( + *, + session_token: str, + message_id: str, + intent_hint: str | None = None, +) -> tuple[int, dict[str, Any]]: + payload: dict[str, Any] = {} + if intent_hint is not None: + payload["intent_hint"] = intent_hint + return invoke_request( + "POST", + f"/v1/channels/telegram/messages/{message_id}/handle", + payload=payload, + headers=auth_header(session_token), + ) + + +def _seed_pending_approval(*, admin_db_url: str, user_id: UUID, seed_key: str) -> UUID: + thread_id = uuid4() + trace_id = uuid4() + tool_id = uuid4() + approval_id = uuid4() + task_id = uuid4() + task_step_id = uuid4() + + with psycopg.connect(admin_db_url) as conn: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO users (id, email, display_name) + VALUES (%s, %s, %s) + ON CONFLICT (id) DO UPDATE + SET email = EXCLUDED.email, + display_name = EXCLUDED.display_name + """, + (str(user_id), f"{seed_key}@example.com", f"{seed_key} User"), + ) + cur.execute( + """ + INSERT INTO threads (id, user_id, title) + VALUES (%s, %s, %s) + """, + (str(thread_id), str(user_id), f"{seed_key} Thread"), + ) + cur.execute( + """ + INSERT INTO traces (id, user_id, thread_id, kind, compiler_version, status, limits) + VALUES (%s, %s, %s, 'telegram.seed', 'v0', 'completed', '{}'::jsonb) + """, + (str(trace_id), str(user_id), str(thread_id)), + ) + cur.execute( + """ + INSERT INTO tools ( + id, + user_id, + tool_key, + name, + description, + version, + metadata_version, + active, + tags, + action_hints, + scope_hints, + domain_hints, + risk_hints, + metadata + ) + VALUES ( + %s, + %s, + %s, + %s, + %s, + '1.0.0', + 'tool_metadata_v0', + TRUE, + '[]'::jsonb, + '[]'::jsonb, + '[]'::jsonb, + '[]'::jsonb, + '[]'::jsonb, + '{}'::jsonb + ) + """, + ( + str(tool_id), + str(user_id), + f"telegram.seed.{seed_key}", + f"{seed_key} Tool", + "Seed tool for telegram approvals", + ), + ) + cur.execute( + """ + INSERT INTO approvals ( + id, + user_id, + thread_id, + tool_id, + task_run_id, + task_step_id, + status, + request, + tool, + routing, + routing_trace_id + ) + VALUES ( + %s, + %s, + %s, + %s, + NULL, + NULL, + 'pending', + '{"action":"deploy"}'::jsonb, + '{"id":"seed-tool"}'::jsonb, + '{"decision":"approval_required"}'::jsonb, + %s + ) + """, + (str(approval_id), str(user_id), str(thread_id), str(tool_id), str(trace_id)), + ) + cur.execute( + """ + INSERT INTO tasks ( + id, + user_id, + thread_id, + tool_id, + status, + request, + tool, + latest_approval_id, + latest_execution_id + ) + VALUES ( + %s, + %s, + %s, + %s, + 'pending_approval', + '{"action":"deploy"}'::jsonb, + '{"id":"seed-tool"}'::jsonb, + %s, + NULL + ) + """, + (str(task_id), str(user_id), str(thread_id), str(tool_id), str(approval_id)), + ) + cur.execute( + """ + INSERT INTO task_steps ( + id, + user_id, + task_id, + sequence_no, + kind, + status, + request, + outcome, + trace_id, + trace_kind + ) + VALUES ( + %s, + %s, + %s, + 1, + 'governed_request', + 'created', + '{"action":"deploy"}'::jsonb, + %s, + %s, + 'telegram.seed' + ) + """, + ( + str(task_step_id), + str(user_id), + str(task_id), + json.dumps( + { + "routing_decision": "approval_required", + "approval_id": str(approval_id), + "approval_status": "pending", + "execution_id": None, + "execution_status": None, + "blocked_reason": None, + } + ), + str(trace_id), + ), + ) + cur.execute( + """ + UPDATE approvals + SET task_step_id = %s + WHERE id = %s + """, + (str(task_step_id), str(approval_id)), + ) + conn.commit() + + return approval_id + + +def test_phase10_telegram_continuity_handle_result_and_open_loop_review( + migrated_database_urls, + monkeypatch, +) -> None: + _configure_settings(migrated_database_urls, monkeypatch) + session_token, workspace_id = _bootstrap_workspace_session("p10s3-continuity@example.com") + _link_telegram_chat( + session_token=session_token, + workspace_id=workspace_id, + chat_id=75001, + user_id=76001, + username="continuity_builder", + ) + + capture_message_id = _ingest_message( + update_id=920001, + message_id=720001, + chat_id=75001, + user_id=76001, + username="continuity_builder", + text="Decision: Ship P10-S3 this week", + ) + capture_handle_status, capture_handle_payload = _handle_message( + session_token=session_token, + message_id=capture_message_id, + ) + assert capture_handle_status == 200 + assert capture_handle_payload["intent"]["intent_kind"] == "capture" + assert capture_handle_payload["intent"]["status"] == "handled" + + result_status, result_payload = invoke_request( + "GET", + f"/v1/channels/telegram/messages/{capture_message_id}/result", + headers=auth_header(session_token), + ) + assert result_status == 200 + assert result_payload["intent"]["intent_kind"] == "capture" + + recall_message_id = _ingest_message( + update_id=920002, + message_id=720002, + chat_id=75001, + user_id=76001, + username="continuity_builder", + text="/recall ship p10-s3", + ) + recall_handle_status, recall_handle_payload = _handle_message( + session_token=session_token, + message_id=recall_message_id, + ) + assert recall_handle_status == 200 + assert recall_handle_payload["intent"]["intent_kind"] == "recall" + assert recall_handle_payload["intent"]["status"] == "handled" + recall_items = recall_handle_payload["intent"]["result_payload"]["intent_result"]["recall"]["items"] + assert len(recall_items) >= 1 + continuity_object_id = recall_items[0]["id"] + + correction_message_id = _ingest_message( + update_id=920003, + message_id=720003, + chat_id=75001, + user_id=76001, + username="continuity_builder", + text=f"/correct {continuity_object_id} Decision: Ship P10-S3 after sign-off", + ) + correction_handle_status, correction_handle_payload = _handle_message( + session_token=session_token, + message_id=correction_message_id, + ) + assert correction_handle_status == 200 + assert correction_handle_payload["intent"]["intent_kind"] == "correction" + assert correction_handle_payload["intent"]["status"] == "handled" + + corrected_recall_message_id = _ingest_message( + update_id=920004, + message_id=720004, + chat_id=75001, + user_id=76001, + username="continuity_builder", + text="/recall sign-off", + ) + corrected_recall_status, corrected_recall_payload = _handle_message( + session_token=session_token, + message_id=corrected_recall_message_id, + ) + assert corrected_recall_status == 200 + corrected_title = corrected_recall_payload["intent"]["result_payload"]["intent_result"]["recall"]["items"][0][ + "title" + ] + assert "after sign-off" in corrected_title + + resume_message_id = _ingest_message( + update_id=920005, + message_id=720005, + chat_id=75001, + user_id=76001, + username="continuity_builder", + text="/resume", + ) + resume_handle_status, resume_handle_payload = _handle_message( + session_token=session_token, + message_id=resume_message_id, + ) + assert resume_handle_status == 200 + assert resume_handle_payload["intent"]["intent_kind"] == "resume" + assert resume_handle_payload["intent"]["status"] == "handled" + assert ( + resume_handle_payload["intent"]["result_payload"]["intent_result"]["brief"]["last_decision"]["item"] is not None + ) + + empty_recall_message_id = _ingest_message( + update_id=920008, + message_id=720008, + chat_id=75001, + user_id=76001, + username="continuity_builder", + text="/recall", + ) + empty_recall_status, empty_recall_payload = _handle_message( + session_token=session_token, + message_id=empty_recall_message_id, + ) + assert empty_recall_status == 200 + assert empty_recall_payload["intent"]["intent_kind"] == "recall" + assert empty_recall_payload["intent"]["status"] == "failed" + assert ( + empty_recall_payload["intent"]["result_payload"]["error"]["detail"] + == "recall intent requires a query" + ) + + open_loop_capture_id = _ingest_message( + update_id=920006, + message_id=720006, + chat_id=75001, + user_id=76001, + username="continuity_builder", + text="Next: Follow up with design review", + ) + open_loop_capture_status, _open_loop_capture_payload = _handle_message( + session_token=session_token, + message_id=open_loop_capture_id, + ) + assert open_loop_capture_status == 200 + + open_loops_status, open_loops_payload = invoke_request( + "GET", + "/v1/channels/telegram/open-loops", + headers=auth_header(session_token), + ) + assert open_loops_status == 200 + next_action_items = open_loops_payload["open_loops"]["dashboard"]["next_action"]["items"] + assert len(next_action_items) >= 1 + open_loop_id = next_action_items[0]["id"] + + review_status, review_payload = invoke_request( + "POST", + f"/v1/channels/telegram/open-loops/{open_loop_id}/review-action", + payload={"action": "deferred", "note": "waiting on external input"}, + headers=auth_header(session_token), + ) + assert review_status == 200 + assert review_payload["review_action"] == "deferred" + assert review_payload["review_log"]["review_action"] == "deferred" + + recall_endpoint_status, recall_endpoint_payload = invoke_request( + "GET", + "/v1/channels/telegram/recall", + query_params={"query": "sign-off"}, + headers=auth_header(session_token), + ) + assert recall_endpoint_status == 200 + assert len(recall_endpoint_payload["recall"]["items"]) >= 1 + + resume_endpoint_status, resume_endpoint_payload = invoke_request( + "GET", + "/v1/channels/telegram/resume", + headers=auth_header(session_token), + ) + assert resume_endpoint_status == 200 + assert "brief" in resume_endpoint_payload["resume"] + + wrong_intent_message_id = _ingest_message( + update_id=920007, + message_id=720007, + chat_id=75001, + user_id=76001, + username="continuity_builder", + text="Remember to sync final notes", + ) + wrong_intent_status, wrong_intent_payload = _handle_message( + session_token=session_token, + message_id=wrong_intent_message_id, + intent_hint="recall", + ) + assert wrong_intent_status == 200 + assert wrong_intent_payload["intent"]["status"] == "failed" + assert wrong_intent_payload["intent"]["result_payload"]["error"]["code"] == "intent_hint_mismatch" + + +def test_phase10_telegram_approval_endpoints_and_chat_resolution( + migrated_database_urls, + monkeypatch, +) -> None: + _configure_settings(migrated_database_urls, monkeypatch) + session_token, workspace_id = _bootstrap_workspace_session("p10s3-approvals@example.com") + _link_telegram_chat( + session_token=session_token, + workspace_id=workspace_id, + chat_id=85001, + user_id=86001, + username="approval_builder", + ) + + seed_message_id = _ingest_message( + update_id=930001, + message_id=730001, + chat_id=85001, + user_id=86001, + username="approval_builder", + text="Decision: establish continuity user shadow", + ) + seed_handle_status, _seed_handle_payload = _handle_message( + session_token=session_token, + message_id=seed_message_id, + ) + assert seed_handle_status == 200 + + session_status, session_payload = invoke_request( + "GET", + "/v1/auth/session", + headers=auth_header(session_token), + ) + assert session_status == 200 + user_account_id = UUID(session_payload["user_account"]["id"]) + + approval_a = _seed_pending_approval( + admin_db_url=migrated_database_urls["admin"], + user_id=user_account_id, + seed_key="approval-a", + ) + approval_b = _seed_pending_approval( + admin_db_url=migrated_database_urls["admin"], + user_id=user_account_id, + seed_key="approval-b", + ) + approval_c = _seed_pending_approval( + admin_db_url=migrated_database_urls["admin"], + user_id=user_account_id, + seed_key="approval-c", + ) + approval_d = _seed_pending_approval( + admin_db_url=migrated_database_urls["admin"], + user_id=user_account_id, + seed_key="approval-d", + ) + + list_status, list_payload = invoke_request( + "GET", + "/v1/channels/telegram/approvals", + headers=auth_header(session_token), + ) + assert list_status == 200 + listed_ids = {item["id"] for item in list_payload["items"]} + assert str(approval_a) in listed_ids + assert str(approval_b) in listed_ids + assert list_payload["summary"]["pending_count"] >= 4 + + approve_status, approve_payload = invoke_request( + "POST", + f"/v1/channels/telegram/approvals/{approval_a}/approve", + payload={}, + headers=auth_header(session_token), + ) + assert approve_status == 200 + assert approve_payload["approval"]["status"] == "approved" + assert isinstance(approve_payload["challenge_updates"], list) + + reject_status, reject_payload = invoke_request( + "POST", + f"/v1/channels/telegram/approvals/{approval_b}/reject", + payload={}, + headers=auth_header(session_token), + ) + assert reject_status == 200 + assert reject_payload["approval"]["status"] == "rejected" + assert isinstance(reject_payload["challenge_updates"], list) + + approvals_message_id = _ingest_message( + update_id=930002, + message_id=730002, + chat_id=85001, + user_id=86001, + username="approval_builder", + text="/approvals", + ) + approvals_handle_status, approvals_handle_payload = _handle_message( + session_token=session_token, + message_id=approvals_message_id, + ) + assert approvals_handle_status == 200 + assert approvals_handle_payload["intent"]["intent_kind"] == "approvals" + + missing_approve_id_message_id = _ingest_message( + update_id=930005, + message_id=730005, + chat_id=85001, + user_id=86001, + username="approval_builder", + text="/approve", + ) + missing_approve_id_status, missing_approve_id_payload = _handle_message( + session_token=session_token, + message_id=missing_approve_id_message_id, + ) + assert missing_approve_id_status == 200 + assert missing_approve_id_payload["intent"]["intent_kind"] == "approval_approve" + assert missing_approve_id_payload["intent"]["status"] == "failed" + assert ( + missing_approve_id_payload["intent"]["result_payload"]["error"]["detail"] + == "approve intent requires approval id" + ) + + missing_reject_id_message_id = _ingest_message( + update_id=930006, + message_id=730006, + chat_id=85001, + user_id=86001, + username="approval_builder", + text="/reject", + ) + missing_reject_id_status, missing_reject_id_payload = _handle_message( + session_token=session_token, + message_id=missing_reject_id_message_id, + ) + assert missing_reject_id_status == 200 + assert missing_reject_id_payload["intent"]["intent_kind"] == "approval_reject" + assert missing_reject_id_payload["intent"]["status"] == "failed" + assert ( + missing_reject_id_payload["intent"]["result_payload"]["error"]["detail"] + == "reject intent requires approval id" + ) + + approve_chat_message_id = _ingest_message( + update_id=930003, + message_id=730003, + chat_id=85001, + user_id=86001, + username="approval_builder", + text=f"/approve {approval_c}", + ) + approve_chat_status, approve_chat_payload = _handle_message( + session_token=session_token, + message_id=approve_chat_message_id, + ) + assert approve_chat_status == 200 + assert approve_chat_payload["intent"]["intent_kind"] == "approval_approve" + assert approve_chat_payload["intent"]["status"] == "handled" + + reject_chat_message_id = _ingest_message( + update_id=930004, + message_id=730004, + chat_id=85001, + user_id=86001, + username="approval_builder", + text=f"/reject {approval_d} no longer needed", + ) + reject_chat_status, reject_chat_payload = _handle_message( + session_token=session_token, + message_id=reject_chat_message_id, + ) + assert reject_chat_status == 200 + assert reject_chat_payload["intent"]["intent_kind"] == "approval_reject" + assert reject_chat_payload["intent"]["status"] == "handled" + + pending_after_status, pending_after_payload = invoke_request( + "GET", + "/v1/channels/telegram/approvals", + headers=auth_header(session_token), + ) + assert pending_after_status == 200 + assert pending_after_payload["summary"]["pending_count"] == 0 diff --git a/tests/integration/test_phase10_daily_brief_notifications_api.py b/tests/integration/test_phase10_daily_brief_notifications_api.py new file mode 100644 index 0000000..fc5f04d --- /dev/null +++ b/tests/integration/test_phase10_daily_brief_notifications_api.py @@ -0,0 +1,602 @@ +from __future__ import annotations + +import hashlib +import json +from datetime import UTC, datetime, timedelta +from typing import Any +from urllib.parse import urlencode +from uuid import UUID, uuid4 + +import anyio +import psycopg + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings + + +def invoke_request( + method: str, + path: str, + *, + query_params: dict[str, str] | None = None, + payload: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, +) -> tuple[int, dict[str, Any]]: + messages: list[dict[str, object]] = [] + encoded_body = b"" if payload is None else json.dumps(payload).encode() + request_received = False + + async def receive() -> dict[str, object]: + nonlocal request_received + if request_received: + return {"type": "http.disconnect"} + + request_received = True + return {"type": "http.request", "body": encoded_body, "more_body": False} + + async def send(message: dict[str, object]) -> None: + messages.append(message) + + query_string = urlencode(query_params or {}).encode() + request_headers = [(b"content-type", b"application/json")] + for key, value in (headers or {}).items(): + request_headers.append((key.lower().encode(), value.encode())) + + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": query_string, + "headers": request_headers, + "client": ("testclient", 50000), + "server": ("testserver", 80), + "root_path": "", + } + + anyio.run(main_module.app, scope, receive, send) + + start_message = next(message for message in messages if message["type"] == "http.response.start") + body = b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + return start_message["status"], json.loads(body) + + +def auth_header(session_token: str) -> dict[str, str]: + return {"authorization": f"Bearer {session_token}"} + + +def _configure_settings(migrated_database_urls, monkeypatch) -> None: + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + magic_link_ttl_seconds=600, + auth_session_ttl_seconds=3600, + device_link_ttl_seconds=600, + telegram_link_ttl_seconds=600, + telegram_bot_username="alicebot", + telegram_webhook_secret="", + telegram_bot_token="", + ), + ) + + +def _bootstrap_workspace_session(email: str) -> tuple[str, str]: + start_status, start_payload = invoke_request( + "POST", + "/v1/auth/magic-link/start", + payload={"email": email}, + ) + assert start_status == 200 + + verify_status, verify_payload = invoke_request( + "POST", + "/v1/auth/magic-link/verify", + payload={ + "challenge_token": start_payload["challenge"]["challenge_token"], + "device_label": "P10-S4 Device", + "device_key": f"device-{email}", + }, + ) + assert verify_status == 200 + session_token = verify_payload["session_token"] + + create_workspace_status, create_workspace_payload = invoke_request( + "POST", + "/v1/workspaces", + payload={"name": "P10-S4 Workspace"}, + headers=auth_header(session_token), + ) + assert create_workspace_status == 201 + workspace_id = create_workspace_payload["workspace"]["id"] + + bootstrap_status, bootstrap_payload = invoke_request( + "POST", + "/v1/workspaces/bootstrap", + payload={"workspace_id": workspace_id}, + headers=auth_header(session_token), + ) + assert bootstrap_status == 200 + assert bootstrap_payload["workspace"]["bootstrap_status"] == "ready" + return session_token, workspace_id + + +def _link_telegram_chat( + *, + session_token: str, + workspace_id: str, + chat_id: int, + user_id: int, + username: str, +) -> None: + start_status, start_payload = invoke_request( + "POST", + "/v1/channels/telegram/link/start", + payload={"workspace_id": workspace_id}, + headers=auth_header(session_token), + ) + assert start_status == 200 + challenge_token = start_payload["challenge"]["challenge_token"] + link_code = start_payload["challenge"]["link_code"] + + webhook_status, webhook_payload = invoke_request( + "POST", + "/v1/channels/telegram/webhook", + payload={ + "update_id": 944001, + "message": { + "message_id": 744001, + "date": 1710000000, + "chat": {"id": chat_id, "type": "private"}, + "from": {"id": user_id, "username": username}, + "text": f"/link {link_code}", + }, + }, + ) + assert webhook_status == 200 + assert webhook_payload["ingest"]["link_status"] == "confirmed" + + confirm_status, confirm_payload = invoke_request( + "POST", + "/v1/channels/telegram/link/confirm", + payload={"challenge_token": challenge_token}, + headers=auth_header(session_token), + ) + assert confirm_status == 201 + assert confirm_payload["identity"]["status"] == "linked" + + +def _resolve_user_account_id(*, admin_db_url: str, session_token: str) -> UUID: + token_hash = hashlib.sha256(session_token.encode("utf-8")).hexdigest() + with psycopg.connect(admin_db_url) as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT user_account_id + FROM auth_sessions + WHERE session_token_hash = %s + LIMIT 1 + """, + (token_hash,), + ) + row = cur.fetchone() + if row is None: + raise AssertionError("failed to resolve user account id for session token") + return row[0] + + +def _seed_open_loop_objects(*, admin_db_url: str, user_id: UUID) -> None: + now = datetime(2026, 4, 8, 8, 0, tzinfo=UTC) + seeded = [ + ("WaitingFor", "active", "Waiting For: Vendor SLA"), + ("Blocker", "active", "Blocker: Missing release key"), + ("NextAction", "active", "Next Action: Publish release note"), + ("WaitingFor", "stale", "Waiting For: Stale security signoff"), + ] + + with psycopg.connect(admin_db_url) as conn: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO users (id, email, display_name) + VALUES (%s, %s, %s) + ON CONFLICT (id) DO UPDATE + SET email = EXCLUDED.email, + display_name = EXCLUDED.display_name + """, + (str(user_id), f"p10s4-{user_id}@example.com", "P10-S4 User"), + ) + + for index, (object_type, status, title) in enumerate(seeded): + capture_event_id = uuid4() + continuity_object_id = uuid4() + created_at = now - timedelta(minutes=index + 1) + cur.execute( + """ + INSERT INTO continuity_capture_events ( + id, + user_id, + raw_content, + explicit_signal, + admission_posture, + admission_reason, + created_at + ) + VALUES (%s, %s, %s, 'note', 'TRIAGE', 'integration_seed', %s) + """, + ( + str(capture_event_id), + str(user_id), + title, + created_at, + ), + ) + cur.execute( + """ + INSERT INTO continuity_objects ( + id, + user_id, + capture_event_id, + object_type, + status, + title, + body, + provenance, + confidence, + last_confirmed_at, + supersedes_object_id, + superseded_by_object_id, + created_at, + updated_at + ) + VALUES ( + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + NULL, + NULL, + NULL, + %s, + %s + ) + """, + ( + str(continuity_object_id), + str(user_id), + str(capture_event_id), + object_type, + status, + title, + json.dumps({"text": title}), + json.dumps({"thread_id": "p10s4-thread"}), + 0.9, + created_at, + created_at, + ), + ) + + +def test_phase10_daily_brief_delivery_records_scheduler_and_idempotency( + migrated_database_urls, + monkeypatch, +) -> None: + _configure_settings(migrated_database_urls, monkeypatch) + session_token, workspace_id = _bootstrap_workspace_session("p10s4-builder@example.com") + _link_telegram_chat( + session_token=session_token, + workspace_id=workspace_id, + chat_id=988001, + user_id=688001, + username="p10s4builder", + ) + + user_account_id = _resolve_user_account_id( + admin_db_url=migrated_database_urls["admin"], + session_token=session_token, + ) + _seed_open_loop_objects(admin_db_url=migrated_database_urls["admin"], user_id=user_account_id) + + patch_status, patch_payload = invoke_request( + "PATCH", + "/v1/channels/telegram/notification-preferences", + payload={ + "notifications_enabled": True, + "daily_brief_enabled": True, + "open_loop_prompts_enabled": True, + "waiting_for_prompts_enabled": True, + "stale_prompts_enabled": True, + "timezone": "UTC", + "daily_brief_window_start": "00:00", + "quiet_hours_enabled": False, + }, + headers=auth_header(session_token), + ) + assert patch_status == 200 + assert patch_payload["notification_preferences"]["daily_brief_enabled"] is True + + preview_status, preview_payload = invoke_request( + "GET", + "/v1/channels/telegram/daily-brief", + headers=auth_header(session_token), + ) + assert preview_status == 200 + assert preview_payload["brief"]["assembly_version"] == "continuity_daily_brief_v0" + assert preview_payload["delivery_policy"]["allowed"] is True + + first_deliver_status, first_deliver_payload = invoke_request( + "POST", + "/v1/channels/telegram/daily-brief/deliver", + payload={}, + headers=auth_header(session_token), + ) + assert first_deliver_status == 201 + assert first_deliver_payload["idempotent_replay"] is False + assert first_deliver_payload["job"]["job_kind"] == "daily_brief" + assert first_deliver_payload["job"]["status"] == "simulated" + assert first_deliver_payload["delivery_receipt"]["status"] == "simulated" + assert first_deliver_payload["delivery_receipt"]["scheduler_job_kind"] == "daily_brief" + + second_deliver_status, second_deliver_payload = invoke_request( + "POST", + "/v1/channels/telegram/daily-brief/deliver", + payload={}, + headers=auth_header(session_token), + ) + assert second_deliver_status == 200 + assert second_deliver_payload["idempotent_replay"] is True + assert second_deliver_payload["job"]["id"] == first_deliver_payload["job"]["id"] + + receipts_status, receipts_payload = invoke_request( + "GET", + "/v1/channels/telegram/delivery-receipts", + headers=auth_header(session_token), + ) + assert receipts_status == 200 + assert receipts_payload["summary"]["total_count"] >= 1 + assert receipts_payload["items"][0]["scheduler_job_kind"] in {"daily_brief", "open_loop_prompt"} + + +def test_phase10_quiet_hours_disabled_notifications_and_stale_prompt_delivery( + migrated_database_urls, + monkeypatch, +) -> None: + _configure_settings(migrated_database_urls, monkeypatch) + session_token, workspace_id = _bootstrap_workspace_session("p10s4-suppress@example.com") + _link_telegram_chat( + session_token=session_token, + workspace_id=workspace_id, + chat_id=988002, + user_id=688002, + username="p10s4suppress", + ) + + user_account_id = _resolve_user_account_id( + admin_db_url=migrated_database_urls["admin"], + session_token=session_token, + ) + _seed_open_loop_objects(admin_db_url=migrated_database_urls["admin"], user_id=user_account_id) + + disabled_status, disabled_payload = invoke_request( + "PATCH", + "/v1/channels/telegram/notification-preferences", + payload={ + "notifications_enabled": False, + "daily_brief_enabled": True, + "timezone": "UTC", + "daily_brief_window_start": "00:00", + "quiet_hours_enabled": False, + }, + headers=auth_header(session_token), + ) + assert disabled_status == 200 + assert disabled_payload["notification_preferences"]["notifications_enabled"] is False + + disabled_deliver_status, disabled_deliver_payload = invoke_request( + "POST", + "/v1/channels/telegram/daily-brief/deliver", + payload={}, + headers=auth_header(session_token), + ) + assert disabled_deliver_status == 201 + assert disabled_deliver_payload["job"]["status"] == "suppressed_disabled" + assert disabled_deliver_payload["delivery_receipt"]["status"] == "suppressed" + + quiet_status, quiet_payload = invoke_request( + "PATCH", + "/v1/channels/telegram/notification-preferences", + payload={ + "notifications_enabled": True, + "daily_brief_enabled": True, + "open_loop_prompts_enabled": True, + "waiting_for_prompts_enabled": True, + "stale_prompts_enabled": True, + "timezone": "UTC", + "daily_brief_window_start": "00:00", + "quiet_hours_enabled": True, + "quiet_hours_start": "00:00", + "quiet_hours_end": "23:59", + }, + headers=auth_header(session_token), + ) + assert quiet_status == 200 + assert quiet_payload["notification_preferences"]["quiet_hours"]["enabled"] is True + + quiet_deliver_status, quiet_deliver_payload = invoke_request( + "POST", + "/v1/channels/telegram/daily-brief/deliver", + payload={"idempotency_key": "quiet-hours-p10s4-delivery"}, + headers=auth_header(session_token), + ) + assert quiet_deliver_status == 201 + assert quiet_deliver_payload["job"]["status"] == "suppressed_quiet_hours" + assert quiet_deliver_payload["delivery_receipt"]["status"] == "suppressed" + + enable_prompts_status, _enable_prompts_payload = invoke_request( + "PATCH", + "/v1/channels/telegram/notification-preferences", + payload={ + "notifications_enabled": True, + "daily_brief_enabled": True, + "open_loop_prompts_enabled": True, + "waiting_for_prompts_enabled": True, + "stale_prompts_enabled": True, + "timezone": "UTC", + "daily_brief_window_start": "00:00", + "quiet_hours_enabled": False, + }, + headers=auth_header(session_token), + ) + assert enable_prompts_status == 200 + + prompts_status, prompts_payload = invoke_request( + "GET", + "/v1/channels/telegram/open-loop-prompts", + headers=auth_header(session_token), + ) + assert prompts_status == 200 + assert prompts_payload["summary"]["returned_count"] >= 1 + + stale_prompt = next(item for item in prompts_payload["items"] if item["prompt_kind"] == "stale") + + first_prompt_status, first_prompt_payload = invoke_request( + "POST", + f"/v1/channels/telegram/open-loop-prompts/{stale_prompt['prompt_id']}/deliver", + payload={}, + headers=auth_header(session_token), + ) + assert first_prompt_status == 201 + assert first_prompt_payload["job"]["job_kind"] == "open_loop_prompt" + assert first_prompt_payload["job"]["status"] == "simulated" + + second_prompt_status, second_prompt_payload = invoke_request( + "POST", + f"/v1/channels/telegram/open-loop-prompts/{stale_prompt['prompt_id']}/deliver", + payload={}, + headers=auth_header(session_token), + ) + assert second_prompt_status == 200 + assert second_prompt_payload["idempotent_replay"] is True + + scheduler_status, scheduler_payload = invoke_request( + "GET", + "/v1/channels/telegram/scheduler/jobs", + headers=auth_header(session_token), + ) + assert scheduler_status == 200 + assert scheduler_payload["summary"]["total_count"] >= 1 + + +def test_phase10_custom_idempotency_key_is_scoped_per_workspace( + migrated_database_urls, + monkeypatch, +) -> None: + _configure_settings(migrated_database_urls, monkeypatch) + + session_token_a, workspace_id_a = _bootstrap_workspace_session("p10s4-scope-a@example.com") + _link_telegram_chat( + session_token=session_token_a, + workspace_id=workspace_id_a, + chat_id=988010, + user_id=688010, + username="p10s4scopea", + ) + user_account_id_a = _resolve_user_account_id( + admin_db_url=migrated_database_urls["admin"], + session_token=session_token_a, + ) + _seed_open_loop_objects(admin_db_url=migrated_database_urls["admin"], user_id=user_account_id_a) + + session_token_b, workspace_id_b = _bootstrap_workspace_session("p10s4-scope-b@example.com") + _link_telegram_chat( + session_token=session_token_b, + workspace_id=workspace_id_b, + chat_id=988011, + user_id=688011, + username="p10s4scopeb", + ) + user_account_id_b = _resolve_user_account_id( + admin_db_url=migrated_database_urls["admin"], + session_token=session_token_b, + ) + _seed_open_loop_objects(admin_db_url=migrated_database_urls["admin"], user_id=user_account_id_b) + + patch_payload = { + "notifications_enabled": True, + "daily_brief_enabled": True, + "open_loop_prompts_enabled": True, + "waiting_for_prompts_enabled": True, + "stale_prompts_enabled": True, + "timezone": "UTC", + "daily_brief_window_start": "00:00", + "quiet_hours_enabled": False, + } + patch_a_status, _ = invoke_request( + "PATCH", + "/v1/channels/telegram/notification-preferences", + payload=patch_payload, + headers=auth_header(session_token_a), + ) + patch_b_status, _ = invoke_request( + "PATCH", + "/v1/channels/telegram/notification-preferences", + payload=patch_payload, + headers=auth_header(session_token_b), + ) + assert patch_a_status == 200 + assert patch_b_status == 200 + + shared_key = "shared-p10s4-idempotency-key" + + first_a_status, first_a_payload = invoke_request( + "POST", + "/v1/channels/telegram/daily-brief/deliver", + payload={"idempotency_key": shared_key}, + headers=auth_header(session_token_a), + ) + first_b_status, first_b_payload = invoke_request( + "POST", + "/v1/channels/telegram/daily-brief/deliver", + payload={"idempotency_key": shared_key}, + headers=auth_header(session_token_b), + ) + assert first_a_status == 201 + assert first_b_status == 201 + assert first_a_payload["idempotent_replay"] is False + assert first_b_payload["idempotent_replay"] is False + assert first_a_payload["workspace_id"] == workspace_id_a + assert first_b_payload["workspace_id"] == workspace_id_b + assert first_a_payload["job"]["id"] != first_b_payload["job"]["id"] + assert first_a_payload["delivery_receipt"]["id"] != first_b_payload["delivery_receipt"]["id"] + + replay_a_status, replay_a_payload = invoke_request( + "POST", + "/v1/channels/telegram/daily-brief/deliver", + payload={"idempotency_key": shared_key}, + headers=auth_header(session_token_a), + ) + replay_b_status, replay_b_payload = invoke_request( + "POST", + "/v1/channels/telegram/daily-brief/deliver", + payload={"idempotency_key": shared_key}, + headers=auth_header(session_token_b), + ) + assert replay_a_status == 200 + assert replay_b_status == 200 + assert replay_a_payload["idempotent_replay"] is True + assert replay_b_payload["idempotent_replay"] is True + assert replay_a_payload["job"]["id"] == first_a_payload["job"]["id"] + assert replay_b_payload["job"]["id"] == first_b_payload["job"]["id"] diff --git a/tests/integration/test_phase10_identity_workspace_bootstrap_api.py b/tests/integration/test_phase10_identity_workspace_bootstrap_api.py new file mode 100644 index 0000000..bfe1d3c --- /dev/null +++ b/tests/integration/test_phase10_identity_workspace_bootstrap_api.py @@ -0,0 +1,518 @@ +from __future__ import annotations + +import json +from typing import Any +from urllib.parse import urlencode + +import anyio +import psycopg +from psycopg.rows import dict_row + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +from apps.api.src.alicebot_api.hosted_auth import hash_token + + +def invoke_request( + method: str, + path: str, + *, + query_params: dict[str, str] | None = None, + payload: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, +) -> tuple[int, dict[str, Any]]: + messages: list[dict[str, object]] = [] + encoded_body = b"" if payload is None else json.dumps(payload).encode() + request_received = False + + async def receive() -> dict[str, object]: + nonlocal request_received + if request_received: + return {"type": "http.disconnect"} + + request_received = True + return {"type": "http.request", "body": encoded_body, "more_body": False} + + async def send(message: dict[str, object]) -> None: + messages.append(message) + + query_string = urlencode(query_params or {}).encode() + request_headers = [(b"content-type", b"application/json")] + for key, value in (headers or {}).items(): + request_headers.append((key.lower().encode(), value.encode())) + + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": query_string, + "headers": request_headers, + "client": ("testclient", 50000), + "server": ("testserver", 80), + "root_path": "", + } + + anyio.run(main_module.app, scope, receive, send) + + start_message = next(message for message in messages if message["type"] == "http.response.start") + body = b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + return start_message["status"], json.loads(body) + + +def auth_header(session_token: str) -> dict[str, str]: + return {"authorization": f"Bearer {session_token}"} + + +def test_phase10_identity_workspace_bootstrap_and_preferences_flow( + migrated_database_urls, + monkeypatch, +) -> None: + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + magic_link_ttl_seconds=600, + auth_session_ttl_seconds=3600, + device_link_ttl_seconds=600, + ), + ) + + start_status, start_payload = invoke_request( + "POST", + "/v1/auth/magic-link/start", + payload={"email": "builder@example.com"}, + ) + assert start_status == 200 + challenge_token = start_payload["challenge"]["challenge_token"] + + verify_status, verify_payload = invoke_request( + "POST", + "/v1/auth/magic-link/verify", + payload={ + "challenge_token": challenge_token, + "device_label": "Builder Laptop", + "device_key": "builder-laptop", + }, + ) + assert verify_status == 200 + assert verify_payload["workspace"] is None + assert verify_payload["telegram_state"] == "available_in_p10_s2_transport" + + session_token = verify_payload["session_token"] + primary_device_id = verify_payload["session"]["device_id"] + + session_status, session_payload = invoke_request( + "GET", + "/v1/auth/session", + headers=auth_header(session_token), + ) + assert session_status == 200 + assert session_payload["user_account"]["email"] == "builder@example.com" + assert session_payload["preferences"]["timezone"] == "UTC" + + create_workspace_status, create_workspace_payload = invoke_request( + "POST", + "/v1/workspaces", + payload={"name": "Builder Control Plane"}, + headers=auth_header(session_token), + ) + assert create_workspace_status == 201 + workspace_id = create_workspace_payload["workspace"]["id"] + + current_workspace_status, current_workspace_payload = invoke_request( + "GET", + "/v1/workspaces/current", + headers=auth_header(session_token), + ) + assert current_workspace_status == 200 + assert current_workspace_payload["workspace"]["id"] == workspace_id + + bootstrap_status_before, bootstrap_payload_before = invoke_request( + "GET", + "/v1/workspaces/bootstrap/status", + headers=auth_header(session_token), + ) + assert bootstrap_status_before == 200 + assert bootstrap_payload_before["bootstrap"]["status"] == "pending" + assert bootstrap_payload_before["bootstrap"]["telegram_state"] == "available_in_p10_s2_transport" + + bootstrap_status, bootstrap_payload = invoke_request( + "POST", + "/v1/workspaces/bootstrap", + payload={"workspace_id": workspace_id}, + headers=auth_header(session_token), + ) + assert bootstrap_status == 200 + assert bootstrap_payload["workspace"]["bootstrap_status"] == "ready" + assert bootstrap_payload["bootstrap"]["ready_for_next_phase_telegram_linkage"] is True + assert bootstrap_payload["telegram_state"] == "available_in_p10_s2_transport" + + duplicate_bootstrap_status, duplicate_bootstrap_payload = invoke_request( + "POST", + "/v1/workspaces/bootstrap", + payload={"workspace_id": workspace_id}, + headers=auth_header(session_token), + ) + assert duplicate_bootstrap_status == 409 + assert "already complete" in duplicate_bootstrap_payload["detail"] + + get_preferences_status, get_preferences_payload = invoke_request( + "GET", + "/v1/preferences", + headers=auth_header(session_token), + ) + assert get_preferences_status == 200 + assert get_preferences_payload["preferences"]["timezone"] == "UTC" + + patch_preferences_status, patch_preferences_payload = invoke_request( + "PATCH", + "/v1/preferences", + payload={ + "timezone": "Europe/Stockholm", + "brief_preferences": { + "daily_brief": {"enabled": True, "window_start": "08:30"}, + "mode": "hosted_foundation", + }, + "quiet_hours": {"enabled": True, "start": "21:00", "end": "06:30"}, + }, + headers=auth_header(session_token), + ) + assert patch_preferences_status == 200 + assert patch_preferences_payload["preferences"]["timezone"] == "Europe/Stockholm" + assert patch_preferences_payload["preferences"]["brief_preferences"]["mode"] == "hosted_foundation" + + start_link_status, start_link_payload = invoke_request( + "POST", + "/v1/devices/link/start", + payload={"device_key": "builder-phone", "device_label": "Builder Phone"}, + headers=auth_header(session_token), + ) + assert start_link_status == 200 + + confirm_link_status, confirm_link_payload = invoke_request( + "POST", + "/v1/devices/link/confirm", + payload={"challenge_token": start_link_payload["challenge"]["challenge_token"]}, + headers=auth_header(session_token), + ) + assert confirm_link_status == 201 + linked_device_id = confirm_link_payload["device"]["id"] + + list_devices_status, list_devices_payload = invoke_request( + "GET", + "/v1/devices", + headers=auth_header(session_token), + ) + assert list_devices_status == 200 + assert list_devices_payload["summary"]["total_count"] >= 2 + + delete_linked_status, delete_linked_payload = invoke_request( + "DELETE", + f"/v1/devices/{linked_device_id}", + headers=auth_header(session_token), + ) + assert delete_linked_status == 200 + assert delete_linked_payload["device"]["status"] == "revoked" + + delete_primary_status, delete_primary_payload = invoke_request( + "DELETE", + f"/v1/devices/{primary_device_id}", + headers=auth_header(session_token), + ) + assert delete_primary_status == 200 + assert delete_primary_payload["device"]["status"] == "revoked" + + revoked_session_status, revoked_session_payload = invoke_request( + "GET", + "/v1/auth/session", + headers=auth_header(session_token), + ) + assert revoked_session_status == 401 + assert revoked_session_payload == {"detail": "session device has been revoked"} + + +def test_phase10_logout_revokes_session(migrated_database_urls, monkeypatch) -> None: + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + magic_link_ttl_seconds=600, + auth_session_ttl_seconds=3600, + device_link_ttl_seconds=600, + ), + ) + + start_status, start_payload = invoke_request( + "POST", + "/v1/auth/magic-link/start", + payload={"email": "logout@example.com"}, + ) + assert start_status == 200 + + verify_status, verify_payload = invoke_request( + "POST", + "/v1/auth/magic-link/verify", + payload={ + "challenge_token": start_payload["challenge"]["challenge_token"], + "device_label": "Logout Device", + "device_key": "logout-device", + }, + ) + assert verify_status == 200 + session_token = verify_payload["session_token"] + + logout_status, logout_payload = invoke_request( + "POST", + "/v1/auth/logout", + headers=auth_header(session_token), + ) + assert logout_status == 200 + assert logout_payload == {"status": "logged_out"} + + session_status, session_payload = invoke_request( + "GET", + "/v1/auth/session", + headers=auth_header(session_token), + ) + assert session_status == 401 + assert session_payload == {"detail": "session is not active"} + + +def test_phase10_magic_link_invalid_and_expired_paths(migrated_database_urls, monkeypatch) -> None: + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + magic_link_ttl_seconds=600, + auth_session_ttl_seconds=3600, + device_link_ttl_seconds=600, + ), + ) + + invalid_verify_status, invalid_verify_payload = invoke_request( + "POST", + "/v1/auth/magic-link/verify", + payload={ + "challenge_token": "invalid-token-value-for-phase10", + "device_label": "Invalid Device", + "device_key": "invalid-device", + }, + ) + assert invalid_verify_status == 400 + assert "invalid" in invalid_verify_payload["detail"] + + start_status, start_payload = invoke_request( + "POST", + "/v1/auth/magic-link/start", + payload={"email": "expired@example.com"}, + ) + assert start_status == 200 + challenge_token = start_payload["challenge"]["challenge_token"] + + with psycopg.connect(migrated_database_urls["app"], row_factory=dict_row) as conn: + with conn.transaction(): + with conn.cursor() as cur: + cur.execute( + """ + UPDATE magic_link_challenges + SET expires_at = clock_timestamp() - interval '1 second' + WHERE challenge_token_hash = %s + """, + (hash_token(challenge_token),), + ) + + expired_verify_status, expired_verify_payload = invoke_request( + "POST", + "/v1/auth/magic-link/verify", + payload={ + "challenge_token": challenge_token, + "device_label": "Expired Device", + "device_key": "expired-device", + }, + ) + assert expired_verify_status == 401 + assert expired_verify_payload == {"detail": "magic-link token has expired"} + + invalid_session_status, invalid_session_payload = invoke_request( + "GET", + "/v1/auth/session", + headers=auth_header("totally-invalid-session-token"), + ) + assert invalid_session_status == 401 + assert "invalid" in invalid_session_payload["detail"] + + +def test_phase10_device_link_invalid_and_expired_paths(migrated_database_urls, monkeypatch) -> None: + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + magic_link_ttl_seconds=600, + auth_session_ttl_seconds=3600, + device_link_ttl_seconds=600, + ), + ) + + start_status, start_payload = invoke_request( + "POST", + "/v1/auth/magic-link/start", + payload={"email": "device-link@example.com"}, + ) + assert start_status == 200 + + verify_status, verify_payload = invoke_request( + "POST", + "/v1/auth/magic-link/verify", + payload={ + "challenge_token": start_payload["challenge"]["challenge_token"], + "device_label": "Primary Device", + "device_key": "primary-device", + }, + ) + assert verify_status == 200 + session_token = verify_payload["session_token"] + + invalid_confirm_status, invalid_confirm_payload = invoke_request( + "POST", + "/v1/devices/link/confirm", + payload={"challenge_token": "invalid-device-link-token-value"}, + headers=auth_header(session_token), + ) + assert invalid_confirm_status == 400 + assert "invalid" in invalid_confirm_payload["detail"] + + start_link_status, start_link_payload = invoke_request( + "POST", + "/v1/devices/link/start", + payload={"device_key": "expiring-device", "device_label": "Expiring Device"}, + headers=auth_header(session_token), + ) + assert start_link_status == 200 + link_challenge_token = start_link_payload["challenge"]["challenge_token"] + + with psycopg.connect(migrated_database_urls["app"], row_factory=dict_row) as conn: + with conn.transaction(): + with conn.cursor() as cur: + cur.execute( + """ + UPDATE device_link_challenges + SET expires_at = clock_timestamp() - interval '1 second' + WHERE challenge_token_hash = %s + """, + (hash_token(link_challenge_token),), + ) + + expired_confirm_status, expired_confirm_payload = invoke_request( + "POST", + "/v1/devices/link/confirm", + payload={"challenge_token": link_challenge_token}, + headers=auth_header(session_token), + ) + assert expired_confirm_status == 401 + assert expired_confirm_payload == {"detail": "device-link token has expired"} + + +def test_phase10_magic_link_start_hides_challenge_token_outside_dev( + migrated_database_urls, + monkeypatch, +) -> None: + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + app_env="staging", + database_url=migrated_database_urls["app"], + magic_link_ttl_seconds=600, + auth_session_ttl_seconds=3600, + device_link_ttl_seconds=600, + entrypoint_rate_limit_backend="memory", + ), + ) + + start_status, start_payload = invoke_request( + "POST", + "/v1/auth/magic-link/start", + payload={"email": "staging-builder@example.com"}, + ) + + assert start_status == 200 + assert "challenge_token" not in start_payload["challenge"] + assert start_payload["delivery"] == { + "kind": "magic_link", + "posture": "out_of_band_delivery_required", + } + + +def test_phase10_magic_link_start_and_verify_rate_limits( + migrated_database_urls, + monkeypatch, +) -> None: + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + magic_link_ttl_seconds=600, + auth_session_ttl_seconds=3600, + device_link_ttl_seconds=600, + magic_link_start_rate_limit_max_requests=1, + magic_link_start_rate_limit_window_seconds=60, + magic_link_verify_rate_limit_max_requests=1, + magic_link_verify_rate_limit_window_seconds=60, + ), + ) + + start_status, start_payload = invoke_request( + "POST", + "/v1/auth/magic-link/start", + payload={"email": "rate-limit@example.com"}, + ) + assert start_status == 200 + + start_limited_status, start_limited_payload = invoke_request( + "POST", + "/v1/auth/magic-link/start", + payload={"email": "rate-limit@example.com"}, + ) + assert start_limited_status == 429 + assert start_limited_payload["detail"]["code"] == "magic_link_start_rate_limit_exceeded" + assert start_limited_payload["detail"]["max_requests"] == 1 + assert start_limited_payload["detail"]["window_seconds"] == 60 + + challenge_token = start_payload["challenge"]["challenge_token"] + verify_status, _verify_payload = invoke_request( + "POST", + "/v1/auth/magic-link/verify", + payload={ + "challenge_token": challenge_token, + "device_label": "Rate Limited Device", + "device_key": "rate-limited-device", + }, + ) + assert verify_status == 200 + + verify_limited_status, verify_limited_payload = invoke_request( + "POST", + "/v1/auth/magic-link/verify", + payload={ + "challenge_token": challenge_token, + "device_label": "Rate Limited Device", + "device_key": "rate-limited-device", + }, + ) + assert verify_limited_status == 429 + assert verify_limited_payload["detail"]["code"] == "magic_link_verify_rate_limit_exceeded" + assert verify_limited_payload["detail"]["max_requests"] == 1 + assert verify_limited_payload["detail"]["window_seconds"] == 60 diff --git a/tests/integration/test_phase10_telegram_transport_api.py b/tests/integration/test_phase10_telegram_transport_api.py new file mode 100644 index 0000000..d4a5a60 --- /dev/null +++ b/tests/integration/test_phase10_telegram_transport_api.py @@ -0,0 +1,666 @@ +from __future__ import annotations + +import json +from typing import Any +from urllib.parse import urlencode + +import anyio + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings + + +def invoke_request( + method: str, + path: str, + *, + query_params: dict[str, str] | None = None, + payload: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, +) -> tuple[int, dict[str, Any]]: + messages: list[dict[str, object]] = [] + encoded_body = b"" if payload is None else json.dumps(payload).encode() + request_received = False + + async def receive() -> dict[str, object]: + nonlocal request_received + if request_received: + return {"type": "http.disconnect"} + + request_received = True + return {"type": "http.request", "body": encoded_body, "more_body": False} + + async def send(message: dict[str, object]) -> None: + messages.append(message) + + query_string = urlencode(query_params or {}).encode() + request_headers = [(b"content-type", b"application/json")] + for key, value in (headers or {}).items(): + request_headers.append((key.lower().encode(), value.encode())) + + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": query_string, + "headers": request_headers, + "client": ("testclient", 50000), + "server": ("testserver", 80), + "root_path": "", + } + + anyio.run(main_module.app, scope, receive, send) + + start_message = next(message for message in messages if message["type"] == "http.response.start") + body = b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + return start_message["status"], json.loads(body) + + +def auth_header(session_token: str) -> dict[str, str]: + return {"authorization": f"Bearer {session_token}"} + + +def _configure_settings(migrated_database_urls, monkeypatch) -> None: + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + magic_link_ttl_seconds=600, + auth_session_ttl_seconds=3600, + device_link_ttl_seconds=600, + telegram_link_ttl_seconds=600, + telegram_bot_username="alicebot", + telegram_webhook_secret="", + telegram_bot_token="", + ), + ) + + +def _bootstrap_workspace_session() -> tuple[str, str]: + start_status, start_payload = invoke_request( + "POST", + "/v1/auth/magic-link/start", + payload={"email": "telegram-builder@example.com"}, + ) + assert start_status == 200 + + verify_status, verify_payload = invoke_request( + "POST", + "/v1/auth/magic-link/verify", + payload={ + "challenge_token": start_payload["challenge"]["challenge_token"], + "device_label": "Telegram Builder Device", + "device_key": "telegram-builder-device", + }, + ) + assert verify_status == 200 + session_token = verify_payload["session_token"] + + create_workspace_status, create_workspace_payload = invoke_request( + "POST", + "/v1/workspaces", + payload={"name": "Telegram Builder Workspace"}, + headers=auth_header(session_token), + ) + assert create_workspace_status == 201 + workspace_id = create_workspace_payload["workspace"]["id"] + + bootstrap_status, bootstrap_payload = invoke_request( + "POST", + "/v1/workspaces/bootstrap", + payload={"workspace_id": workspace_id}, + headers=auth_header(session_token), + ) + assert bootstrap_status == 200 + assert bootstrap_payload["workspace"]["bootstrap_status"] == "ready" + return session_token, workspace_id + + +def _create_and_bootstrap_workspace(session_token: str, workspace_name: str) -> str: + create_workspace_status, create_workspace_payload = invoke_request( + "POST", + "/v1/workspaces", + payload={"name": workspace_name}, + headers=auth_header(session_token), + ) + assert create_workspace_status == 201 + workspace_id = create_workspace_payload["workspace"]["id"] + + bootstrap_status, bootstrap_payload = invoke_request( + "POST", + "/v1/workspaces/bootstrap", + payload={"workspace_id": workspace_id}, + headers=auth_header(session_token), + ) + assert bootstrap_status == 200 + assert bootstrap_payload["workspace"]["bootstrap_status"] == "ready" + return workspace_id + + +def test_phase10_telegram_link_webhook_idempotency_and_dispatch_flow( + migrated_database_urls, + monkeypatch, +) -> None: + _configure_settings(migrated_database_urls, monkeypatch) + session_token, workspace_id = _bootstrap_workspace_session() + + link_start_status, link_start_payload = invoke_request( + "POST", + "/v1/channels/telegram/link/start", + payload={"workspace_id": workspace_id}, + headers=auth_header(session_token), + ) + assert link_start_status == 200 + challenge_token = link_start_payload["challenge"]["challenge_token"] + link_code = link_start_payload["challenge"]["link_code"] + + webhook_status, webhook_payload = invoke_request( + "POST", + "/v1/channels/telegram/webhook", + payload={ + "update_id": 2001, + "message": { + "message_id": 501, + "date": 1710000000, + "chat": {"id": 9001, "type": "private"}, + "from": {"id": 7001, "username": "builder"}, + "text": f"/link {link_code}", + }, + }, + ) + assert webhook_status == 200 + assert webhook_payload["ingest"]["duplicate"] is False + assert webhook_payload["ingest"]["route_status"] == "resolved" + assert webhook_payload["ingest"]["link_status"] == "confirmed" + + duplicate_webhook_status, duplicate_webhook_payload = invoke_request( + "POST", + "/v1/channels/telegram/webhook", + payload={ + "update_id": 2001, + "message": { + "message_id": 501, + "date": 1710000000, + "chat": {"id": 9001, "type": "private"}, + "from": {"id": 7001, "username": "builder"}, + "text": f"/link {link_code}", + }, + }, + ) + assert duplicate_webhook_status == 200 + assert duplicate_webhook_payload["ingest"]["duplicate"] is True + + confirm_status, confirm_payload = invoke_request( + "POST", + "/v1/channels/telegram/link/confirm", + payload={"challenge_token": challenge_token}, + headers=auth_header(session_token), + ) + assert confirm_status == 201 + assert confirm_payload["identity"]["status"] == "linked" + assert confirm_payload["identity"]["workspace_id"] == workspace_id + + status_code, status_payload = invoke_request( + "GET", + "/v1/channels/telegram/status", + headers=auth_header(session_token), + ) + assert status_code == 200 + assert status_payload["linked"] is True + assert status_payload["identity"]["external_chat_id"] == "9001" + + message_webhook_status, message_webhook_payload = invoke_request( + "POST", + "/v1/channels/telegram/webhook", + payload={ + "update_id": 2002, + "message": { + "message_id": 502, + "date": 1710000005, + "chat": {"id": 9001, "type": "private"}, + "from": {"id": 7001, "username": "builder"}, + "text": "hello from telegram", + }, + }, + ) + assert message_webhook_status == 200 + assert message_webhook_payload["ingest"]["route_status"] == "resolved" + + messages_status, messages_payload = invoke_request( + "GET", + "/v1/channels/telegram/messages", + headers=auth_header(session_token), + ) + assert messages_status == 200 + assert messages_payload["summary"]["total_count"] == 2 + + inbound_message = next( + item for item in messages_payload["items"] if item["provider_update_id"] == "2002" + ) + + threads_status, threads_payload = invoke_request( + "GET", + "/v1/channels/telegram/threads", + headers=auth_header(session_token), + ) + assert threads_status == 200 + assert threads_payload["summary"]["total_count"] == 1 + + dispatch_status, dispatch_payload = invoke_request( + "POST", + f"/v1/channels/telegram/messages/{inbound_message['id']}/dispatch", + payload={"text": "acknowledged"}, + headers=auth_header(session_token), + ) + assert dispatch_status == 201 + assert dispatch_payload["message"]["direction"] == "outbound" + assert dispatch_payload["receipt"]["status"] == "simulated" + + receipts_status, receipts_payload = invoke_request( + "GET", + "/v1/channels/telegram/delivery-receipts", + headers=auth_header(session_token), + ) + assert receipts_status == 200 + assert receipts_payload["summary"]["total_count"] == 1 + assert receipts_payload["items"][0]["status"] == "simulated" + + +def test_phase10_telegram_invalid_link_token_and_unknown_chat_routing( + migrated_database_urls, + monkeypatch, +) -> None: + _configure_settings(migrated_database_urls, monkeypatch) + session_token, _workspace_id = _bootstrap_workspace_session() + + invalid_confirm_status, invalid_confirm_payload = invoke_request( + "POST", + "/v1/channels/telegram/link/confirm", + payload={"challenge_token": "invalid-telegram-link-token"}, + headers=auth_header(session_token), + ) + assert invalid_confirm_status == 400 + assert "invalid" in invalid_confirm_payload["detail"] + + unknown_webhook_status, unknown_webhook_payload = invoke_request( + "POST", + "/v1/channels/telegram/webhook", + payload={ + "update_id": 3101, + "message": { + "message_id": 901, + "date": 1710001000, + "chat": {"id": 9900, "type": "private"}, + "from": {"id": 8800, "username": "unknown"}, + "text": "hello anyone there", + }, + }, + ) + assert unknown_webhook_status == 200 + assert unknown_webhook_payload["ingest"]["unknown_chat_routing"] is True + assert unknown_webhook_payload["ingest"]["route_status"] == "unresolved" + + messages_status, messages_payload = invoke_request( + "GET", + "/v1/channels/telegram/messages", + headers=auth_header(session_token), + ) + assert messages_status == 200 + assert messages_payload["summary"]["total_count"] == 0 + + malformed_webhook_status, malformed_webhook_payload = invoke_request( + "POST", + "/v1/channels/telegram/webhook", + payload={"message": {}}, + ) + assert malformed_webhook_status == 400 + assert "update_id" in malformed_webhook_payload["detail"] + + +def test_phase10_telegram_unlink_and_relink_flow( + migrated_database_urls, + monkeypatch, +) -> None: + _configure_settings(migrated_database_urls, monkeypatch) + session_token, workspace_id = _bootstrap_workspace_session() + + first_start_status, first_start_payload = invoke_request( + "POST", + "/v1/channels/telegram/link/start", + payload={"workspace_id": workspace_id}, + headers=auth_header(session_token), + ) + assert first_start_status == 200 + + first_webhook_status, _first_webhook_payload = invoke_request( + "POST", + "/v1/channels/telegram/webhook", + payload={ + "update_id": 4101, + "message": { + "message_id": 1101, + "date": 1710002000, + "chat": {"id": 777001, "type": "private"}, + "from": {"id": 77001, "username": "relinker"}, + "text": f"/link {first_start_payload['challenge']['link_code']}", + }, + }, + ) + assert first_webhook_status == 200 + + first_confirm_status, first_confirm_payload = invoke_request( + "POST", + "/v1/channels/telegram/link/confirm", + payload={"challenge_token": first_start_payload["challenge"]["challenge_token"]}, + headers=auth_header(session_token), + ) + assert first_confirm_status == 201 + assert first_confirm_payload["identity"]["status"] == "linked" + + unlink_status, unlink_payload = invoke_request( + "POST", + "/v1/channels/telegram/unlink", + payload={"workspace_id": workspace_id}, + headers=auth_header(session_token), + ) + assert unlink_status == 200 + assert unlink_payload["identity"]["status"] == "unlinked" + + unresolved_after_unlink_status, unresolved_after_unlink_payload = invoke_request( + "POST", + "/v1/channels/telegram/webhook", + payload={ + "update_id": 4102, + "message": { + "message_id": 1102, + "date": 1710002010, + "chat": {"id": 777001, "type": "private"}, + "from": {"id": 77001, "username": "relinker"}, + "text": "post-unlink message", + }, + }, + ) + assert unresolved_after_unlink_status == 200 + assert unresolved_after_unlink_payload["ingest"]["unknown_chat_routing"] is True + + second_start_status, second_start_payload = invoke_request( + "POST", + "/v1/channels/telegram/link/start", + payload={"workspace_id": workspace_id}, + headers=auth_header(session_token), + ) + assert second_start_status == 200 + + second_webhook_status, second_webhook_payload = invoke_request( + "POST", + "/v1/channels/telegram/webhook", + payload={ + "update_id": 4103, + "message": { + "message_id": 1103, + "date": 1710002020, + "chat": {"id": 777001, "type": "private"}, + "from": {"id": 77001, "username": "relinker"}, + "text": f"/link {second_start_payload['challenge']['link_code']}", + }, + }, + ) + assert second_webhook_status == 200 + assert second_webhook_payload["ingest"]["link_status"] == "confirmed" + + second_confirm_status, second_confirm_payload = invoke_request( + "POST", + "/v1/channels/telegram/link/confirm", + payload={"challenge_token": second_start_payload["challenge"]["challenge_token"]}, + headers=auth_header(session_token), + ) + assert second_confirm_status == 201 + assert second_confirm_payload["identity"]["status"] == "linked" + + final_status_code, final_status_payload = invoke_request( + "GET", + "/v1/channels/telegram/status", + headers=auth_header(session_token), + ) + assert final_status_code == 200 + assert final_status_payload["linked"] is True + assert final_status_payload["identity"]["external_chat_id"] == "777001" + + +def test_phase10_telegram_rejects_confirmed_link_code_replay_from_different_chat( + migrated_database_urls, + monkeypatch, +) -> None: + _configure_settings(migrated_database_urls, monkeypatch) + session_token, workspace_id = _bootstrap_workspace_session() + + link_start_status, link_start_payload = invoke_request( + "POST", + "/v1/channels/telegram/link/start", + payload={"workspace_id": workspace_id}, + headers=auth_header(session_token), + ) + assert link_start_status == 200 + challenge_token = link_start_payload["challenge"]["challenge_token"] + link_code = link_start_payload["challenge"]["link_code"] + + first_webhook_status, first_webhook_payload = invoke_request( + "POST", + "/v1/channels/telegram/webhook", + payload={ + "update_id": 5101, + "message": { + "message_id": 2101, + "date": 1710003000, + "chat": {"id": 880001, "type": "private"}, + "from": {"id": 880001, "username": "linkeduser"}, + "text": f"/link {link_code}", + }, + }, + ) + assert first_webhook_status == 200 + assert first_webhook_payload["ingest"]["link_status"] == "confirmed" + + confirm_status, confirm_payload = invoke_request( + "POST", + "/v1/channels/telegram/link/confirm", + payload={"challenge_token": challenge_token}, + headers=auth_header(session_token), + ) + assert confirm_status == 201 + assert confirm_payload["identity"]["external_chat_id"] == "880001" + + replay_status, replay_payload = invoke_request( + "POST", + "/v1/channels/telegram/webhook", + payload={ + "update_id": 5102, + "message": { + "message_id": 2102, + "date": 1710003010, + "chat": {"id": 880002, "type": "private"}, + "from": {"id": 880002, "username": "replayuser"}, + "text": f"/link {link_code}", + }, + }, + ) + assert replay_status == 200 + assert replay_payload["ingest"]["link_status"] == "invalid_link_code" + assert replay_payload["ingest"]["route_status"] == "unresolved" + assert replay_payload["ingest"]["unknown_chat_routing"] is True + + +def test_phase10_telegram_rejects_cross_workspace_identity_conflict( + migrated_database_urls, + monkeypatch, +) -> None: + _configure_settings(migrated_database_urls, monkeypatch) + session_token, workspace_id = _bootstrap_workspace_session() + + first_link_start_status, first_link_start_payload = invoke_request( + "POST", + "/v1/channels/telegram/link/start", + payload={"workspace_id": workspace_id}, + headers=auth_header(session_token), + ) + assert first_link_start_status == 200 + + first_webhook_status, first_webhook_payload = invoke_request( + "POST", + "/v1/channels/telegram/webhook", + payload={ + "update_id": 6101, + "message": { + "message_id": 3101, + "date": 1710004000, + "chat": {"id": 990001, "type": "private"}, + "from": {"id": 990001, "username": "workspaceone"}, + "text": f"/link {first_link_start_payload['challenge']['link_code']}", + }, + }, + ) + assert first_webhook_status == 200 + assert first_webhook_payload["ingest"]["link_status"] == "confirmed" + + first_confirm_status, _first_confirm_payload = invoke_request( + "POST", + "/v1/channels/telegram/link/confirm", + payload={"challenge_token": first_link_start_payload["challenge"]["challenge_token"]}, + headers=auth_header(session_token), + ) + assert first_confirm_status == 201 + + second_workspace_id = _create_and_bootstrap_workspace( + session_token, + "Telegram Builder Workspace Two", + ) + + second_link_start_status, second_link_start_payload = invoke_request( + "POST", + "/v1/channels/telegram/link/start", + payload={"workspace_id": second_workspace_id}, + headers=auth_header(session_token), + ) + assert second_link_start_status == 200 + + second_webhook_status, second_webhook_payload = invoke_request( + "POST", + "/v1/channels/telegram/webhook", + payload={ + "update_id": 6102, + "message": { + "message_id": 3102, + "date": 1710004010, + "chat": {"id": 990001, "type": "private"}, + "from": {"id": 990001, "username": "workspaceone"}, + "text": f"/link {second_link_start_payload['challenge']['link_code']}", + }, + }, + ) + assert second_webhook_status == 200 + assert second_webhook_payload["ingest"]["link_status"] == "identity_conflict" + + second_confirm_status, second_confirm_payload = invoke_request( + "POST", + "/v1/channels/telegram/link/confirm", + payload={"challenge_token": second_link_start_payload["challenge"]["challenge_token"]}, + headers=auth_header(session_token), + ) + assert second_confirm_status == 409 + assert "pending webhook confirmation" in second_confirm_payload["detail"] + + second_status_code, second_status_payload = invoke_request( + "GET", + "/v1/channels/telegram/status", + query_params={"workspace_id": second_workspace_id}, + headers=auth_header(session_token), + ) + assert second_status_code == 200 + assert second_status_payload["linked"] is False + + +def test_phase10_telegram_webhook_requires_secret_outside_dev( + migrated_database_urls, + monkeypatch, +) -> None: + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + app_env="staging", + database_url=migrated_database_urls["app"], + telegram_webhook_secret="", + telegram_bot_token="", + telegram_bot_username="alicebot", + ), + ) + + webhook_status, webhook_payload = invoke_request( + "POST", + "/v1/channels/telegram/webhook", + payload={"update_id": 1, "message": {"message_id": 1}}, + ) + assert webhook_status == 503 + assert webhook_payload == {"detail": "telegram webhook ingress is not configured"} + + +def test_phase10_telegram_webhook_rate_limit_enforced( + migrated_database_urls, + monkeypatch, +) -> None: + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + telegram_webhook_secret="", + telegram_bot_token="", + telegram_bot_username="alicebot", + telegram_webhook_rate_limit_max_requests=1, + telegram_webhook_rate_limit_window_seconds=60, + ), + ) + + first_webhook_status, _first_webhook_payload = invoke_request( + "POST", + "/v1/channels/telegram/webhook", + payload={ + "update_id": 7101, + "message": { + "message_id": 4001, + "date": 1710007000, + "chat": {"id": 12345, "type": "private"}, + "from": {"id": 12345, "username": "ratelimited"}, + "text": "hello", + }, + }, + ) + assert first_webhook_status == 200 + + second_webhook_status, second_webhook_payload = invoke_request( + "POST", + "/v1/channels/telegram/webhook", + payload={ + "update_id": 7102, + "message": { + "message_id": 4002, + "date": 1710007005, + "chat": {"id": 12345, "type": "private"}, + "from": {"id": 12345, "username": "ratelimited"}, + "text": "hello again", + }, + }, + ) + assert second_webhook_status == 429 + assert second_webhook_payload["detail"]["code"] == "telegram_webhook_rate_limit_exceeded" + assert second_webhook_payload["detail"]["max_requests"] == 1 + assert second_webhook_payload["detail"]["window_seconds"] == 60 diff --git a/tests/integration/test_phase4_acceptance_suite.py b/tests/integration/test_phase4_acceptance_suite.py new file mode 100644 index 0000000..7f95655 --- /dev/null +++ b/tests/integration/test_phase4_acceptance_suite.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import scripts.run_phase4_acceptance as phase4_acceptance + + +def test_acceptance_scenario_mapping_is_deterministic_and_contains_magnesium() -> None: + assert [scenario.scenario for scenario in phase4_acceptance.ACCEPTANCE_SCENARIOS] == [ + "response_memory", + "capture_resumption", + "approval_execution", + "magnesium_reorder", + ] + assert phase4_acceptance.ACCEPTANCE_TEST_NODE_IDS == ( + "tests/integration/test_mvp_acceptance_suite.py::" + "test_acceptance_response_path_uses_admitted_memory_and_preference_correction", + "tests/integration/test_mvp_acceptance_suite.py::" + "test_acceptance_explicit_signal_capture_flows_into_resumption_brief", + "tests/integration/test_mvp_acceptance_suite.py::" + "test_acceptance_approval_lifecycle_resolution_execution_and_trace_availability", + "tests/integration/test_mvp_acceptance_suite.py::" + "test_acceptance_canonical_magnesium_reorder_flow_with_memory_write_back_evidence", + ) + + +def test_phase4_acceptance_induced_failure_sets_mvp_compat_env(monkeypatch, capsys) -> None: + captured: dict[str, object] = {} + + def fake_run(command, *, cwd, env, check): # noqa: ANN001 + captured["command"] = command + captured["cwd"] = cwd + captured["env"] = env + captured["check"] = check + return SimpleNamespace(returncode=13) + + monkeypatch.setattr(phase4_acceptance, "_resolve_python_executable", lambda: "/usr/bin/python3") + monkeypatch.setattr( + phase4_acceptance.sys, + "argv", + ["scripts/run_phase4_acceptance.py", "--induce-failure", "magnesium_reorder"], + ) + monkeypatch.setattr(phase4_acceptance.subprocess, "run", fake_run) + + exit_code = phase4_acceptance.main() + output = capsys.readouterr().out + + assert exit_code == 13 + assert captured["command"] == [ + "/usr/bin/python3", + "-m", + "pytest", + "-q", + *phase4_acceptance.ACCEPTANCE_TEST_NODE_IDS, + ] + assert captured["cwd"] == phase4_acceptance.ROOT_DIR + assert captured["check"] is False + assert ( + captured["env"][phase4_acceptance.INDUCED_FAILURE_ENV] # type: ignore[index] + == "magnesium_reorder" + ) + assert "magnesium_reorder" in output + assert "canonical MVP ship gate" in output diff --git a/tests/integration/test_phase4_mvp_exit_manifest.py b/tests/integration/test_phase4_mvp_exit_manifest.py new file mode 100644 index 0000000..7d7ac58 --- /dev/null +++ b/tests/integration/test_phase4_mvp_exit_manifest.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from datetime import UTC, datetime +import json +from pathlib import Path + +import scripts.generate_phase4_mvp_exit_manifest as generate_manifest +import scripts.run_phase4_release_candidate as release_candidate +import scripts.verify_phase4_mvp_exit_manifest as verify_manifest + + +def _always_pass_executor(command: tuple[str, ...], cwd: Path) -> int: + del command, cwd + return 0 + + +def _pass_except_induced_executor(command: tuple[str, ...], cwd: Path) -> int: + del cwd + if "-c" in command and "Induced phase4 release-candidate failure" in command[-1]: + return release_candidate.INDUCED_FAILURE_EXIT_CODE + return 0 + + +def _write_go_archive(tmp_path: Path, created_at: datetime) -> Path: + step_results = release_candidate.run_release_candidate(execute_command=_always_pass_executor) + summary_path = tmp_path / "phase4_rc_summary.json" + release_candidate.write_release_candidate_summary( + step_results=step_results, + artifact_path=summary_path, + created_at=created_at, + ) + return tmp_path / "archive" / "index.json" + + +def _write_no_go_archive(tmp_path: Path, created_at: datetime) -> Path: + step_results = release_candidate.run_release_candidate( + induce_step=release_candidate.STEP_PHASE4_VALIDATION_MATRIX, + execute_command=_pass_except_induced_executor, + ) + summary_path = tmp_path / "phase4_rc_summary.json" + release_candidate.write_release_candidate_summary( + step_results=step_results, + artifact_path=summary_path, + created_at=created_at, + command_mode=f"induced_failure:{release_candidate.STEP_PHASE4_VALIDATION_MATRIX}", + ) + return tmp_path / "archive" / "index.json" + + +def test_generate_manifest_selects_latest_go_entry_and_verify_passes(tmp_path: Path) -> None: + index_path = _write_go_archive(tmp_path, datetime(2026, 3, 28, 10, 0, 0, tzinfo=UTC)) + _write_no_go_archive(tmp_path, datetime(2026, 3, 28, 10, 15, 0, tzinfo=UTC)) + manifest_path = tmp_path / "phase4_mvp_exit_manifest.json" + + manifest = generate_manifest.generate_manifest(index_path=index_path, manifest_path=manifest_path) + + assert manifest["artifact_version"] == generate_manifest.MANIFEST_ARTIFACT_VERSION + assert manifest["artifact_path"] == str(manifest_path) + assert manifest["phase"] == "phase4" + assert manifest["release_gate"] == "mvp" + assert manifest["compatibility_validation_commands"] == list( + generate_manifest.REQUIRED_COMPATIBILITY_COMMANDS + ) + assert manifest["decision"] == { + "final_decision": "GO", + "summary_exit_code": 0, + "failing_steps": [], + } + + source_references = manifest["source_references"] + assert source_references["archive_index_path"] == str(index_path) + assert source_references["archive_artifact_path"] == str( + tmp_path / "archive" / "20260328T100000Z_phase4_rc_summary.json" + ) + assert source_references["archive_entry_created_at"] == "2026-03-28T10:00:00Z" + assert source_references["archive_entry_command_mode"] == "default" + + ordered_steps = manifest["ordered_steps"] + assert ordered_steps == list(release_candidate.STEP_IDS) + assert manifest["step_status_by_id"] == {step_id: "PASS" for step_id in ordered_steps} + assert verify_manifest.verify_manifest(manifest_path=manifest_path) == [] + + +def test_verify_manifest_fails_when_referenced_archive_artifact_missing(tmp_path: Path) -> None: + index_path = _write_go_archive(tmp_path, datetime(2026, 3, 28, 10, 30, 0, tzinfo=UTC)) + manifest_path = tmp_path / "phase4_mvp_exit_manifest.json" + manifest = generate_manifest.generate_manifest(index_path=index_path, manifest_path=manifest_path) + + source_references = manifest["source_references"] + archive_artifact_path = Path(source_references["archive_artifact_path"]) + archive_artifact_path.unlink() + + errors = verify_manifest.verify_manifest(manifest_path=manifest_path) + assert any("archive_artifact_path missing file" in error for error in errors) + + +def test_verify_manifest_fails_when_archive_entry_index_is_tampered(tmp_path: Path) -> None: + _write_go_archive(tmp_path, datetime(2026, 3, 28, 10, 0, 0, tzinfo=UTC)) + index_path = _write_go_archive(tmp_path, datetime(2026, 3, 28, 10, 15, 0, tzinfo=UTC)) + manifest_path = tmp_path / "phase4_mvp_exit_manifest.json" + generate_manifest.generate_manifest(index_path=index_path, manifest_path=manifest_path) + + manifest_payload = json.loads(manifest_path.read_text(encoding="utf-8")) + source_references = manifest_payload["source_references"] + source_references["archive_entry_index"] = 0 + manifest_path.write_text(json.dumps(manifest_payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + errors = verify_manifest.verify_manifest(manifest_path=manifest_path) + assert any("archive_entry_index" in error for error in errors) diff --git a/tests/integration/test_phase4_mvp_qualification.py b/tests/integration/test_phase4_mvp_qualification.py new file mode 100644 index 0000000..2ce6d7c --- /dev/null +++ b/tests/integration/test_phase4_mvp_qualification.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +from datetime import UTC, datetime +import json +from pathlib import Path + +import scripts.run_phase4_mvp_qualification as qualification +import scripts.verify_phase4_mvp_signoff_record as verify_signoff + + +def _touch_artifact(path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + if path.suffix == ".json": + path.write_text("{}\n", encoding="utf-8") + else: + path.write_text("ok\n", encoding="utf-8") + + +def _build_test_contract(tmp_path: Path) -> tuple[list[qualification.QualificationStep], dict[str, str], Path]: + rc_summary_path = tmp_path / "phase4_rc_summary.json" + rc_archive_index_path = tmp_path / "archive" / "index.json" + mvp_exit_manifest_path = tmp_path / "phase4_mvp_exit_manifest.json" + signoff_path = tmp_path / "phase4_mvp_signoff_record.json" + + steps = qualification.build_qualification_steps( + python_executable="/usr/bin/python3", + rc_summary_path=rc_summary_path, + rc_archive_index_path=rc_archive_index_path, + mvp_exit_manifest_path=mvp_exit_manifest_path, + ) + references = qualification.default_required_references( + rc_summary_path=rc_summary_path, + rc_archive_index_path=rc_archive_index_path, + mvp_exit_manifest_path=mvp_exit_manifest_path, + ) + return steps, references, signoff_path + + +def test_qualification_go_contract_and_signoff_verifier_pass(tmp_path: Path) -> None: + steps, references, signoff_path = _build_test_contract(tmp_path) + for step in steps: + for artifact_path_value in step.required_artifacts: + _touch_artifact(Path(artifact_path_value)) + + def _always_pass_executor(command: tuple[str, ...], cwd: Path) -> int: + del command, cwd + return 0 + + step_results = qualification.run_qualification( + qualification_steps=steps, + execute_command=_always_pass_executor, + ) + record = qualification.write_signoff_record( + step_results=step_results, + artifact_path=signoff_path, + required_references=references, + created_at=datetime(2026, 3, 28, 12, 0, 0, tzinfo=UTC), + ) + + assert [result.status for result in step_results] == ["PASS", "PASS", "PASS", "PASS"] + assert record["final_decision"] == "GO" + assert record["summary_exit_code"] == 0 + assert record["blockers"] == [] + assert record["required_references"] == references + + assert verify_signoff.verify_signoff_record( + signoff_path=signoff_path, + expected_required_references=references, + ) == [] + + +def test_qualification_no_go_marks_downstream_not_run_and_sets_blockers(tmp_path: Path) -> None: + steps, references, signoff_path = _build_test_contract(tmp_path) + _touch_artifact(tmp_path / "phase4_rc_summary.json") + _touch_artifact(tmp_path / "archive" / "index.json") + + def _fail_archive_verify_executor(command: tuple[str, ...], cwd: Path) -> int: + del cwd + if command[-1] == "scripts/verify_phase4_rc_archive.py": + return 1 + return 0 + + step_results = qualification.run_qualification( + qualification_steps=steps, + execute_command=_fail_archive_verify_executor, + ) + record = qualification.write_signoff_record( + step_results=step_results, + artifact_path=signoff_path, + required_references=references, + created_at=datetime(2026, 3, 28, 12, 15, 0, tzinfo=UTC), + ) + + assert [result.status for result in step_results] == ["PASS", "FAIL", "NOT_RUN", "NOT_RUN"] + assert record["final_decision"] == "NO_GO" + assert record["summary_exit_code"] == 1 + assert record["failing_steps"] == [qualification.STEP_RELEASE_CANDIDATE_ARCHIVE_VERIFY] + assert record["not_run_steps"] == [ + qualification.STEP_MVP_EXIT_MANIFEST_GENERATE, + qualification.STEP_MVP_EXIT_MANIFEST_VERIFY, + ] + + blocker_steps = {blocker["step"] for blocker in record["blockers"]} + assert blocker_steps == { + qualification.STEP_RELEASE_CANDIDATE_ARCHIVE_VERIFY, + qualification.STEP_MVP_EXIT_MANIFEST_GENERATE, + qualification.STEP_MVP_EXIT_MANIFEST_VERIFY, + } + + assert verify_signoff.verify_signoff_record( + signoff_path=signoff_path, + expected_required_references=references, + ) == [] + + +def test_signoff_verifier_rejects_go_with_blockers(tmp_path: Path) -> None: + steps, references, signoff_path = _build_test_contract(tmp_path) + for step in steps: + for artifact_path_value in step.required_artifacts: + _touch_artifact(Path(artifact_path_value)) + + def _always_pass_executor(command: tuple[str, ...], cwd: Path) -> int: + del command, cwd + return 0 + + step_results = qualification.run_qualification( + qualification_steps=steps, + execute_command=_always_pass_executor, + ) + qualification.write_signoff_record( + step_results=step_results, + artifact_path=signoff_path, + required_references=references, + created_at=datetime(2026, 3, 28, 12, 30, 0, tzinfo=UTC), + ) + + payload = json.loads(signoff_path.read_text(encoding="utf-8")) + payload["blockers"] = [ + { + "step": qualification.STEP_RELEASE_CANDIDATE_REHEARSAL, + "reason": "command_failed", + "detail": "tampered", + } + ] + signoff_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + errors = verify_signoff.verify_signoff_record( + signoff_path=signoff_path, + expected_required_references=references, + ) + assert any("blockers must be empty when final_decision is GO" in error for error in errors) diff --git a/tests/integration/test_phase4_rc_archive.py b/tests/integration/test_phase4_rc_archive.py new file mode 100644 index 0000000..cb9c45a --- /dev/null +++ b/tests/integration/test_phase4_rc_archive.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from datetime import UTC, datetime +import json +from pathlib import Path + +import scripts.run_phase4_release_candidate as release_candidate +import scripts.verify_phase4_rc_archive as verify_archive + + +def _always_pass_executor(command: tuple[str, ...], cwd: Path) -> int: + del command, cwd + return 0 + + +def _write_go_archive(tmp_path: Path) -> Path: + results = release_candidate.run_release_candidate(execute_command=_always_pass_executor) + summary_path = tmp_path / "phase4_rc_summary.json" + release_candidate.write_release_candidate_summary( + step_results=results, + artifact_path=summary_path, + created_at=datetime(2026, 3, 28, 10, 0, 0, tzinfo=UTC), + ) + return tmp_path / "archive" / "index.json" + + +def test_verify_phase4_rc_archive_passes_for_valid_archive(tmp_path: Path) -> None: + index_path = _write_go_archive(tmp_path) + + assert verify_archive.verify_archive_index(index_path=index_path) == [] + + +def test_verify_phase4_rc_archive_detects_summary_mismatch(tmp_path: Path) -> None: + index_path = _write_go_archive(tmp_path) + index_payload = json.loads(index_path.read_text(encoding="utf-8")) + index_payload["entries"][0]["summary_exit_code"] = 1 + index_path.write_text(json.dumps(index_payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + errors = verify_archive.verify_archive_index(index_path=index_path) + assert any("summary_exit_code mismatch with archive summary" in error for error in errors) + + +def test_verify_phase4_rc_archive_detects_missing_archive_artifact(tmp_path: Path) -> None: + index_path = _write_go_archive(tmp_path) + index_payload = json.loads(index_path.read_text(encoding="utf-8")) + archive_path = Path(index_payload["entries"][0]["archive_artifact_path"]) + archive_path.unlink() + + errors = verify_archive.verify_archive_index(index_path=index_path) + assert any("archive_artifact_path missing file" in error for error in errors) + + +def test_verify_phase4_rc_archive_detects_stale_lock_file(tmp_path: Path) -> None: + index_path = _write_go_archive(tmp_path) + lock_path = tmp_path / "archive" / release_candidate.ARCHIVE_INDEX_LOCK_NAME + lock_path.write_text("stale-lock\n", encoding="utf-8") + + errors = verify_archive.verify_archive_index(index_path=index_path) + assert any("lock file should not persist" in error for error in errors) diff --git a/tests/integration/test_phase4_readiness_gates.py b/tests/integration/test_phase4_readiness_gates.py new file mode 100644 index 0000000..b75b15a --- /dev/null +++ b/tests/integration/test_phase4_readiness_gates.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from pathlib import Path + +import scripts.run_phase4_readiness_gates as readiness_gates + + +def _always_pass_executor(command: tuple[str, ...], cwd: Path) -> int: + del cwd + if "-c" in command and "Induced phase4 readiness failure" in command[-1]: + return readiness_gates.INDUCED_FAILURE_EXIT_CODE + return 0 + + +def test_readiness_gate_sequence_contains_magnesium_and_phase3_compat() -> None: + gate_steps = readiness_gates.build_readiness_gate_steps(python_executable="/usr/bin/python3") + + assert [gate.gate for gate in gate_steps] == [ + readiness_gates.GATE_PHASE4_ACCEPTANCE, + readiness_gates.GATE_MAGNESIUM_SHIP_GATE, + readiness_gates.GATE_PHASE3_COMPAT, + ] + + assert gate_steps[0].command == ("/usr/bin/python3", "scripts/run_phase4_acceptance.py") + assert gate_steps[1].command == ( + "/usr/bin/python3", + "-m", + "pytest", + "-q", + readiness_gates.MAGNESIUM_NODE_ID, + ) + assert gate_steps[2].command == ("/usr/bin/python3", "scripts/run_phase3_readiness_gates.py") + + +def test_induced_gate_failure_reports_explicit_failing_gate(capsys) -> None: + results = readiness_gates.run_readiness_gates( + induce_gate=readiness_gates.GATE_MAGNESIUM_SHIP_GATE, + execute_command=_always_pass_executor, + ) + readiness_gates._print_gate_results(results) + output = capsys.readouterr().out + + assert [result.gate for result in results] == [ + readiness_gates.GATE_PHASE4_ACCEPTANCE, + readiness_gates.GATE_MAGNESIUM_SHIP_GATE, + readiness_gates.GATE_PHASE3_COMPAT, + ] + assert results[0].status == "PASS" + assert results[1].status == "FAIL" + assert results[1].exit_code == readiness_gates.INDUCED_FAILURE_EXIT_CODE + assert results[1].induced_failure is True + assert results[2].status == "PASS" + + assert "Phase 4 readiness gate results:" in output + assert " - canonical_magnesium_ship_gate: FAIL" in output + assert "induced_failure: true" in output + assert "Failing gates: canonical_magnesium_ship_gate" in output + assert readiness_gates.exit_code_for_gate_results(results) == 1 diff --git a/tests/integration/test_phase4_release_candidate.py b/tests/integration/test_phase4_release_candidate.py new file mode 100644 index 0000000..963a0a5 --- /dev/null +++ b/tests/integration/test_phase4_release_candidate.py @@ -0,0 +1,233 @@ +from __future__ import annotations + +from datetime import UTC, datetime +import json +from pathlib import Path +import threading + +import pytest + +import scripts.run_phase4_release_candidate as release_candidate + + +def _always_pass_executor(command: tuple[str, ...], cwd: Path) -> int: + del command, cwd + return 0 + + +def _pass_except_induced_executor(command: tuple[str, ...], cwd: Path) -> int: + del cwd + if "-c" in command and "Induced phase4 release-candidate failure" in command[-1]: + return release_candidate.INDUCED_FAILURE_EXIT_CODE + return 0 + + +def test_release_candidate_step_sequence_and_go_summary_contract(tmp_path: Path) -> None: + steps = release_candidate.build_release_candidate_steps(python_executable="/usr/bin/python3") + + assert [step.step for step in steps] == [ + release_candidate.STEP_CONTROL_DOC_TRUTH, + release_candidate.STEP_PHASE4_ACCEPTANCE, + release_candidate.STEP_PHASE4_READINESS, + release_candidate.STEP_PHASE4_VALIDATION_MATRIX, + release_candidate.STEP_PHASE3_COMPAT_VALIDATION, + release_candidate.STEP_PHASE2_COMPAT_VALIDATION, + release_candidate.STEP_MVP_COMPAT_VALIDATION, + ] + + assert steps[0].command == ("/usr/bin/python3", "scripts/check_control_doc_truth.py") + assert steps[1].command == ("/usr/bin/python3", "scripts/run_phase4_acceptance.py") + assert steps[2].command == ("/usr/bin/python3", "scripts/run_phase4_readiness_gates.py") + assert steps[3].command == ("/usr/bin/python3", "scripts/run_phase4_validation_matrix.py") + assert steps[4].command == ("/usr/bin/python3", "scripts/run_phase3_validation_matrix.py") + assert steps[5].command == ("/usr/bin/python3", "scripts/run_phase2_validation_matrix.py") + assert steps[6].command == ("/usr/bin/python3", "scripts/run_mvp_validation_matrix.py") + + results = release_candidate.run_release_candidate(execute_command=_always_pass_executor) + summary_path = tmp_path / "phase4_rc_summary.json" + summary = release_candidate.write_release_candidate_summary( + step_results=results, + artifact_path=summary_path, + created_at=datetime(2026, 3, 28, 8, 0, 0, tzinfo=UTC), + ) + + assert all(result.status == "PASS" for result in results) + assert summary["artifact_version"] == "phase4_rc_summary.v1" + assert summary["final_decision"] == "GO" + assert summary["summary_exit_code"] == 0 + assert summary["ordered_steps"] == list(release_candidate.STEP_IDS) + assert summary["executed_steps"] == len(release_candidate.STEP_IDS) + assert summary["total_steps"] == len(release_candidate.STEP_IDS) + assert summary["failing_steps"] == [] + + payload = json.loads(summary_path.read_text(encoding="utf-8")) + assert payload["artifact_version"] == "phase4_rc_summary.v1" + assert payload["steps"][0]["status"] == "PASS" + assert payload["steps"][3]["step"] == release_candidate.STEP_PHASE4_VALIDATION_MATRIX + + archive_artifact_path = tmp_path / "archive" / "20260328T080000Z_phase4_rc_summary.json" + archive_index_path = tmp_path / "archive" / "index.json" + assert summary["archive_artifact_path"] == str(archive_artifact_path) + assert summary["archive_index_path"] == str(archive_index_path) + assert archive_artifact_path.exists() + assert archive_index_path.exists() + + archive_payload = json.loads(archive_artifact_path.read_text(encoding="utf-8")) + assert archive_payload["artifact_path"] == str(archive_artifact_path) + assert archive_payload["final_decision"] == "GO" + + index_payload = json.loads(archive_index_path.read_text(encoding="utf-8")) + assert index_payload["artifact_version"] == release_candidate.ARCHIVE_INDEX_VERSION + assert index_payload["latest_summary_path"] == str(summary_path) + assert index_payload["archive_dir"] == str(tmp_path / "archive") + assert index_payload["entries"] == [ + { + "created_at": "2026-03-28T08:00:00Z", + "archive_artifact_path": str(archive_artifact_path), + "final_decision": "GO", + "summary_exit_code": 0, + "failing_steps": [], + "command_mode": "default", + } + ] + + +def test_induced_failure_reports_no_go_and_preserves_partial_evidence(tmp_path: Path) -> None: + results = release_candidate.run_release_candidate( + induce_step=release_candidate.STEP_PHASE4_VALIDATION_MATRIX, + execute_command=_pass_except_induced_executor, + ) + + summary_path = tmp_path / "phase4_rc_summary.json" + summary = release_candidate.write_release_candidate_summary( + step_results=results, + artifact_path=summary_path, + command_mode=f"induced_failure:{release_candidate.STEP_PHASE4_VALIDATION_MATRIX}", + created_at=datetime(2026, 3, 28, 8, 15, 0, tzinfo=UTC), + ) + + assert [result.step for result in results] == list(release_candidate.STEP_IDS) + assert [result.status for result in results[:3]] == ["PASS", "PASS", "PASS"] + + induced_failure = results[3] + assert induced_failure.step == release_candidate.STEP_PHASE4_VALIDATION_MATRIX + assert induced_failure.status == "FAIL" + assert induced_failure.exit_code == release_candidate.INDUCED_FAILURE_EXIT_CODE + assert induced_failure.induced_failure is True + + assert [result.status for result in results[4:]] == ["NOT_RUN", "NOT_RUN", "NOT_RUN"] + assert all(result.exit_code is None for result in results[4:]) + + assert summary["final_decision"] == "NO_GO" + assert summary["summary_exit_code"] == 1 + assert summary["executed_steps"] == 4 + assert summary["failing_steps"] == [release_candidate.STEP_PHASE4_VALIDATION_MATRIX] + + payload = json.loads(summary_path.read_text(encoding="utf-8")) + assert payload["final_decision"] == "NO_GO" + assert payload["steps"][3]["status"] == "FAIL" + assert payload["steps"][4]["status"] == "NOT_RUN" + + archive_index_path = tmp_path / "archive" / "index.json" + index_payload = json.loads(archive_index_path.read_text(encoding="utf-8")) + assert index_payload["entries"] == [ + { + "created_at": "2026-03-28T08:15:00Z", + "archive_artifact_path": str( + tmp_path / "archive" / "20260328T081500Z_phase4_rc_summary.json" + ), + "final_decision": "NO_GO", + "summary_exit_code": 1, + "failing_steps": [release_candidate.STEP_PHASE4_VALIDATION_MATRIX], + "command_mode": f"induced_failure:{release_candidate.STEP_PHASE4_VALIDATION_MATRIX}", + } + ] + + +def test_archive_index_is_append_only_and_avoids_same_second_overwrite(tmp_path: Path) -> None: + first_results = release_candidate.run_release_candidate(execute_command=_always_pass_executor) + second_results = release_candidate.run_release_candidate(execute_command=_always_pass_executor) + summary_path = tmp_path / "phase4_rc_summary.json" + + release_candidate.write_release_candidate_summary( + step_results=first_results, + artifact_path=summary_path, + created_at=datetime(2026, 3, 28, 9, 0, 0, tzinfo=UTC), + ) + release_candidate.write_release_candidate_summary( + step_results=second_results, + artifact_path=summary_path, + created_at=datetime(2026, 3, 28, 9, 0, 0, tzinfo=UTC), + ) + + archive_dir = tmp_path / "archive" + first_archive = archive_dir / "20260328T090000Z_phase4_rc_summary.json" + second_archive = archive_dir / "20260328T090000Z_001_phase4_rc_summary.json" + assert first_archive.exists() + assert second_archive.exists() + + index_payload = json.loads((archive_dir / "index.json").read_text(encoding="utf-8")) + assert len(index_payload["entries"]) == 2 + assert index_payload["entries"][0]["archive_artifact_path"] == str(first_archive) + assert index_payload["entries"][1]["archive_artifact_path"] == str(second_archive) + + +def test_archive_index_concurrent_writes_retain_all_entries(tmp_path: Path) -> None: + summary_path = tmp_path / "phase4_rc_summary.json" + created_at = datetime(2026, 3, 28, 9, 30, 0, tzinfo=UTC) + run_count = 4 + barrier = threading.Barrier(run_count) + failures: list[str] = [] + + def _writer() -> None: + try: + step_results = release_candidate.run_release_candidate(execute_command=_always_pass_executor) + barrier.wait() + release_candidate.write_release_candidate_summary( + step_results=step_results, + artifact_path=summary_path, + created_at=created_at, + ) + except Exception as exc: # pragma: no cover - defensive guard for threaded assertions + failures.append(str(exc)) + + threads = [threading.Thread(target=_writer) for _ in range(run_count)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + assert failures == [] + archive_dir = tmp_path / "archive" + index_payload = json.loads((archive_dir / "index.json").read_text(encoding="utf-8")) + archive_paths = [entry["archive_artifact_path"] for entry in index_payload["entries"]] + assert len(archive_paths) == run_count + assert len(set(archive_paths)) == run_count + assert all(Path(path).exists() for path in archive_paths) + assert (archive_dir / "20260328T093000Z_phase4_rc_summary.json").exists() + assert (archive_dir / "20260328T093000Z_001_phase4_rc_summary.json").exists() + assert (archive_dir / "20260328T093000Z_002_phase4_rc_summary.json").exists() + assert (archive_dir / "20260328T093000Z_003_phase4_rc_summary.json").exists() + + +def test_archive_index_lock_timeout_is_explicit_and_bounded(tmp_path: Path) -> None: + step_results = release_candidate.run_release_candidate(execute_command=_always_pass_executor) + summary_path = tmp_path / "phase4_rc_summary.json" + lock_path = tmp_path / "archive" / release_candidate.ARCHIVE_INDEX_LOCK_NAME + lock_path.parent.mkdir(parents=True, exist_ok=True) + lock_path.write_text("held-by-test\n", encoding="utf-8") + + with pytest.raises( + release_candidate.ArchiveIndexLockTimeoutError, + match=r"Timed out acquiring archive index lock at .*index\.lock", + ): + release_candidate.write_release_candidate_summary( + step_results=step_results, + artifact_path=summary_path, + created_at=datetime(2026, 3, 28, 9, 45, 0, tzinfo=UTC), + lock_timeout_seconds=0.02, + lock_retry_interval_seconds=0.005, + ) + + assert summary_path.exists() + assert not (tmp_path / "archive" / "index.json").exists() diff --git a/tests/integration/test_phase4_validation_matrix.py b/tests/integration/test_phase4_validation_matrix.py new file mode 100644 index 0000000..873bb65 --- /dev/null +++ b/tests/integration/test_phase4_validation_matrix.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from pathlib import Path + +import scripts.run_phase4_validation_matrix as validation_matrix + + +def _always_pass_executor(command: tuple[str, ...], cwd: Path) -> int: + del cwd + if "-c" in command and "Induced phase4 validation failure" in command[-1]: + return validation_matrix.INDUCED_FAILURE_EXIT_CODE + return 0 + + +def test_matrix_sequence_contains_canonical_magnesium_and_compatibility_steps() -> None: + steps = validation_matrix.build_validation_matrix_steps(python_executable="/usr/bin/python3") + + assert [step.step for step in steps] == [ + validation_matrix.STEP_CONTROL_DOC_TRUTH, + validation_matrix.STEP_PHASE4_ACCEPTANCE, + validation_matrix.STEP_PHASE4_READINESS, + validation_matrix.STEP_PHASE4_MAGNESIUM, + validation_matrix.STEP_PHASE4_SCENARIOS, + validation_matrix.STEP_PHASE4_WEB, + validation_matrix.STEP_PHASE3_COMPAT, + validation_matrix.STEP_PHASE2_COMPAT, + validation_matrix.STEP_MVP_COMPAT, + ] + + assert steps[0].command == ("/usr/bin/python3", "scripts/check_control_doc_truth.py") + assert steps[1].command == ("/usr/bin/python3", "scripts/run_phase4_acceptance.py") + assert steps[2].command == ("/usr/bin/python3", "scripts/run_phase4_readiness_gates.py") + assert steps[3].command == ( + "/usr/bin/python3", + "-m", + "pytest", + "-q", + validation_matrix.PHASE4_MAGNESIUM_NODE_ID, + ) + assert steps[4].command == ( + "/usr/bin/python3", + "-m", + "pytest", + "-q", + *validation_matrix.PHASE4_SCENARIO_NODE_IDS, + ) + assert steps[5].command == validation_matrix.PHASE4_WEB_COMMAND + assert steps[6].command == ("/usr/bin/python3", "scripts/run_phase3_validation_matrix.py") + assert steps[7].command == ("/usr/bin/python3", "scripts/run_phase2_validation_matrix.py") + assert steps[8].command == ("/usr/bin/python3", "scripts/run_mvp_validation_matrix.py") + + +def test_induced_step_failure_reports_explicit_failing_step(capsys) -> None: + results = validation_matrix.run_validation_matrix( + induce_step=validation_matrix.STEP_PHASE4_MAGNESIUM, + execute_command=_always_pass_executor, + ) + validation_matrix._print_step_results(results) + output = capsys.readouterr().out + + assert results[3].step == validation_matrix.STEP_PHASE4_MAGNESIUM + assert results[3].status == "FAIL" + assert results[3].exit_code == validation_matrix.INDUCED_FAILURE_EXIT_CODE + assert results[3].induced_failure is True + assert all(result.status == "PASS" for result in results[:3]) + assert all(result.status == "PASS" for result in results[4:]) + + assert "Phase 4 validation matrix results:" in output + assert " - phase4_magnesium_ship_gate: FAIL" in output + assert "induced_failure: true" in output + assert "Failing steps: phase4_magnesium_ship_gate" in output + assert validation_matrix.exit_code_for_step_results(results) == 1 diff --git a/tests/integration/test_phase9_eval.py b/tests/integration/test_phase9_eval.py new file mode 100644 index 0000000..621201c --- /dev/null +++ b/tests/integration/test_phase9_eval.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import json +from pathlib import Path +import subprocess +import sys +from uuid import UUID, uuid4 + +from alicebot_api.db import user_connection +from alicebot_api.store import ContinuityStore + + +REPO_ROOT = Path(__file__).resolve().parents[2] +EVAL_SCRIPT = REPO_ROOT / "scripts" / "run_phase9_eval.py" + + +def seed_user(database_url: str, *, email: str) -> UUID: + user_id = uuid4() + with user_connection(database_url, user_id) as conn: + ContinuityStore(conn).create_user(user_id, email, email.split("@", 1)[0].title()) + return user_id + + +def test_phase9_eval_script_generates_report_with_expected_metrics(migrated_database_urls, tmp_path: Path) -> None: + user_id = seed_user(migrated_database_urls["app"], email="phase9-eval@example.com") + report_path = tmp_path / "phase9_eval_report.json" + + completed = subprocess.run( + [ + sys.executable, + str(EVAL_SCRIPT), + "--database-url", + migrated_database_urls["app"], + "--user-id", + str(user_id), + "--report-path", + str(report_path), + ], + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=True, + ) + + stdout_payload = json.loads(completed.stdout) + assert stdout_payload["status"] == "pass" + + report = json.loads(report_path.read_text(encoding="utf-8")) + summary = report["summary"] + + assert report["schema_version"] == "phase9_eval_v1" + assert summary["status"] == "pass" + assert summary["importer_count"] == 3 + assert summary["importer_success_rate"] == 1.0 + assert summary["duplicate_posture_rate"] == 1.0 + assert summary["recall_precision_at_1"] == 1.0 + assert summary["resumption_usefulness_rate"] == 1.0 + assert summary["correction_effectiveness_rate"] == 1.0 + assert len(report["importer_runs"]) == 3 diff --git a/tests/integration/test_policy_api.py b/tests/integration/test_policy_api.py new file mode 100644 index 0000000..c1c34bb --- /dev/null +++ b/tests/integration/test_policy_api.py @@ -0,0 +1,508 @@ +from __future__ import annotations + +import json +from typing import Any +from urllib.parse import urlencode +from uuid import UUID, uuid4 + +import anyio + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.db import user_connection +from alicebot_api.store import ContinuityStore + + +def invoke_request( + method: str, + path: str, + *, + query_params: dict[str, str] | None = None, + payload: dict[str, Any] | None = None, +) -> tuple[int, dict[str, Any]]: + messages: list[dict[str, object]] = [] + encoded_body = b"" if payload is None else json.dumps(payload).encode() + request_received = False + + async def receive() -> dict[str, object]: + nonlocal request_received + if request_received: + return {"type": "http.disconnect"} + + request_received = True + return {"type": "http.request", "body": encoded_body, "more_body": False} + + async def send(message: dict[str, object]) -> None: + messages.append(message) + + query_string = urlencode(query_params or {}).encode() + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": query_string, + "headers": [(b"content-type", b"application/json")], + "client": ("testclient", 50000), + "server": ("testserver", 80), + "root_path": "", + } + + anyio.run(main_module.app, scope, receive, send) + + start_message = next(message for message in messages if message["type"] == "http.response.start") + body = b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + return start_message["status"], json.loads(body) + + +def seed_user( + database_url: str, + *, + email: str, + agent_profile_id: str = "assistant_default", +) -> dict[str, UUID]: + user_id = uuid4() + + with user_connection(database_url, user_id) as conn: + store = ContinuityStore(conn) + store.create_user(user_id, email, email.split("@", 1)[0].title()) + thread = store.create_thread("Policy thread", agent_profile_id=agent_profile_id) + + return { + "user_id": user_id, + "thread_id": thread["id"], + } + + +def test_consent_endpoints_upsert_and_list_deterministically(migrated_database_urls, monkeypatch) -> None: + seeded = seed_user(migrated_database_urls["app"], email="owner@example.com") + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + + first_status, first_payload = invoke_request( + "POST", + "/v0/consents", + payload={ + "user_id": str(seeded["user_id"]), + "consent_key": "email_marketing", + "status": "granted", + "metadata": {"source": "settings"}, + }, + ) + second_status, second_payload = invoke_request( + "POST", + "/v0/consents", + payload={ + "user_id": str(seeded["user_id"]), + "consent_key": "analytics_tracking", + "status": "revoked", + "metadata": {"source": "banner"}, + }, + ) + third_status, third_payload = invoke_request( + "POST", + "/v0/consents", + payload={ + "user_id": str(seeded["user_id"]), + "consent_key": "email_marketing", + "status": "revoked", + "metadata": {"source": "preferences"}, + }, + ) + list_status, list_payload = invoke_request( + "GET", + "/v0/consents", + query_params={"user_id": str(seeded["user_id"])}, + ) + + assert first_status == 201 + assert second_status == 201 + assert third_status == 200 + assert first_payload["write_mode"] == "created" + assert second_payload["write_mode"] == "created" + assert third_payload["write_mode"] == "updated" + assert third_payload["consent"]["id"] == first_payload["consent"]["id"] + assert list_status == 200 + assert [item["consent_key"] for item in list_payload["items"]] == [ + "analytics_tracking", + "email_marketing", + ] + assert list_payload["summary"] == { + "total_count": 2, + "order": ["consent_key_asc", "created_at_asc", "id_asc"], + } + + with user_connection(migrated_database_urls["app"], seeded["user_id"]) as conn: + stored_consents = ContinuityStore(conn).list_consents() + + assert [consent["consent_key"] for consent in stored_consents] == [ + "analytics_tracking", + "email_marketing", + ] + assert stored_consents[1]["status"] == "revoked" + assert stored_consents[1]["metadata"] == {"source": "preferences"} + + +def test_policy_endpoints_create_list_and_get_in_priority_order(migrated_database_urls, monkeypatch) -> None: + seeded = seed_user(migrated_database_urls["app"], email="owner@example.com") + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + + low_priority_status, low_priority_payload = invoke_request( + "POST", + "/v0/policies", + payload={ + "user_id": str(seeded["user_id"]), + "name": "Require approval for export", + "action": "memory.export", + "scope": "profile", + "effect": "require_approval", + "priority": 20, + "active": True, + "conditions": {"channel": "email"}, + "required_consents": ["email_marketing", "email_marketing"], + "agent_profile_id": "coach_default", + }, + ) + high_priority_status, high_priority_payload = invoke_request( + "POST", + "/v0/policies", + payload={ + "user_id": str(seeded["user_id"]), + "name": "Allow profile read", + "action": "memory.read", + "scope": "profile", + "effect": "allow", + "priority": 10, + "active": True, + "conditions": {}, + "required_consents": [], + }, + ) + list_status, list_payload = invoke_request( + "GET", + "/v0/policies", + query_params={"user_id": str(seeded["user_id"])}, + ) + detail_status, detail_payload = invoke_request( + "GET", + f"/v0/policies/{low_priority_payload['policy']['id']}", + query_params={"user_id": str(seeded['user_id'])}, + ) + + assert low_priority_status == 201 + assert high_priority_status == 201 + assert low_priority_payload["policy"]["agent_profile_id"] == "coach_default" + assert high_priority_payload["policy"]["agent_profile_id"] is None + assert low_priority_payload["policy"]["required_consents"] == ["email_marketing"] + assert list_status == 200 + assert [item["id"] for item in list_payload["items"]] == [ + high_priority_payload["policy"]["id"], + low_priority_payload["policy"]["id"], + ] + assert list_payload["summary"] == { + "total_count": 2, + "order": ["priority_asc", "created_at_asc", "id_asc"], + } + assert detail_status == 200 + assert detail_payload == {"policy": low_priority_payload["policy"]} + + +def test_policy_evaluation_allow_records_trace_events(migrated_database_urls, monkeypatch) -> None: + seeded = seed_user(migrated_database_urls["app"], email="owner@example.com") + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + + with user_connection(migrated_database_urls["app"], seeded["user_id"]) as conn: + store = ContinuityStore(conn) + store.create_consent( + consent_key="email_marketing", + status="granted", + metadata={"source": "settings"}, + ) + created_policy = store.create_policy( + name="Allow export", + action="memory.export", + scope="profile", + effect="allow", + priority=10, + active=True, + conditions={"channel": "email"}, + required_consents=["email_marketing"], + ) + + status_code, payload = invoke_request( + "POST", + "/v0/policies/evaluate", + payload={ + "user_id": str(seeded["user_id"]), + "thread_id": str(seeded["thread_id"]), + "action": "memory.export", + "scope": "profile", + "attributes": {"channel": "email"}, + }, + ) + + assert status_code == 200 + assert payload["decision"] == "allow" + assert payload["matched_policy"]["id"] == str(created_policy["id"]) + assert payload["evaluation"] == { + "action": "memory.export", + "scope": "profile", + "evaluated_policy_count": 1, + "matched_policy_id": str(created_policy["id"]), + "order": ["priority_asc", "created_at_asc", "id_asc"], + } + assert [reason["code"] for reason in payload["reasons"]] == [ + "matched_policy", + "policy_effect_allow", + ] + assert payload["trace"]["trace_event_count"] == 3 + + with user_connection(migrated_database_urls["app"], seeded["user_id"]) as conn: + store = ContinuityStore(conn) + trace = store.get_trace(UUID(payload["trace"]["trace_id"])) + trace_events = store.list_trace_events(UUID(payload["trace"]["trace_id"])) + + assert trace["kind"] == "policy.evaluate" + assert trace["compiler_version"] == "policy_evaluation_v0" + assert trace["limits"] == { + "order": ["priority_asc", "created_at_asc", "id_asc"], + "active_policy_count": 1, + "consent_count": 1, + } + assert [event["kind"] for event in trace_events] == [ + "policy.evaluate.request", + "policy.evaluate.order", + "policy.evaluate.decision", + ] + assert trace_events[2]["payload"]["decision"] == "allow" + assert trace_events[2]["payload"]["matched_policy_id"] == str(created_policy["id"]) + + +def test_policy_evaluation_scopes_to_global_and_thread_profile_policies( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_user( + migrated_database_urls["app"], + email="owner@example.com", + agent_profile_id="coach_default", + ) + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + + with user_connection(migrated_database_urls["app"], seeded["user_id"]) as conn: + store = ContinuityStore(conn) + mismatched = store.create_policy( + agent_profile_id="assistant_default", + name="Mismatched deny", + action="memory.export", + scope="profile", + effect="deny", + priority=1, + active=True, + conditions={}, + required_consents=[], + ) + global_policy = store.create_policy( + agent_profile_id=None, + name="Global approval", + action="memory.export", + scope="profile", + effect="require_approval", + priority=5, + active=True, + conditions={}, + required_consents=[], + ) + matched = store.create_policy( + agent_profile_id="coach_default", + name="Matched allow", + action="memory.export", + scope="profile", + effect="allow", + priority=10, + active=True, + conditions={}, + required_consents=[], + ) + + status_code, payload = invoke_request( + "POST", + "/v0/policies/evaluate", + payload={ + "user_id": str(seeded["user_id"]), + "thread_id": str(seeded["thread_id"]), + "action": "memory.export", + "scope": "profile", + "attributes": {}, + }, + ) + + assert status_code == 200 + assert payload["decision"] == "require_approval" + assert payload["matched_policy"]["id"] == str(global_policy["id"]) + assert payload["evaluation"]["evaluated_policy_count"] == 2 + + with user_connection(migrated_database_urls["app"], seeded["user_id"]) as conn: + store = ContinuityStore(conn) + trace_events = store.list_trace_events(UUID(payload["trace"]["trace_id"])) + + assert trace_events[1]["kind"] == "policy.evaluate.order" + assert trace_events[1]["payload"]["policy_ids"] == [ + str(global_policy["id"]), + str(matched["id"]), + ] + assert str(mismatched["id"]) not in trace_events[1]["payload"]["policy_ids"] + + +def test_policy_evaluation_denies_when_required_consent_is_missing(migrated_database_urls, monkeypatch) -> None: + seeded = seed_user(migrated_database_urls["app"], email="owner@example.com") + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + + with user_connection(migrated_database_urls["app"], seeded["user_id"]) as conn: + ContinuityStore(conn).create_policy( + name="Allow export with consent", + action="memory.export", + scope="profile", + effect="allow", + priority=10, + active=True, + conditions={}, + required_consents=["email_marketing"], + ) + + status_code, payload = invoke_request( + "POST", + "/v0/policies/evaluate", + payload={ + "user_id": str(seeded["user_id"]), + "thread_id": str(seeded["thread_id"]), + "action": "memory.export", + "scope": "profile", + "attributes": {}, + }, + ) + + assert status_code == 200 + assert payload["decision"] == "deny" + assert [reason["code"] for reason in payload["reasons"]] == [ + "matched_policy", + "consent_missing", + ] + + +def test_policy_evaluation_returns_require_approval(migrated_database_urls, monkeypatch) -> None: + seeded = seed_user(migrated_database_urls["app"], email="owner@example.com") + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + + with user_connection(migrated_database_urls["app"], seeded["user_id"]) as conn: + created_policy = ContinuityStore(conn).create_policy( + name="Escalate export", + action="memory.export", + scope="profile", + effect="require_approval", + priority=10, + active=True, + conditions={}, + required_consents=[], + ) + + status_code, payload = invoke_request( + "POST", + "/v0/policies/evaluate", + payload={ + "user_id": str(seeded["user_id"]), + "thread_id": str(seeded["thread_id"]), + "action": "memory.export", + "scope": "profile", + "attributes": {}, + }, + ) + + assert status_code == 200 + assert payload["decision"] == "require_approval" + assert payload["matched_policy"]["id"] == str(created_policy["id"]) + assert payload["reasons"][-1]["code"] == "policy_effect_require_approval" + + +def test_policy_and_consent_endpoints_enforce_per_user_isolation(migrated_database_urls, monkeypatch) -> None: + owner = seed_user(migrated_database_urls["app"], email="owner@example.com") + intruder = seed_user(migrated_database_urls["app"], email="intruder@example.com") + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + store = ContinuityStore(conn) + store.create_consent(consent_key="email_marketing", status="granted", metadata={}) + owner_policy = store.create_policy( + name="Allow export", + action="memory.export", + scope="profile", + effect="allow", + priority=10, + active=True, + conditions={}, + required_consents=["email_marketing"], + ) + + consent_status, consent_payload = invoke_request( + "GET", + "/v0/consents", + query_params={"user_id": str(intruder["user_id"])}, + ) + policy_status, policy_payload = invoke_request( + "GET", + "/v0/policies", + query_params={"user_id": str(intruder["user_id"])}, + ) + detail_status, detail_payload = invoke_request( + "GET", + f"/v0/policies/{owner_policy['id']}", + query_params={"user_id": str(intruder["user_id"])}, + ) + evaluation_status, evaluation_payload = invoke_request( + "POST", + "/v0/policies/evaluate", + payload={ + "user_id": str(intruder["user_id"]), + "thread_id": str(intruder["thread_id"]), + "action": "memory.export", + "scope": "profile", + "attributes": {}, + }, + ) + + assert consent_status == 200 + assert consent_payload == { + "items": [], + "summary": { + "total_count": 0, + "order": ["consent_key_asc", "created_at_asc", "id_asc"], + }, + } + assert policy_status == 200 + assert policy_payload == { + "items": [], + "summary": { + "total_count": 0, + "order": ["priority_asc", "created_at_asc", "id_asc"], + }, + } + assert detail_status == 404 + assert detail_payload == {"detail": f"policy {owner_policy['id']} was not found"} + assert evaluation_status == 200 + assert evaluation_payload["decision"] == "deny" + assert evaluation_payload["matched_policy"] is None + assert evaluation_payload["reasons"] == [ + { + "code": "no_matching_policy", + "source": "system", + "message": "No active policy matched the requested action, scope, and attributes.", + "policy_id": None, + "consent_key": None, + } + ] diff --git a/tests/integration/test_proxy_execution_api.py b/tests/integration/test_proxy_execution_api.py new file mode 100644 index 0000000..78c4822 --- /dev/null +++ b/tests/integration/test_proxy_execution_api.py @@ -0,0 +1,1816 @@ +from __future__ import annotations + +import json +from typing import Any +from urllib.parse import urlencode +from uuid import UUID, uuid4 + +import anyio +import psycopg + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.db import user_connection +from alicebot_api.store import ContinuityStore + + +def invoke_request( + method: str, + path: str, + *, + query_params: dict[str, str] | None = None, + payload: dict[str, Any] | None = None, +) -> tuple[int, dict[str, Any]]: + messages: list[dict[str, object]] = [] + encoded_body = b"" if payload is None else json.dumps(payload).encode() + request_received = False + + async def receive() -> dict[str, object]: + nonlocal request_received + if request_received: + return {"type": "http.disconnect"} + + request_received = True + return {"type": "http.request", "body": encoded_body, "more_body": False} + + async def send(message: dict[str, object]) -> None: + messages.append(message) + + query_string = urlencode(query_params or {}).encode() + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": query_string, + "headers": [(b"content-type", b"application/json")], + "client": ("testclient", 50000), + "server": ("testserver", 80), + "root_path": "", + } + + anyio.run(main_module.app, scope, receive, send) + + start_message = next(message for message in messages if message["type"] == "http.response.start") + body = b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + return start_message["status"], json.loads(body) + + +def seed_user(database_url: str, *, email: str) -> dict[str, UUID]: + user_id = uuid4() + + with user_connection(database_url, user_id) as conn: + store = ContinuityStore(conn) + store.create_user(user_id, email, email.split("@", 1)[0].title()) + thread = store.create_thread("Proxy execution thread") + + return { + "user_id": user_id, + "thread_id": thread["id"], + } + + +def create_thread( + database_url: str, + *, + user_id: UUID, + title: str, + agent_profile_id: str, +) -> UUID: + with user_connection(database_url, user_id) as conn: + store = ContinuityStore(conn) + thread = store.create_thread(title, agent_profile_id) + return thread["id"] + + +def create_tool_and_policy( + database_url: str, + *, + user_id: UUID, + tool_key: str, +) -> UUID: + with user_connection(database_url, user_id) as conn: + store = ContinuityStore(conn) + tool = store.create_tool( + tool_key=tool_key, + name="Proxy Tool", + description="Deterministic proxy tool.", + version="1.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["proxy"], + action_hints=["tool.run"], + scope_hints=["workspace"], + domain_hints=[], + risk_hints=[], + metadata={"transport": "proxy"}, + ) + store.create_policy( + name=f"Require approval for {tool_key}", + action="tool.run", + scope="workspace", + effect="require_approval", + priority=10, + active=True, + conditions={"tool_key": tool_key}, + required_consents=[], + ) + return tool["id"] + + +def create_pending_approval( + *, + user_id: UUID, + thread_id: UUID, + tool_id: UUID, +) -> tuple[int, dict[str, Any]]: + return invoke_request( + "POST", + "/v0/approvals/requests", + payload={ + "user_id": str(user_id), + "thread_id": str(thread_id), + "tool_id": str(tool_id), + "action": "tool.run", + "scope": "workspace", + "attributes": {"message": "hello", "count": 2}, + }, + ) + + +def create_execution_budget( + database_url: str, + *, + user_id: UUID, + agent_profile_id: str | None = None, + tool_key: str | None, + domain_hint: str | None, + max_completed_executions: int, + rolling_window_seconds: int | None = None, +) -> UUID: + with user_connection(database_url, user_id) as conn: + store = ContinuityStore(conn) + budget = store.create_execution_budget( + agent_profile_id=agent_profile_id, + tool_key=tool_key, + domain_hint=domain_hint, + max_completed_executions=max_completed_executions, + rolling_window_seconds=rolling_window_seconds, + supersedes_budget_id=None, + ) + return budget["id"] + + +def set_execution_executed_at( + admin_database_url: str, + *, + execution_id: UUID, + executed_at_sql: str, +) -> None: + with psycopg.connect(admin_database_url) as conn: + conn.execute( + f"UPDATE tool_executions SET executed_at = {executed_at_sql} WHERE id = %s", + (execution_id,), + ) + conn.commit() + + +def set_approval_request_thread_id( + admin_database_url: str, + *, + approval_id: UUID, + request_thread_id: str, +) -> None: + with psycopg.connect(admin_database_url) as conn: + conn.execute( + """ + UPDATE approvals + SET request = jsonb_set(request, '{thread_id}', to_jsonb(%s::text), true) + WHERE id = %s + """, + (request_thread_id, approval_id), + ) + conn.commit() + + +def test_execute_approved_proxy_endpoint_executes_only_approved_requests( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_user(migrated_database_urls["app"], email="owner@example.com") + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + tool_id = create_tool_and_policy( + migrated_database_urls["app"], + user_id=owner["user_id"], + tool_key="proxy.echo", + ) + + create_status, create_payload = create_pending_approval( + user_id=owner["user_id"], + thread_id=owner["thread_id"], + tool_id=tool_id, + ) + assert create_status == 200 + + approve_status, approve_payload = invoke_request( + "POST", + f"/v0/approvals/{create_payload['approval']['id']}/approve", + payload={"user_id": str(owner["user_id"])}, + ) + assert approve_status == 200 + assert approve_payload["approval"]["status"] == "approved" + + execute_status, execute_payload = invoke_request( + "POST", + f"/v0/approvals/{create_payload['approval']['id']}/execute", + payload={"user_id": str(owner["user_id"])}, + ) + + assert execute_status == 200 + assert list(execute_payload) == ["request", "approval", "tool", "result", "events", "trace"] + assert execute_payload["request"] == { + "approval_id": create_payload["approval"]["id"], + "task_step_id": create_payload["approval"]["task_step_id"], + } + assert execute_payload["approval"]["id"] == create_payload["approval"]["id"] + assert execute_payload["approval"]["status"] == "approved" + assert execute_payload["tool"]["id"] == str(tool_id) + assert execute_payload["tool"]["tool_key"] == "proxy.echo" + assert execute_payload["result"] == { + "handler_key": "proxy.echo", + "status": "completed", + "output": { + "mode": "no_side_effect", + "tool_key": "proxy.echo", + "action": "tool.run", + "scope": "workspace", + "domain_hint": None, + "risk_hint": None, + "attributes": {"message": "hello", "count": 2}, + }, + } + assert execute_payload["events"]["request_sequence_no"] == 1 + assert execute_payload["events"]["result_sequence_no"] == 2 + assert execute_payload["trace"]["trace_event_count"] == 9 + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + store = ContinuityStore(conn) + thread_events = store.list_thread_events(owner["thread_id"]) + tasks = store.list_tasks() + task_steps = store.list_task_steps_for_task(tasks[0]["id"]) + tool_executions = store.list_tool_executions() + execute_trace = store.get_trace(UUID(execute_payload["trace"]["trace_id"])) + execute_trace_events = store.list_trace_events(UUID(execute_payload["trace"]["trace_id"])) + + assert [event["kind"] for event in thread_events] == [ + "tool.proxy.execution.request", + "tool.proxy.execution.result", + ] + assert len(tool_executions) == 1 + assert len(tasks) == 1 + assert len(task_steps) == 1 + assert tasks[0]["status"] == "executed" + assert tasks[0]["latest_execution_id"] == tool_executions[0]["id"] + assert task_steps[0]["status"] == "executed" + assert tool_executions[0]["approval_id"] == UUID(create_payload["approval"]["id"]) + assert tool_executions[0]["task_step_id"] == task_steps[0]["id"] + assert tool_executions[0]["thread_id"] == owner["thread_id"] + assert tool_executions[0]["tool_id"] == tool_id + assert tool_executions[0]["trace_id"] == UUID(execute_payload["trace"]["trace_id"]) + assert tool_executions[0]["handler_key"] == "proxy.echo" + assert tool_executions[0]["status"] == "completed" + assert tool_executions[0]["request"] == thread_events[0]["payload"]["request"] + assert tool_executions[0]["tool"]["tool_key"] == "proxy.echo" + assert tool_executions[0]["result"] == { + "handler_key": "proxy.echo", + "status": "completed", + "output": execute_payload["result"]["output"], + "reason": None, + } + assert thread_events[0]["payload"] == { + "approval_id": create_payload["approval"]["id"], + "task_step_id": create_payload["approval"]["task_step_id"], + "tool_id": str(tool_id), + "tool_key": "proxy.echo", + "request": { + "thread_id": str(owner["thread_id"]), + "tool_id": str(tool_id), + "action": "tool.run", + "scope": "workspace", + "domain_hint": None, + "risk_hint": None, + "attributes": {"message": "hello", "count": 2}, + }, + } + assert execute_trace["kind"] == "tool.proxy.execute" + assert execute_trace["compiler_version"] == "proxy_execution_v0" + assert execute_trace["limits"] == { + "approval_status": "approved", + "enabled_handler_keys": ["proxy.echo"], + "budget_match_order": ["specificity_desc", "created_at_asc", "id_asc"], + } + assert [event["kind"] for event in execute_trace_events] == [ + "tool.proxy.execute.request", + "tool.proxy.execute.approval", + "tool.proxy.execute.budget", + "tool.proxy.execute.dispatch", + "tool.proxy.execute.summary", + "task.lifecycle.state", + "task.lifecycle.summary", + "task.step.lifecycle.state", + "task.step.lifecycle.summary", + ] + assert execute_trace_events[0]["payload"] == { + "approval_id": create_payload["approval"]["id"], + "task_step_id": create_payload["approval"]["task_step_id"], + } + assert execute_trace_events[1]["payload"]["task_step_id"] == create_payload["approval"]["task_step_id"] + assert execute_trace_events[2]["payload"]["decision"] == "allow" + assert execute_trace_events[3]["payload"]["dispatch_status"] == "executed" + assert execute_trace_events[3]["payload"]["task_step_id"] == create_payload["approval"]["task_step_id"] + assert execute_trace_events[4]["payload"]["request_event_id"] == execute_payload["events"]["request_event_id"] + assert execute_trace_events[4]["payload"]["task_step_id"] == create_payload["approval"]["task_step_id"] + assert execute_trace_events[7]["payload"] == { + "task_id": create_payload["task"]["id"], + "task_step_id": str(task_steps[0]["id"]), + "source": "proxy_execution", + "sequence_no": 1, + "kind": "governed_request", + "previous_status": "approved", + "current_status": "executed", + "trace": { + "trace_id": execute_payload["trace"]["trace_id"], + "trace_kind": "tool.proxy.execute", + }, + } + + +def test_execute_approved_proxy_endpoint_rejects_pending_approval( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_user(migrated_database_urls["app"], email="owner@example.com") + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + tool_id = create_tool_and_policy( + migrated_database_urls["app"], + user_id=owner["user_id"], + tool_key="proxy.echo", + ) + + create_status, create_payload = create_pending_approval( + user_id=owner["user_id"], + thread_id=owner["thread_id"], + tool_id=tool_id, + ) + assert create_status == 200 + + execute_status, execute_payload = invoke_request( + "POST", + f"/v0/approvals/{create_payload['approval']['id']}/execute", + payload={"user_id": str(owner["user_id"])}, + ) + + assert execute_status == 409 + assert execute_payload == { + "detail": f"approval {create_payload['approval']['id']} is pending and cannot be executed" + } + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + store = ContinuityStore(conn) + trace_rows = store.conn.execute( + "SELECT id, kind, limits FROM traces WHERE kind = %s ORDER BY created_at ASC, id ASC", + ("tool.proxy.execute",), + ).fetchall() + trace_events = store.list_trace_events(trace_rows[-1]["id"]) + thread_events = store.list_thread_events(owner["thread_id"]) + + assert thread_events == [] + assert trace_rows[-1]["limits"] == { + "approval_status": "pending", + "enabled_handler_keys": ["proxy.echo"], + "budget_match_order": ["specificity_desc", "created_at_asc", "id_asc"], + } + assert trace_events[2]["payload"]["dispatch_status"] == "blocked" + assert trace_events[3]["payload"]["execution_status"] == "blocked" + + +def test_execute_approved_proxy_endpoint_rejects_rejected_approval( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_user(migrated_database_urls["app"], email="owner@example.com") + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + tool_id = create_tool_and_policy( + migrated_database_urls["app"], + user_id=owner["user_id"], + tool_key="proxy.echo", + ) + + create_status, create_payload = create_pending_approval( + user_id=owner["user_id"], + thread_id=owner["thread_id"], + tool_id=tool_id, + ) + assert create_status == 200 + + reject_status, reject_payload = invoke_request( + "POST", + f"/v0/approvals/{create_payload['approval']['id']}/reject", + payload={"user_id": str(owner["user_id"])}, + ) + assert reject_status == 200 + assert reject_payload["approval"]["status"] == "rejected" + + execute_status, execute_payload = invoke_request( + "POST", + f"/v0/approvals/{create_payload['approval']['id']}/execute", + payload={"user_id": str(owner["user_id"])}, + ) + + assert execute_status == 409 + assert execute_payload == { + "detail": f"approval {create_payload['approval']['id']} is rejected and cannot be executed" + } + + +def test_execute_approved_proxy_endpoint_rejects_missing_handler( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_user(migrated_database_urls["app"], email="owner@example.com") + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + tool_id = create_tool_and_policy( + migrated_database_urls["app"], + user_id=owner["user_id"], + tool_key="proxy.missing", + ) + + create_status, create_payload = create_pending_approval( + user_id=owner["user_id"], + thread_id=owner["thread_id"], + tool_id=tool_id, + ) + assert create_status == 200 + + approve_status, approve_payload = invoke_request( + "POST", + f"/v0/approvals/{create_payload['approval']['id']}/approve", + payload={"user_id": str(owner["user_id"])}, + ) + assert approve_status == 200 + assert approve_payload["approval"]["status"] == "approved" + + execute_status, execute_payload = invoke_request( + "POST", + f"/v0/approvals/{create_payload['approval']['id']}/execute", + payload={"user_id": str(owner["user_id"])}, + ) + + assert execute_status == 409 + assert execute_payload == { + "detail": "tool 'proxy.missing' has no registered proxy handler" + } + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + store = ContinuityStore(conn) + trace_rows = store.conn.execute( + "SELECT id FROM traces WHERE kind = %s ORDER BY created_at ASC, id ASC", + ("tool.proxy.execute",), + ).fetchall() + trace_events = store.list_trace_events(trace_rows[-1]["id"]) + tool_executions = store.list_tool_executions() + thread_events = store.list_thread_events(owner["thread_id"]) + + assert thread_events == [] + assert len(tool_executions) == 1 + assert tool_executions[0]["approval_id"] == UUID(create_payload["approval"]["id"]) + assert tool_executions[0]["task_step_id"] == UUID(create_payload["approval"]["task_step_id"]) + assert tool_executions[0]["handler_key"] is None + assert tool_executions[0]["status"] == "blocked" + assert tool_executions[0]["request_event_id"] is None + assert tool_executions[0]["result_event_id"] is None + assert tool_executions[0]["result"] == { + "handler_key": None, + "status": "blocked", + "output": None, + "reason": "tool 'proxy.missing' has no registered proxy handler", + } + assert trace_events[2]["payload"]["decision"] == "allow" + assert trace_events[3]["payload"] == { + "approval_id": create_payload["approval"]["id"], + "task_step_id": create_payload["approval"]["task_step_id"], + "tool_id": str(tool_id), + "tool_key": "proxy.missing", + "handler_key": None, + "dispatch_status": "blocked", + "reason": "tool 'proxy.missing' has no registered proxy handler", + "result_status": "blocked", + "output": None, + } + + list_status, list_payload = invoke_request( + "GET", + "/v0/tool-executions", + query_params={"user_id": str(owner["user_id"])}, + ) + detail_status, detail_payload = invoke_request( + "GET", + f"/v0/tool-executions/{tool_executions[0]['id']}", + query_params={"user_id": str(owner['user_id'])}, + ) + + assert list_status == 200 + assert list_payload["items"][0]["task_step_id"] == create_payload["approval"]["task_step_id"] + assert list_payload["items"][0]["status"] == "blocked" + assert list_payload["items"][0]["request_event_id"] is None + assert list_payload["items"][0]["result_event_id"] is None + assert list_payload["items"][0]["result"]["reason"] == "tool 'proxy.missing' has no registered proxy handler" + assert detail_status == 200 + assert detail_payload == {"execution": list_payload["items"][0]} + + +def test_execute_approved_proxy_endpoint_marks_linked_run_failed_when_blocked( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_user(migrated_database_urls["app"], email="owner-linked-run@example.com") + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + tool_id = create_tool_and_policy( + migrated_database_urls["app"], + user_id=owner["user_id"], + tool_key="proxy.missing", + ) + + create_status, create_payload = create_pending_approval( + user_id=owner["user_id"], + thread_id=owner["thread_id"], + tool_id=tool_id, + ) + assert create_status == 200 + + run_create_status, run_create_payload = invoke_request( + "POST", + f"/v0/tasks/{create_payload['task']['id']}/runs", + payload={ + "user_id": str(owner["user_id"]), + "max_ticks": 3, + "checkpoint": { + "cursor": 0, + "target_steps": 1, + "wait_for_signal": False, + }, + }, + ) + assert run_create_status == 201 + run_id = run_create_payload["task_run"]["id"] + + run_tick_status, run_tick_payload = invoke_request( + "POST", + f"/v0/task-runs/{run_id}/tick", + payload={"user_id": str(owner["user_id"])}, + ) + assert run_tick_status == 200 + assert run_tick_payload["task_run"]["status"] == "waiting_approval" + + approve_status, approve_payload = invoke_request( + "POST", + f"/v0/approvals/{create_payload['approval']['id']}/approve", + payload={"user_id": str(owner["user_id"])}, + ) + assert approve_status == 200 + assert approve_payload["approval"]["status"] == "approved" + assert approve_payload["approval"]["task_run_id"] == run_id + + execute_status, execute_payload = invoke_request( + "POST", + f"/v0/approvals/{create_payload['approval']['id']}/execute", + payload={"user_id": str(owner["user_id"])}, + ) + assert execute_status == 409 + assert execute_payload == { + "detail": "tool 'proxy.missing' has no registered proxy handler" + } + + run_detail_status, run_detail_payload = invoke_request( + "GET", + f"/v0/task-runs/{run_id}", + query_params={"user_id": str(owner["user_id"])}, + ) + assert run_detail_status == 200 + assert run_detail_payload["task_run"]["status"] == "failed" + assert run_detail_payload["task_run"]["stop_reason"] == "policy_blocked" + assert run_detail_payload["task_run"]["failure_class"] == "policy" + assert run_detail_payload["task_run"]["retry_posture"] == "terminal" + assert run_detail_payload["task_run"]["checkpoint"]["last_execution_status"] == "blocked" + assert run_detail_payload["task_run"]["checkpoint"]["resolved_approval_id"] == create_payload["approval"]["id"] + + +def test_execute_approved_proxy_endpoint_enforces_user_isolation( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_user(migrated_database_urls["app"], email="owner@example.com") + other_user = seed_user(migrated_database_urls["app"], email="other@example.com") + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + tool_id = create_tool_and_policy( + migrated_database_urls["app"], + user_id=owner["user_id"], + tool_key="proxy.echo", + ) + + create_status, create_payload = create_pending_approval( + user_id=owner["user_id"], + thread_id=owner["thread_id"], + tool_id=tool_id, + ) + assert create_status == 200 + + approve_status, _ = invoke_request( + "POST", + f"/v0/approvals/{create_payload['approval']['id']}/approve", + payload={"user_id": str(owner["user_id"])}, + ) + assert approve_status == 200 + + execute_status, execute_payload = invoke_request( + "POST", + f"/v0/approvals/{create_payload['approval']['id']}/execute", + payload={"user_id": str(other_user["user_id"])}, + ) + + assert execute_status == 404 + assert execute_payload == { + "detail": f"approval {create_payload['approval']['id']} was not found" + } + + +def test_execute_approved_proxy_endpoint_updates_the_explicitly_linked_later_step( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_user(migrated_database_urls["app"], email="owner-step-linkage@example.com") + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + tool_id = create_tool_and_policy( + migrated_database_urls["app"], + user_id=owner["user_id"], + tool_key="proxy.echo", + ) + + create_status, create_payload = create_pending_approval( + user_id=owner["user_id"], + thread_id=owner["thread_id"], + tool_id=tool_id, + ) + assert create_status == 200 + + approve_status, _ = invoke_request( + "POST", + f"/v0/approvals/{create_payload['approval']['id']}/approve", + payload={"user_id": str(owner["user_id"])}, + ) + assert approve_status == 200 + + execute_status, execute_payload = invoke_request( + "POST", + f"/v0/approvals/{create_payload['approval']['id']}/execute", + payload={"user_id": str(owner["user_id"])}, + ) + assert execute_status == 200 + + detail_status, detail_payload = invoke_request( + "GET", + f"/v0/tasks/{create_payload['task']['id']}", + query_params={"user_id": str(owner["user_id"])}, + ) + step_list_status, step_list_payload = invoke_request( + "GET", + f"/v0/tasks/{create_payload['task']['id']}/steps", + query_params={"user_id": str(owner["user_id"])}, + ) + assert detail_status == 200 + assert step_list_status == 200 + initial_execution_id = detail_payload["task"]["latest_execution_id"] + assert initial_execution_id is not None + + create_step_status, create_step_payload = invoke_request( + "POST", + f"/v0/tasks/{create_payload['task']['id']}/steps", + payload={ + "user_id": str(owner["user_id"]), + "kind": "governed_request", + "status": "created", + "request": { + "thread_id": str(owner["thread_id"]), + "tool_id": str(tool_id), + "action": "tool.run", + "scope": "workspace", + "attributes": {"message": "step-2"}, + }, + "outcome": { + "routing_decision": "approval_required", + "approval_status": None, + "approval_id": None, + "execution_id": None, + "execution_status": None, + "blocked_reason": None, + }, + "lineage": { + "parent_step_id": step_list_payload["items"][0]["id"], + "source_approval_id": create_payload["approval"]["id"], + "source_execution_id": initial_execution_id, + }, + }, + ) + assert create_step_status == 201 + + transition_status, transition_payload = invoke_request( + "POST", + f"/v0/task-steps/{create_step_payload['task_step']['id']}/transition", + payload={ + "user_id": str(owner["user_id"]), + "status": "approved", + "outcome": { + "routing_decision": "approval_required", + "approval_status": "approved", + "approval_id": create_payload["approval"]["id"], + "execution_id": None, + "execution_status": None, + "blocked_reason": None, + }, + }, + ) + assert transition_status == 200 + assert transition_payload["task_step"]["status"] == "approved" + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + store = ContinuityStore(conn) + relinked = store.update_approval_task_step_optional( + approval_id=UUID(create_payload["approval"]["id"]), + task_step_id=UUID(create_step_payload["task_step"]["id"]), + ) + assert relinked is not None + + second_execute_status, second_execute_payload = invoke_request( + "POST", + f"/v0/approvals/{create_payload['approval']['id']}/execute", + payload={"user_id": str(owner["user_id"])}, + ) + assert second_execute_status == 200 + assert second_execute_payload["request"] == { + "approval_id": create_payload["approval"]["id"], + "task_step_id": create_step_payload["task_step"]["id"], + } + assert second_execute_payload["approval"]["task_step_id"] == create_step_payload["task_step"]["id"] + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + store = ContinuityStore(conn) + task = store.get_task_optional(UUID(create_payload["task"]["id"])) + task_steps = store.list_task_steps_for_task(UUID(create_payload["task"]["id"])) + tool_executions = store.list_tool_executions() + proxy_traces = store.conn.execute( + """ + SELECT id + FROM traces + WHERE thread_id = %s + AND kind = 'tool.proxy.execute' + ORDER BY created_at ASC, id ASC + """, + (owner["thread_id"],), + ).fetchall() + + assert task is not None + assert task["status"] == "executed" + assert task["latest_approval_id"] == UUID(create_payload["approval"]["id"]) + assert len(task_steps) == 2 + assert task_steps[0]["status"] == "executed" + assert task_steps[0]["trace_id"] == UUID(execute_payload["trace"]["trace_id"]) + assert task_steps[0]["outcome"]["execution_id"] == initial_execution_id + assert task_steps[1]["status"] == "executed" + assert task_steps[1]["id"] == UUID(create_step_payload["task_step"]["id"]) + assert task_steps[1]["trace_id"] == UUID(second_execute_payload["trace"]["trace_id"]) + assert task_steps[1]["outcome"]["approval_id"] == create_payload["approval"]["id"] + assert task_steps[1]["outcome"]["execution_status"] == "completed" + assert len(tool_executions) == 2 + assert task["latest_execution_id"] == tool_executions[1]["id"] + assert tool_executions[1]["task_step_id"] == UUID(create_step_payload["task_step"]["id"]) + assert task_steps[1]["outcome"]["execution_id"] == str(tool_executions[1]["id"]) + assert len(proxy_traces) == 2 + + +def test_execute_approved_proxy_endpoint_fail_closes_when_runtime_context_is_invalid( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_user(migrated_database_urls["app"], email="owner@example.com") + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + tool_id = create_tool_and_policy( + migrated_database_urls["app"], + user_id=owner["user_id"], + tool_key="proxy.echo", + ) + + create_status, create_payload = create_pending_approval( + user_id=owner["user_id"], + thread_id=owner["thread_id"], + tool_id=tool_id, + ) + assert create_status == 200 + + approve_status, _ = invoke_request( + "POST", + f"/v0/approvals/{create_payload['approval']['id']}/approve", + payload={"user_id": str(owner["user_id"])}, + ) + assert approve_status == 200 + + set_approval_request_thread_id( + migrated_database_urls["admin"], + approval_id=UUID(create_payload["approval"]["id"]), + request_thread_id="not-a-uuid", + ) + + execute_status, execute_payload = invoke_request( + "POST", + f"/v0/approvals/{create_payload['approval']['id']}/execute", + payload={"user_id": str(owner["user_id"])}, + ) + + assert execute_status == 200 + assert execute_payload["events"] is None + assert execute_payload["result"] == { + "handler_key": None, + "status": "blocked", + "output": None, + "reason": ( + "execution budget invariance blocks execution: invalid request thread/profile " + "context: request.thread_id 'not-a-uuid' is not a valid UUID" + ), + "budget_decision": { + "matched_budget_id": None, + "tool_key": "proxy.echo", + "domain_hint": None, + "budget_tool_key": None, + "budget_domain_hint": None, + "max_completed_executions": None, + "rolling_window_seconds": None, + "count_scope": "lifetime", + "window_started_at": None, + "completed_execution_count": 0, + "projected_completed_execution_count": 1, + "decision": "block", + "reason": "invalid_request_context", + "order": ["specificity_desc", "created_at_asc", "id_asc"], + "history_order": ["executed_at_asc", "id_asc"], + "request_thread_id": "not-a-uuid", + "context_resolution": "invalid", + "context_reason": "request.thread_id 'not-a-uuid' is not a valid UUID", + }, + } + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + store = ContinuityStore(conn) + stored_executions = store.list_tool_executions() + blocked_trace_events = store.list_trace_events(UUID(execute_payload["trace"]["trace_id"])) + thread_events = store.list_thread_events(owner["thread_id"]) + + assert len(stored_executions) == 1 + assert stored_executions[0]["status"] == "blocked" + assert stored_executions[0]["result"] == execute_payload["result"] + assert stored_executions[0]["request_event_id"] is None + assert stored_executions[0]["result_event_id"] is None + assert thread_events == [] + assert blocked_trace_events[2]["payload"] == execute_payload["result"]["budget_decision"] + assert blocked_trace_events[3]["payload"]["dispatch_status"] == "blocked" + assert blocked_trace_events[3]["payload"]["budget_context"] == { + "request_thread_id": "not-a-uuid", + "context_resolution": "invalid", + "context_reason": "request.thread_id 'not-a-uuid' is not a valid UUID", + } + + +def test_execute_approved_proxy_endpoint_blocks_when_execution_budget_is_exceeded( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_user(migrated_database_urls["app"], email="owner@example.com") + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + tool_id = create_tool_and_policy( + migrated_database_urls["app"], + user_id=owner["user_id"], + tool_key="proxy.echo", + ) + budget_id = create_execution_budget( + migrated_database_urls["app"], + user_id=owner["user_id"], + tool_key="proxy.echo", + domain_hint=None, + max_completed_executions=1, + ) + + first_create_status, first_create_payload = create_pending_approval( + user_id=owner["user_id"], + thread_id=owner["thread_id"], + tool_id=tool_id, + ) + second_create_status, second_create_payload = create_pending_approval( + user_id=owner["user_id"], + thread_id=owner["thread_id"], + tool_id=tool_id, + ) + assert first_create_status == 200 + assert second_create_status == 200 + + first_approve_status, _ = invoke_request( + "POST", + f"/v0/approvals/{first_create_payload['approval']['id']}/approve", + payload={"user_id": str(owner["user_id"])}, + ) + second_approve_status, _ = invoke_request( + "POST", + f"/v0/approvals/{second_create_payload['approval']['id']}/approve", + payload={"user_id": str(owner["user_id"])}, + ) + assert first_approve_status == 200 + assert second_approve_status == 200 + + first_execute_status, first_execute_payload = invoke_request( + "POST", + f"/v0/approvals/{first_create_payload['approval']['id']}/execute", + payload={"user_id": str(owner["user_id"])}, + ) + second_execute_status, second_execute_payload = invoke_request( + "POST", + f"/v0/approvals/{second_create_payload['approval']['id']}/execute", + payload={"user_id": str(owner["user_id"])}, + ) + + assert first_execute_status == 200 + assert second_execute_status == 200 + assert second_execute_payload["events"] is None + assert second_execute_payload["result"] == { + "handler_key": None, + "status": "blocked", + "output": None, + "reason": ( + f"execution budget {budget_id} blocks execution: projected completed executions " + "2 would exceed limit 1" + ), + "budget_decision": { + "matched_budget_id": str(budget_id), + "tool_key": "proxy.echo", + "domain_hint": None, + "budget_tool_key": "proxy.echo", + "budget_domain_hint": None, + "max_completed_executions": 1, + "rolling_window_seconds": None, + "count_scope": "lifetime", + "window_started_at": None, + "completed_execution_count": 1, + "projected_completed_execution_count": 2, + "decision": "block", + "reason": "budget_exceeded", + "order": ["specificity_desc", "created_at_asc", "id_asc"], + "history_order": ["executed_at_asc", "id_asc"], + }, + } + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + store = ContinuityStore(conn) + stored_executions = store.list_tool_executions() + blocked_trace = store.get_trace(UUID(second_execute_payload["trace"]["trace_id"])) + blocked_trace_events = store.list_trace_events(UUID(second_execute_payload["trace"]["trace_id"])) + thread_events = store.list_thread_events(owner["thread_id"]) + + list_status, list_payload = invoke_request( + "GET", + "/v0/tool-executions", + query_params={"user_id": str(owner["user_id"])}, + ) + detail_status, detail_payload = invoke_request( + "GET", + f"/v0/tool-executions/{stored_executions[1]['id']}", + query_params={"user_id": str(owner['user_id'])}, + ) + + assert len(stored_executions) == 2 + assert [row["status"] for row in stored_executions] == ["completed", "blocked"] + assert stored_executions[1]["task_step_id"] == UUID(second_execute_payload["request"]["task_step_id"]) + assert stored_executions[1]["result"] == second_execute_payload["result"] + assert stored_executions[1]["request_event_id"] is None + assert stored_executions[1]["result_event_id"] is None + assert [event["kind"] for event in thread_events] == [ + "tool.proxy.execution.request", + "tool.proxy.execution.result", + ] + assert blocked_trace["limits"] == { + "approval_status": "approved", + "enabled_handler_keys": ["proxy.echo"], + "budget_match_order": ["specificity_desc", "created_at_asc", "id_asc"], + } + assert [event["kind"] for event in blocked_trace_events] == [ + "tool.proxy.execute.request", + "tool.proxy.execute.approval", + "tool.proxy.execute.budget", + "tool.proxy.execute.dispatch", + "tool.proxy.execute.summary", + "task.lifecycle.state", + "task.lifecycle.summary", + "task.step.lifecycle.state", + "task.step.lifecycle.summary", + ] + assert blocked_trace_events[0]["payload"] == second_execute_payload["request"] + assert blocked_trace_events[1]["payload"]["task_step_id"] == second_execute_payload["request"]["task_step_id"] + assert blocked_trace_events[2]["payload"] == second_execute_payload["result"]["budget_decision"] + assert blocked_trace_events[3]["payload"]["dispatch_status"] == "blocked" + assert blocked_trace_events[3]["payload"]["task_step_id"] == second_execute_payload["request"]["task_step_id"] + assert list_status == 200 + assert list_payload["items"][1]["task_step_id"] == second_execute_payload["request"]["task_step_id"] + assert [item["status"] for item in list_payload["items"]] == ["completed", "blocked"] + assert list_payload["items"][1]["result"] == second_execute_payload["result"] + assert detail_status == 200 + assert detail_payload == {"execution": list_payload["items"][1]} + + +def test_execute_approved_proxy_endpoint_allows_when_recent_history_is_within_rolling_window_limit( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_user(migrated_database_urls["app"], email="owner@example.com") + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + tool_id = create_tool_and_policy( + migrated_database_urls["app"], + user_id=owner["user_id"], + tool_key="proxy.echo", + ) + create_execution_budget( + migrated_database_urls["app"], + user_id=owner["user_id"], + tool_key="proxy.echo", + domain_hint=None, + max_completed_executions=2, + rolling_window_seconds=3600, + ) + + first_create_status, first_create_payload = create_pending_approval( + user_id=owner["user_id"], + thread_id=owner["thread_id"], + tool_id=tool_id, + ) + second_create_status, second_create_payload = create_pending_approval( + user_id=owner["user_id"], + thread_id=owner["thread_id"], + tool_id=tool_id, + ) + assert first_create_status == 200 + assert second_create_status == 200 + + first_approve_status, _ = invoke_request( + "POST", + f"/v0/approvals/{first_create_payload['approval']['id']}/approve", + payload={"user_id": str(owner["user_id"])}, + ) + second_approve_status, _ = invoke_request( + "POST", + f"/v0/approvals/{second_create_payload['approval']['id']}/approve", + payload={"user_id": str(owner["user_id"])}, + ) + assert first_approve_status == 200 + assert second_approve_status == 200 + + first_execute_status, _ = invoke_request( + "POST", + f"/v0/approvals/{first_create_payload['approval']['id']}/execute", + payload={"user_id": str(owner["user_id"])}, + ) + second_execute_status, second_execute_payload = invoke_request( + "POST", + f"/v0/approvals/{second_create_payload['approval']['id']}/execute", + payload={"user_id": str(owner["user_id"])}, + ) + + assert first_execute_status == 200 + assert second_execute_status == 200 + assert second_execute_payload["result"]["status"] == "completed" + assert second_execute_payload["events"] is not None + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + store = ContinuityStore(conn) + execute_trace_events = store.list_trace_events(UUID(second_execute_payload["trace"]["trace_id"])) + + assert execute_trace_events[2]["payload"]["matched_budget_id"] is not None + assert execute_trace_events[2]["payload"]["rolling_window_seconds"] == 3600 + assert execute_trace_events[2]["payload"]["count_scope"] == "rolling_window" + assert execute_trace_events[2]["payload"]["window_started_at"] is not None + assert execute_trace_events[2]["payload"]["completed_execution_count"] == 1 + assert execute_trace_events[2]["payload"]["projected_completed_execution_count"] == 2 + assert execute_trace_events[2]["payload"]["decision"] == "allow" + assert execute_trace_events[2]["payload"]["reason"] == "within_budget" + assert execute_trace_events[2]["payload"]["history_order"] == ["executed_at_asc", "id_asc"] + + +def test_execute_approved_proxy_endpoint_blocks_when_recent_window_history_exceeds_limit( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_user(migrated_database_urls["app"], email="owner@example.com") + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + tool_id = create_tool_and_policy( + migrated_database_urls["app"], + user_id=owner["user_id"], + tool_key="proxy.echo", + ) + budget_id = create_execution_budget( + migrated_database_urls["app"], + user_id=owner["user_id"], + tool_key="proxy.echo", + domain_hint=None, + max_completed_executions=1, + rolling_window_seconds=3600, + ) + + first_create_status, first_create_payload = create_pending_approval( + user_id=owner["user_id"], + thread_id=owner["thread_id"], + tool_id=tool_id, + ) + second_create_status, second_create_payload = create_pending_approval( + user_id=owner["user_id"], + thread_id=owner["thread_id"], + tool_id=tool_id, + ) + assert first_create_status == 200 + assert second_create_status == 200 + + first_approve_status, _ = invoke_request( + "POST", + f"/v0/approvals/{first_create_payload['approval']['id']}/approve", + payload={"user_id": str(owner["user_id"])}, + ) + second_approve_status, _ = invoke_request( + "POST", + f"/v0/approvals/{second_create_payload['approval']['id']}/approve", + payload={"user_id": str(owner["user_id"])}, + ) + assert first_approve_status == 200 + assert second_approve_status == 200 + + first_execute_status, _ = invoke_request( + "POST", + f"/v0/approvals/{first_create_payload['approval']['id']}/execute", + payload={"user_id": str(owner["user_id"])}, + ) + second_execute_status, second_execute_payload = invoke_request( + "POST", + f"/v0/approvals/{second_create_payload['approval']['id']}/execute", + payload={"user_id": str(owner["user_id"])}, + ) + + assert first_execute_status == 200 + assert second_execute_status == 200 + assert second_execute_payload["events"] is None + assert list(second_execute_payload["result"]) == [ + "handler_key", + "status", + "output", + "reason", + "budget_decision", + ] + assert second_execute_payload["result"]["handler_key"] is None + assert second_execute_payload["result"]["status"] == "blocked" + assert second_execute_payload["result"]["output"] is None + assert second_execute_payload["result"]["reason"] == ( + f"execution budget {budget_id} blocks execution: projected completed executions " + "2 within rolling window 3600 seconds would exceed limit 1" + ) + assert second_execute_payload["result"]["budget_decision"]["matched_budget_id"] == str(budget_id) + assert second_execute_payload["result"]["budget_decision"]["rolling_window_seconds"] == 3600 + assert second_execute_payload["result"]["budget_decision"]["count_scope"] == "rolling_window" + assert second_execute_payload["result"]["budget_decision"]["window_started_at"] is not None + assert second_execute_payload["result"]["budget_decision"]["completed_execution_count"] == 1 + assert second_execute_payload["result"]["budget_decision"]["projected_completed_execution_count"] == 2 + assert second_execute_payload["result"]["budget_decision"]["history_order"] == [ + "executed_at_asc", + "id_asc", + ] + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + store = ContinuityStore(conn) + stored_executions = store.list_tool_executions() + blocked_trace_events = store.list_trace_events(UUID(second_execute_payload["trace"]["trace_id"])) + + assert [row["status"] for row in stored_executions] == ["completed", "blocked"] + assert stored_executions[1]["task_step_id"] == UUID(second_execute_payload["request"]["task_step_id"]) + assert stored_executions[1]["result"] == second_execute_payload["result"] + assert blocked_trace_events[2]["payload"] == second_execute_payload["result"]["budget_decision"] + + +def test_execute_approved_proxy_endpoint_excludes_old_window_history_and_keeps_counts_user_scoped( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_user(migrated_database_urls["app"], email="owner@example.com") + other_user = seed_user(migrated_database_urls["app"], email="other@example.com") + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + owner_tool_id = create_tool_and_policy( + migrated_database_urls["app"], + user_id=owner["user_id"], + tool_key="proxy.echo", + ) + other_tool_id = create_tool_and_policy( + migrated_database_urls["app"], + user_id=other_user["user_id"], + tool_key="proxy.echo", + ) + budget_id = create_execution_budget( + migrated_database_urls["app"], + user_id=owner["user_id"], + tool_key="proxy.echo", + domain_hint=None, + max_completed_executions=1, + rolling_window_seconds=60, + ) + + owner_first_status, owner_first_payload = create_pending_approval( + user_id=owner["user_id"], + thread_id=owner["thread_id"], + tool_id=owner_tool_id, + ) + owner_second_status, owner_second_payload = create_pending_approval( + user_id=owner["user_id"], + thread_id=owner["thread_id"], + tool_id=owner_tool_id, + ) + other_status, other_payload = create_pending_approval( + user_id=other_user["user_id"], + thread_id=other_user["thread_id"], + tool_id=other_tool_id, + ) + assert owner_first_status == 200 + assert owner_second_status == 200 + assert other_status == 200 + + for approval_payload, user_id in ( + (owner_first_payload, owner["user_id"]), + (owner_second_payload, owner["user_id"]), + (other_payload, other_user["user_id"]), + ): + approve_status, _ = invoke_request( + "POST", + f"/v0/approvals/{approval_payload['approval']['id']}/approve", + payload={"user_id": str(user_id)}, + ) + assert approve_status == 200 + + owner_first_execute_status, owner_first_execute_payload = invoke_request( + "POST", + f"/v0/approvals/{owner_first_payload['approval']['id']}/execute", + payload={"user_id": str(owner["user_id"])}, + ) + other_execute_status, _ = invoke_request( + "POST", + f"/v0/approvals/{other_payload['approval']['id']}/execute", + payload={"user_id": str(other_user["user_id"])}, + ) + assert owner_first_execute_status == 200 + assert other_execute_status == 200 + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + store = ContinuityStore(conn) + owner_first_execution_id = store.list_tool_executions()[0]["id"] + + set_execution_executed_at( + migrated_database_urls["admin"], + execution_id=owner_first_execution_id, + executed_at_sql="clock_timestamp() - interval '2 hours'", + ) + + owner_second_execute_status, owner_second_execute_payload = invoke_request( + "POST", + f"/v0/approvals/{owner_second_payload['approval']['id']}/execute", + payload={"user_id": str(owner["user_id"])}, + ) + + assert owner_second_execute_status == 200 + assert owner_second_execute_payload["result"]["status"] == "completed" + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + store = ContinuityStore(conn) + execute_trace_events = store.list_trace_events(UUID(owner_second_execute_payload["trace"]["trace_id"])) + + assert execute_trace_events[2]["payload"]["matched_budget_id"] == str(budget_id) + assert execute_trace_events[2]["payload"]["rolling_window_seconds"] == 60 + assert execute_trace_events[2]["payload"]["count_scope"] == "rolling_window" + assert execute_trace_events[2]["payload"]["window_started_at"] is not None + assert execute_trace_events[2]["payload"]["completed_execution_count"] == 0 + assert execute_trace_events[2]["payload"]["projected_completed_execution_count"] == 1 + assert execute_trace_events[2]["payload"]["reason"] == "within_budget" + + +def test_execute_approved_proxy_endpoint_ignores_deactivated_budget( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_user(migrated_database_urls["app"], email="owner@example.com") + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + tool_id = create_tool_and_policy( + migrated_database_urls["app"], + user_id=owner["user_id"], + tool_key="proxy.echo", + ) + budget_id = create_execution_budget( + migrated_database_urls["app"], + user_id=owner["user_id"], + tool_key="proxy.echo", + domain_hint=None, + max_completed_executions=1, + ) + + first_create_status, first_create_payload = create_pending_approval( + user_id=owner["user_id"], + thread_id=owner["thread_id"], + tool_id=tool_id, + ) + second_create_status, second_create_payload = create_pending_approval( + user_id=owner["user_id"], + thread_id=owner["thread_id"], + tool_id=tool_id, + ) + assert first_create_status == 200 + assert second_create_status == 200 + + first_approve_status, _ = invoke_request( + "POST", + f"/v0/approvals/{first_create_payload['approval']['id']}/approve", + payload={"user_id": str(owner["user_id"])}, + ) + second_approve_status, _ = invoke_request( + "POST", + f"/v0/approvals/{second_create_payload['approval']['id']}/approve", + payload={"user_id": str(owner["user_id"])}, + ) + assert first_approve_status == 200 + assert second_approve_status == 200 + + first_execute_status, _ = invoke_request( + "POST", + f"/v0/approvals/{first_create_payload['approval']['id']}/execute", + payload={"user_id": str(owner["user_id"])}, + ) + deactivate_status, deactivate_payload = invoke_request( + "POST", + f"/v0/execution-budgets/{budget_id}/deactivate", + payload={ + "user_id": str(owner["user_id"]), + "thread_id": str(owner["thread_id"]), + }, + ) + second_execute_status, second_execute_payload = invoke_request( + "POST", + f"/v0/approvals/{second_create_payload['approval']['id']}/execute", + payload={"user_id": str(owner["user_id"])}, + ) + + assert first_execute_status == 200 + assert deactivate_status == 200 + assert deactivate_payload["execution_budget"]["status"] == "inactive" + assert second_execute_status == 200 + assert second_execute_payload["result"]["status"] == "completed" + assert second_execute_payload["trace"]["trace_event_count"] == 9 + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + store = ContinuityStore(conn) + execute_trace_events = store.list_trace_events(UUID(second_execute_payload["trace"]["trace_id"])) + + assert execute_trace_events[2]["payload"] == { + "matched_budget_id": None, + "tool_key": "proxy.echo", + "domain_hint": None, + "budget_tool_key": None, + "budget_domain_hint": None, + "max_completed_executions": None, + "rolling_window_seconds": None, + "count_scope": "lifetime", + "window_started_at": None, + "completed_execution_count": 0, + "projected_completed_execution_count": 1, + "decision": "allow", + "reason": "no_matching_budget", + "order": ["specificity_desc", "created_at_asc", "id_asc"], + "history_order": ["executed_at_asc", "id_asc"], + } + + +def test_execute_approved_proxy_endpoint_uses_replacement_budget_after_supersession( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_user(migrated_database_urls["app"], email="owner@example.com") + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + tool_id = create_tool_and_policy( + migrated_database_urls["app"], + user_id=owner["user_id"], + tool_key="proxy.echo", + ) + budget_id = create_execution_budget( + migrated_database_urls["app"], + user_id=owner["user_id"], + tool_key="proxy.echo", + domain_hint=None, + max_completed_executions=1, + ) + + first_create_status, first_create_payload = create_pending_approval( + user_id=owner["user_id"], + thread_id=owner["thread_id"], + tool_id=tool_id, + ) + second_create_status, second_create_payload = create_pending_approval( + user_id=owner["user_id"], + thread_id=owner["thread_id"], + tool_id=tool_id, + ) + assert first_create_status == 200 + assert second_create_status == 200 + + first_approve_status, _ = invoke_request( + "POST", + f"/v0/approvals/{first_create_payload['approval']['id']}/approve", + payload={"user_id": str(owner["user_id"])}, + ) + second_approve_status, _ = invoke_request( + "POST", + f"/v0/approvals/{second_create_payload['approval']['id']}/approve", + payload={"user_id": str(owner["user_id"])}, + ) + assert first_approve_status == 200 + assert second_approve_status == 200 + + first_execute_status, _ = invoke_request( + "POST", + f"/v0/approvals/{first_create_payload['approval']['id']}/execute", + payload={"user_id": str(owner["user_id"])}, + ) + supersede_status, supersede_payload = invoke_request( + "POST", + f"/v0/execution-budgets/{budget_id}/supersede", + payload={ + "user_id": str(owner["user_id"]), + "thread_id": str(owner["thread_id"]), + "max_completed_executions": 2, + }, + ) + second_execute_status, second_execute_payload = invoke_request( + "POST", + f"/v0/approvals/{second_create_payload['approval']['id']}/execute", + payload={"user_id": str(owner["user_id"])}, + ) + + assert first_execute_status == 200 + assert supersede_status == 200 + assert supersede_payload["superseded_budget"]["status"] == "superseded" + assert supersede_payload["replacement_budget"]["status"] == "active" + assert second_execute_status == 200 + assert second_execute_payload["result"]["status"] == "completed" + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + store = ContinuityStore(conn) + execute_trace_events = store.list_trace_events(UUID(second_execute_payload["trace"]["trace_id"])) + + assert execute_trace_events[2]["payload"] == { + "matched_budget_id": supersede_payload["replacement_budget"]["id"], + "tool_key": "proxy.echo", + "domain_hint": None, + "budget_tool_key": "proxy.echo", + "budget_domain_hint": None, + "max_completed_executions": 2, + "rolling_window_seconds": None, + "count_scope": "lifetime", + "window_started_at": None, + "completed_execution_count": 1, + "projected_completed_execution_count": 2, + "decision": "allow", + "reason": "within_budget", + "order": ["specificity_desc", "created_at_asc", "id_asc"], + "history_order": ["executed_at_asc", "id_asc"], + } + + +def test_execute_approved_proxy_endpoint_applies_profile_scope_before_global_fallback( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_user(migrated_database_urls["app"], email="owner@example.com") + coach_thread_id = create_thread( + migrated_database_urls["app"], + user_id=owner["user_id"], + title="Coach profile thread", + agent_profile_id="coach_default", + ) + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + tool_id = create_tool_and_policy( + migrated_database_urls["app"], + user_id=owner["user_id"], + tool_key="proxy.echo", + ) + assistant_budget_id = create_execution_budget( + migrated_database_urls["app"], + user_id=owner["user_id"], + agent_profile_id="assistant_default", + tool_key="proxy.echo", + domain_hint=None, + max_completed_executions=1, + ) + global_budget_id = create_execution_budget( + migrated_database_urls["app"], + user_id=owner["user_id"], + agent_profile_id=None, + tool_key="proxy.echo", + domain_hint=None, + max_completed_executions=1, + ) + + assistant_first_status, assistant_first_payload = create_pending_approval( + user_id=owner["user_id"], + thread_id=owner["thread_id"], + tool_id=tool_id, + ) + assistant_second_status, assistant_second_payload = create_pending_approval( + user_id=owner["user_id"], + thread_id=owner["thread_id"], + tool_id=tool_id, + ) + coach_first_status, coach_first_payload = create_pending_approval( + user_id=owner["user_id"], + thread_id=coach_thread_id, + tool_id=tool_id, + ) + coach_second_status, coach_second_payload = create_pending_approval( + user_id=owner["user_id"], + thread_id=coach_thread_id, + tool_id=tool_id, + ) + assert assistant_first_status == 200 + assert assistant_second_status == 200 + assert coach_first_status == 200 + assert coach_second_status == 200 + + for approval_payload in ( + assistant_first_payload, + assistant_second_payload, + coach_first_payload, + coach_second_payload, + ): + approve_status, _ = invoke_request( + "POST", + f"/v0/approvals/{approval_payload['approval']['id']}/approve", + payload={"user_id": str(owner["user_id"])}, + ) + assert approve_status == 200 + + assistant_first_execute_status, assistant_first_execute_payload = invoke_request( + "POST", + f"/v0/approvals/{assistant_first_payload['approval']['id']}/execute", + payload={"user_id": str(owner["user_id"])}, + ) + coach_first_execute_status, coach_first_execute_payload = invoke_request( + "POST", + f"/v0/approvals/{coach_first_payload['approval']['id']}/execute", + payload={"user_id": str(owner["user_id"])}, + ) + coach_second_execute_status, coach_second_execute_payload = invoke_request( + "POST", + f"/v0/approvals/{coach_second_payload['approval']['id']}/execute", + payload={"user_id": str(owner["user_id"])}, + ) + assistant_second_execute_status, assistant_second_execute_payload = invoke_request( + "POST", + f"/v0/approvals/{assistant_second_payload['approval']['id']}/execute", + payload={"user_id": str(owner["user_id"])}, + ) + + assert assistant_first_execute_status == 200 + assert coach_first_execute_status == 200 + assert coach_second_execute_status == 200 + assert assistant_second_execute_status == 200 + assert assistant_first_execute_payload["result"]["status"] == "completed" + assert coach_first_execute_payload["result"]["status"] == "completed" + assert coach_second_execute_payload["result"]["status"] == "blocked" + assert assistant_second_execute_payload["result"]["status"] == "blocked" + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + store = ContinuityStore(conn) + assistant_first_trace_events = store.list_trace_events( + UUID(assistant_first_execute_payload["trace"]["trace_id"]) + ) + coach_first_trace_events = store.list_trace_events( + UUID(coach_first_execute_payload["trace"]["trace_id"]) + ) + coach_second_trace_events = store.list_trace_events( + UUID(coach_second_execute_payload["trace"]["trace_id"]) + ) + assistant_second_trace_events = store.list_trace_events( + UUID(assistant_second_execute_payload["trace"]["trace_id"]) + ) + + assert assistant_first_trace_events[2]["payload"]["matched_budget_id"] == str(assistant_budget_id) + assert assistant_first_trace_events[2]["payload"]["completed_execution_count"] == 0 + assert assistant_first_trace_events[2]["payload"]["projected_completed_execution_count"] == 1 + assert assistant_first_trace_events[2]["payload"]["reason"] == "within_budget" + + assert coach_first_trace_events[2]["payload"]["matched_budget_id"] == str(global_budget_id) + assert coach_first_trace_events[2]["payload"]["completed_execution_count"] == 0 + assert coach_first_trace_events[2]["payload"]["projected_completed_execution_count"] == 1 + assert coach_first_trace_events[2]["payload"]["reason"] == "within_budget" + + assert coach_second_trace_events[2]["payload"]["matched_budget_id"] == str(global_budget_id) + assert coach_second_trace_events[2]["payload"]["completed_execution_count"] == 1 + assert coach_second_trace_events[2]["payload"]["projected_completed_execution_count"] == 2 + assert coach_second_trace_events[2]["payload"]["reason"] == "budget_exceeded" + + assert assistant_second_trace_events[2]["payload"]["matched_budget_id"] == str(assistant_budget_id) + assert assistant_second_trace_events[2]["payload"]["completed_execution_count"] == 1 + assert assistant_second_trace_events[2]["payload"]["projected_completed_execution_count"] == 2 + assert assistant_second_trace_events[2]["payload"]["reason"] == "budget_exceeded" + + +def test_execute_approved_proxy_execution_budget_is_user_scoped( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_user(migrated_database_urls["app"], email="owner@example.com") + other_user = seed_user(migrated_database_urls["app"], email="other@example.com") + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + owner_tool_id = create_tool_and_policy( + migrated_database_urls["app"], + user_id=owner["user_id"], + tool_key="proxy.echo", + ) + other_tool_id = create_tool_and_policy( + migrated_database_urls["app"], + user_id=other_user["user_id"], + tool_key="proxy.echo", + ) + create_execution_budget( + migrated_database_urls["app"], + user_id=owner["user_id"], + tool_key="proxy.echo", + domain_hint=None, + max_completed_executions=1, + ) + + owner_create_status, owner_create_payload = create_pending_approval( + user_id=owner["user_id"], + thread_id=owner["thread_id"], + tool_id=owner_tool_id, + ) + other_create_status, other_create_payload = create_pending_approval( + user_id=other_user["user_id"], + thread_id=other_user["thread_id"], + tool_id=other_tool_id, + ) + assert owner_create_status == 200 + assert other_create_status == 200 + + owner_approve_status, _ = invoke_request( + "POST", + f"/v0/approvals/{owner_create_payload['approval']['id']}/approve", + payload={"user_id": str(owner["user_id"])}, + ) + other_approve_status, _ = invoke_request( + "POST", + f"/v0/approvals/{other_create_payload['approval']['id']}/approve", + payload={"user_id": str(other_user["user_id"])}, + ) + assert owner_approve_status == 200 + assert other_approve_status == 200 + + owner_execute_status, _ = invoke_request( + "POST", + f"/v0/approvals/{owner_create_payload['approval']['id']}/execute", + payload={"user_id": str(owner["user_id"])}, + ) + other_execute_status, other_execute_payload = invoke_request( + "POST", + f"/v0/approvals/{other_create_payload['approval']['id']}/execute", + payload={"user_id": str(other_user["user_id"])}, + ) + + assert owner_execute_status == 200 + assert other_execute_status == 200 + assert other_execute_payload["result"]["status"] == "completed" + + +def test_tool_execution_review_endpoints_are_deterministic_and_user_scoped( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_user(migrated_database_urls["app"], email="owner@example.com") + intruder = seed_user(migrated_database_urls["app"], email="intruder@example.com") + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + tool_id = create_tool_and_policy( + migrated_database_urls["app"], + user_id=owner["user_id"], + tool_key="proxy.echo", + ) + + first_status, first_payload = create_pending_approval( + user_id=owner["user_id"], + thread_id=owner["thread_id"], + tool_id=tool_id, + ) + second_status, second_payload = create_pending_approval( + user_id=owner["user_id"], + thread_id=owner["thread_id"], + tool_id=tool_id, + ) + assert first_status == 200 + assert second_status == 200 + + first_approve_status, _ = invoke_request( + "POST", + f"/v0/approvals/{first_payload['approval']['id']}/approve", + payload={"user_id": str(owner["user_id"])}, + ) + second_approve_status, _ = invoke_request( + "POST", + f"/v0/approvals/{second_payload['approval']['id']}/approve", + payload={"user_id": str(owner["user_id"])}, + ) + assert first_approve_status == 200 + assert second_approve_status == 200 + + first_execute_status, _ = invoke_request( + "POST", + f"/v0/approvals/{first_payload['approval']['id']}/execute", + payload={"user_id": str(owner["user_id"])}, + ) + second_execute_status, _ = invoke_request( + "POST", + f"/v0/approvals/{second_payload['approval']['id']}/execute", + payload={"user_id": str(owner["user_id"])}, + ) + assert first_execute_status == 200 + assert second_execute_status == 200 + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + store = ContinuityStore(conn) + stored_executions = store.list_tool_executions() + + list_status, list_payload = invoke_request( + "GET", + "/v0/tool-executions", + query_params={"user_id": str(owner["user_id"])}, + ) + detail_status, detail_payload = invoke_request( + "GET", + f"/v0/tool-executions/{stored_executions[1]['id']}", + query_params={"user_id": str(owner["user_id"])}, + ) + isolated_list_status, isolated_list_payload = invoke_request( + "GET", + "/v0/tool-executions", + query_params={"user_id": str(intruder["user_id"])}, + ) + + assert list_status == 200 + assert [item["id"] for item in list_payload["items"]] == [ + str(stored_executions[0]["id"]), + str(stored_executions[1]["id"]), + ] + assert [item["task_step_id"] for item in list_payload["items"]] == [ + str(stored_executions[0]["task_step_id"]), + str(stored_executions[1]["task_step_id"]), + ] + assert list_payload["summary"] == { + "total_count": 2, + "order": ["executed_at_asc", "id_asc"], + } + assert detail_status == 200 + assert detail_payload == { + "execution": next( + item for item in list_payload["items"] if item["id"] == str(stored_executions[1]["id"]) + ) + } + assert isolated_list_status == 200 + assert isolated_list_payload == { + "items": [], + "summary": {"total_count": 0, "order": ["executed_at_asc", "id_asc"]}, + } + + isolated_detail_status, isolated_detail_payload = invoke_request( + "GET", + f"/v0/tool-executions/{stored_executions[0]['id']}", + query_params={"user_id": str(intruder['user_id'])}, + ) + + assert isolated_detail_status == 404 + assert isolated_detail_payload == { + "detail": f"tool execution {stored_executions[0]['id']} was not found" + } diff --git a/tests/integration/test_responses_api.py b/tests/integration/test_responses_api.py new file mode 100644 index 0000000..22c4d7a --- /dev/null +++ b/tests/integration/test_responses_api.py @@ -0,0 +1,537 @@ +from __future__ import annotations + +import json +from uuid import UUID, uuid4 + +import anyio +import psycopg +import pytest + +import apps.api.src.alicebot_api.main as main_module +import alicebot_api.response_generation as response_generation_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.db import user_connection +from alicebot_api.store import ContinuityStore + + +def invoke_generate_response(payload: dict[str, object]) -> tuple[int, dict[str, object]]: + messages: list[dict[str, object]] = [] + encoded_body = json.dumps(payload).encode() + request_received = False + + async def receive() -> dict[str, object]: + nonlocal request_received + if request_received: + return {"type": "http.disconnect"} + + request_received = True + return {"type": "http.request", "body": encoded_body, "more_body": False} + + async def send(message: dict[str, object]) -> None: + messages.append(message) + + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": "POST", + "scheme": "http", + "path": "/v0/responses", + "raw_path": b"/v0/responses", + "query_string": b"", + "headers": [(b"content-type", b"application/json")], + "client": ("testclient", 50000), + "server": ("testserver", 80), + "root_path": "", + } + + anyio.run(main_module.app, scope, receive, send) + + start_message = next(message for message in messages if message["type"] == "http.response.start") + body = b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + return start_message["status"], json.loads(body) + + +def seed_response_thread( + database_url: str, + *, + email: str = "owner@example.com", + display_name: str = "Owner", + agent_profile_id: str = "assistant_default", +) -> dict[str, object]: + user_id = uuid4() + + with user_connection(database_url, user_id) as conn: + store = ContinuityStore(conn) + store.create_user(user_id, email, display_name) + thread = store.create_thread("Response thread", agent_profile_id=agent_profile_id) + session = store.create_session(thread["id"], status="active") + prior_event = store.append_event( + thread["id"], + session["id"], + "message.user", + {"text": "Remember that I like oat milk."}, + ) + memory = store.create_memory( + memory_key="user.preference.coffee", + value={"likes": "oat milk"}, + status="active", + source_event_ids=[str(prior_event["id"])], + ) + + return { + "user_id": user_id, + "thread_id": thread["id"], + "session_id": session["id"], + "prior_event_id": prior_event["id"], + "memory_id": memory["id"], + } + + +def test_generate_response_persists_user_and_assistant_events_and_trace_metadata( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_response_thread( + migrated_database_urls["app"], + agent_profile_id="coach_default", + ) + captured: dict[str, object] = {} + + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + model_provider="openai_responses", + model_name="gpt-5-mini", + model_api_key="test-key", + ), + ) + + def fake_invoke_model(*, settings, request): + captured["settings"] = settings + captured["request_payload"] = request.as_payload() + return response_generation_module.ModelInvocationResponse( + provider=request.provider, + model=request.model, + response_id="resp_123", + finish_reason="completed", + output_text="You prefer oat milk.", + usage={"input_tokens": 20, "output_tokens": 6, "total_tokens": 26}, + ) + + monkeypatch.setattr(response_generation_module, "invoke_model", fake_invoke_model) + + status_code, payload = invoke_generate_response( + { + "user_id": str(seeded["user_id"]), + "thread_id": str(seeded["thread_id"]), + "message": "What do I usually take in coffee?", + } + ) + + assert status_code == 200 + assert payload["metadata"] == {"agent_profile_id": "coach_default"} + assert payload["assistant"] == { + "event_id": payload["assistant"]["event_id"], + "sequence_no": 3, + "text": "You prefer oat milk.", + "model_provider": "openai_responses", + "model": "gpt-5", + } + assert payload["trace"]["compile_trace_event_count"] > 0 + assert payload["trace"]["response_trace_event_count"] == 2 + assert captured["request_payload"]["tool_choice"] == "none" + assert captured["request_payload"]["tools"] == [] + assert captured["request_payload"]["store"] is False + assert captured["request_payload"]["provider"] == "openai_responses" + assert captured["request_payload"]["model"] == "gpt-5" + assert captured["request_payload"]["sections"] == [ + "system", + "developer", + "context", + "conversation", + ] + + with user_connection(migrated_database_urls["app"], seeded["user_id"]) as conn: + store = ContinuityStore(conn) + events = store.list_thread_events(seeded["thread_id"]) + compile_trace = store.get_trace(UUID(payload["trace"]["compile_trace_id"])) + response_trace = store.get_trace(UUID(payload["trace"]["response_trace_id"])) + response_trace_events = store.list_trace_events(UUID(payload["trace"]["response_trace_id"])) + + assert [event["sequence_no"] for event in events] == [1, 2, 3] + assert [event["kind"] for event in events] == [ + "message.user", + "message.user", + "message.assistant", + ] + assert events[1]["payload"] == {"text": "What do I usually take in coffee?"} + assert events[2]["payload"] == { + "text": "You prefer oat milk.", + "model": { + "provider": "openai_responses", + "model": "gpt-5", + "response_id": "resp_123", + "finish_reason": "completed", + "usage": {"input_tokens": 20, "output_tokens": 6, "total_tokens": 26}, + }, + "prompt": { + "assembly_version": "prompt_assembly_v0", + "prompt_sha256": events[2]["payload"]["prompt"]["prompt_sha256"], + "section_order": ["system", "developer", "context", "conversation"], + }, + } + assert compile_trace["kind"] == "context.compile" + assert response_trace["kind"] == "response.generate" + assert response_trace["compiler_version"] == "response_generation_v0" + assert [event["kind"] for event in response_trace_events] == [ + "response.prompt.assembled", + "response.model.completed", + ] + assert response_trace_events[0]["payload"]["compile_trace_id"] == payload["trace"]["compile_trace_id"] + assert response_trace_events[1]["payload"] == { + "provider": "openai_responses", + "model": "gpt-5", + "tool_choice": "none", + "tools_enabled": False, + "response_id": "resp_123", + "finish_reason": "completed", + "output_text_char_count": len("You prefer oat milk."), + "usage": {"input_tokens": 20, "output_tokens": 6, "total_tokens": 26}, + "error_message": None, + } + + with psycopg.connect(migrated_database_urls["admin"]) as conn: + with pytest.raises(psycopg.Error, match="append-only"): + with conn.cursor() as cur: + cur.execute( + "UPDATE events SET kind = 'message.mutated' WHERE id = %s", + (UUID(payload["assistant"]["event_id"]),), + ) + + +def test_generate_response_persists_optional_cached_token_telemetry_in_event_and_trace( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_response_thread(migrated_database_urls["app"]) + + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + model_provider="openai_responses", + model_name="gpt-5-mini", + model_api_key="test-key", + ), + ) + + def fake_invoke_model(*, settings, request): + del settings + del request + return response_generation_module.ModelInvocationResponse( + provider="openai_responses", + model="gpt-5-mini", + response_id="resp_cached", + finish_reason="completed", + output_text="You prefer oat milk.", + usage={ + "input_tokens": 20, + "output_tokens": 6, + "total_tokens": 26, + "cached_input_tokens": 16, + }, + ) + + monkeypatch.setattr(response_generation_module, "invoke_model", fake_invoke_model) + + status_code, payload = invoke_generate_response( + { + "user_id": str(seeded["user_id"]), + "thread_id": str(seeded["thread_id"]), + "message": "What do I usually take in coffee?", + } + ) + + assert status_code == 200 + assert payload["metadata"] == {"agent_profile_id": "assistant_default"} + + with user_connection(migrated_database_urls["app"], seeded["user_id"]) as conn: + store = ContinuityStore(conn) + events = store.list_thread_events(seeded["thread_id"]) + response_trace_events = store.list_trace_events(UUID(payload["trace"]["response_trace_id"])) + + assert events[2]["payload"]["model"]["usage"] == { + "input_tokens": 20, + "output_tokens": 6, + "total_tokens": 26, + "cached_input_tokens": 16, + } + assert response_trace_events[1]["payload"]["usage"] == { + "input_tokens": 20, + "output_tokens": 6, + "total_tokens": 26, + "cached_input_tokens": 16, + } + + +def test_generate_response_falls_back_to_global_runtime_when_profile_runtime_is_unset( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_response_thread( + migrated_database_urls["app"], + agent_profile_id="coach_default", + ) + captured: dict[str, object] = {} + + with psycopg.connect(migrated_database_urls["admin"], autocommit=True) as conn: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE agent_profiles + SET model_provider = NULL, + model_name = NULL + WHERE id = 'coach_default' + """ + ) + + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + model_provider="openai_responses", + model_name="gpt-5-nano", + model_api_key="test-key", + ), + ) + + def fake_invoke_model(*, settings, request): + captured["settings"] = settings + captured["request_payload"] = request.as_payload() + return response_generation_module.ModelInvocationResponse( + provider=request.provider, + model=request.model, + response_id="resp_fallback", + finish_reason="completed", + output_text="Fallback response.", + usage={"input_tokens": 12, "output_tokens": 4, "total_tokens": 16}, + ) + + monkeypatch.setattr(response_generation_module, "invoke_model", fake_invoke_model) + + status_code, payload = invoke_generate_response( + { + "user_id": str(seeded["user_id"]), + "thread_id": str(seeded["thread_id"]), + "message": "Use fallback runtime config.", + } + ) + + assert status_code == 200 + assert payload["metadata"] == {"agent_profile_id": "coach_default"} + assert payload["assistant"]["model_provider"] == "openai_responses" + assert payload["assistant"]["model"] == "gpt-5-nano" + assert captured["request_payload"]["provider"] == "openai_responses" + assert captured["request_payload"]["model"] == "gpt-5-nano" + + +def test_generate_response_returns_clean_failure_without_persisting_assistant_event( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_response_thread(migrated_database_urls["app"]) + + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + model_provider="openai_responses", + model_name="gpt-5-mini", + model_api_key="test-key", + ), + ) + monkeypatch.setattr( + response_generation_module, + "invoke_model", + lambda **_kwargs: (_ for _ in ()).throw( + response_generation_module.ModelInvocationError("upstream timeout") + ), + ) + + status_code, payload = invoke_generate_response( + { + "user_id": str(seeded["user_id"]), + "thread_id": str(seeded["thread_id"]), + "message": "What do I usually take in coffee?", + } + ) + + assert status_code == 502 + assert payload["detail"] == "upstream timeout" + assert payload["metadata"] == {"agent_profile_id": "assistant_default"} + assert payload["trace"]["compile_trace_event_count"] > 0 + assert payload["trace"]["response_trace_event_count"] == 2 + + with user_connection(migrated_database_urls["app"], seeded["user_id"]) as conn: + store = ContinuityStore(conn) + events = store.list_thread_events(seeded["thread_id"]) + response_trace_events = store.list_trace_events(UUID(payload["trace"]["response_trace_id"])) + + assert [event["sequence_no"] for event in events] == [1, 2] + assert [event["kind"] for event in events] == ["message.user", "message.user"] + assert events[-1]["payload"] == {"text": "What do I usually take in coffee?"} + assert [event["kind"] for event in response_trace_events] == [ + "response.prompt.assembled", + "response.model.failed", + ] + assert response_trace_events[1]["payload"] == { + "provider": "openai_responses", + "model": "gpt-5-mini", + "tool_choice": "none", + "tools_enabled": False, + "response_id": None, + "finish_reason": "incomplete", + "output_text_char_count": 0, + "usage": {"input_tokens": None, "output_tokens": None, "total_tokens": None}, + "error_message": "upstream timeout", + } + + +def test_generate_response_respects_per_user_isolation(migrated_database_urls, monkeypatch) -> None: + owner = seed_response_thread(migrated_database_urls["app"]) + intruder = seed_response_thread( + migrated_database_urls["app"], + email="intruder@example.com", + display_name="Intruder", + ) + captured = {"invoke_model_called": False} + + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + model_provider="openai_responses", + model_name="gpt-5-mini", + model_api_key="test-key", + ), + ) + + def fake_invoke_model(**_kwargs): + captured["invoke_model_called"] = True + raise AssertionError("invoke_model should not be called for cross-user access") + + monkeypatch.setattr(response_generation_module, "invoke_model", fake_invoke_model) + + status_code, payload = invoke_generate_response( + { + "user_id": str(intruder["user_id"]), + "thread_id": str(owner["thread_id"]), + "message": "Tell me their preferences.", + } + ) + + assert status_code == 404 + assert payload == {"detail": "get_thread did not return a row from the database"} + assert captured["invoke_model_called"] is False + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + owner_events = ContinuityStore(conn).list_thread_events(owner["thread_id"]) + + assert [event["sequence_no"] for event in owner_events] == [1] + + +def test_generate_response_inherits_profile_scoped_memory_compile_behavior( + migrated_database_urls, + monkeypatch, +) -> None: + user_id = uuid4() + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + store.create_user(user_id, "profile-response@example.com", "Profile Response") + assistant_thread = store.create_thread("Assistant response thread") + coach_thread = store.create_thread("Coach response thread", agent_profile_id="coach_default") + assistant_session = store.create_session(assistant_thread["id"], status="active") + coach_session = store.create_session(coach_thread["id"], status="active") + assistant_event = store.append_event( + assistant_thread["id"], + assistant_session["id"], + "message.user", + {"text": "assistant memory evidence"}, + ) + coach_event = store.append_event( + coach_thread["id"], + coach_session["id"], + "message.user", + {"text": "coach memory evidence"}, + ) + assistant_memory = store.create_memory( + memory_key="user.preference.response.assistant_scope", + value={"likes": "americano"}, + status="active", + source_event_ids=[str(assistant_event["id"])], + ) + coach_memory = store.create_memory( + memory_key="user.preference.response.coach_scope", + value={"likes": "macchiato"}, + status="active", + source_event_ids=[str(coach_event["id"])], + agent_profile_id="coach_default", + ) + + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + model_provider="openai_responses", + model_name="gpt-5-mini", + model_api_key="test-key", + ), + ) + monkeypatch.setattr( + response_generation_module, + "invoke_model", + lambda **_kwargs: response_generation_module.ModelInvocationResponse( + provider="openai_responses", + model="gpt-5-mini", + response_id="resp_profile_scope", + finish_reason="completed", + output_text="Scoped response.", + usage={"input_tokens": 10, "output_tokens": 3, "total_tokens": 13}, + ), + ) + + status_code, payload = invoke_generate_response( + { + "user_id": str(user_id), + "thread_id": str(coach_thread["id"]), + "message": "Use my coaching profile context.", + "max_memories": 10, + } + ) + + assert status_code == 200 + assert payload["metadata"] == {"agent_profile_id": "coach_default"} + + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + compile_trace_events = store.list_trace_events(UUID(payload["trace"]["compile_trace_id"])) + + memory_decision_ids = [ + event["payload"]["entity_id"] + for event in compile_trace_events + if event["payload"].get("entity_type") == "memory" + ] + assert str(coach_memory["id"]) in memory_decision_ids + assert str(assistant_memory["id"]) not in memory_decision_ids diff --git a/tests/integration/test_retrieval_evaluation_api.py b/tests/integration/test_retrieval_evaluation_api.py new file mode 100644 index 0000000..f1749de --- /dev/null +++ b/tests/integration/test_retrieval_evaluation_api.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import json +from typing import Any +from urllib.parse import urlencode +from uuid import UUID, uuid4 + +import anyio + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.db import user_connection +from alicebot_api.store import ContinuityStore + + +def invoke_request( + method: str, + path: str, + *, + query_params: dict[str, str] | None = None, + payload: dict[str, Any] | None = None, +) -> tuple[int, dict[str, Any]]: + messages: list[dict[str, object]] = [] + encoded_body = b"" if payload is None else json.dumps(payload).encode() + request_received = False + + async def receive() -> dict[str, object]: + nonlocal request_received + if request_received: + return {"type": "http.disconnect"} + + request_received = True + return {"type": "http.request", "body": encoded_body, "more_body": False} + + async def send(message: dict[str, object]) -> None: + messages.append(message) + + query_string = urlencode(query_params or {}).encode() + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": query_string, + "headers": [(b"content-type", b"application/json")], + "client": ("testclient", 50000), + "server": ("testserver", 80), + "root_path": "", + } + + anyio.run(main_module.app, scope, receive, send) + + start_message = next(message for message in messages if message["type"] == "http.response.start") + body = b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + return start_message["status"], json.loads(body) + + +def seed_user(database_url: str, *, email: str) -> UUID: + user_id = uuid4() + with user_connection(database_url, user_id) as conn: + ContinuityStore(conn).create_user(user_id, email, email.split("@", 1)[0].title()) + return user_id + + +def test_retrieval_evaluation_api_returns_deterministic_fixture_precision_summary( + migrated_database_urls, + monkeypatch, +) -> None: + user_id = seed_user(migrated_database_urls["app"], email="retrieval-eval@example.com") + + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + status, payload = invoke_request( + "GET", + "/v0/continuity/retrieval-evaluation", + query_params={"user_id": str(user_id)}, + ) + + assert status == 200 + assert payload["summary"]["fixture_count"] == 3 + assert payload["summary"]["evaluated_fixture_count"] == 3 + assert payload["summary"]["status"] == "pass" + assert payload["summary"]["precision_at_k_mean"] >= payload["summary"]["precision_target"] + assert [fixture["fixture_id"] for fixture in payload["fixtures"]] == [ + "confirmed_fresh_truth_preferred", + "provenance_breaks_tie", + "supersession_chain_prefers_current_truth", + ] + assert payload["fixtures"][0]["top_result_ordering"]["freshness_posture"] == "fresh" diff --git a/tests/integration/test_semantic_artifact_chunk_retrieval_api.py b/tests/integration/test_semantic_artifact_chunk_retrieval_api.py new file mode 100644 index 0000000..7ee8cbd --- /dev/null +++ b/tests/integration/test_semantic_artifact_chunk_retrieval_api.py @@ -0,0 +1,569 @@ +from __future__ import annotations + +import json +from typing import Any +from urllib.parse import urlencode +from uuid import UUID, uuid4 + +import anyio +import pytest + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.db import user_connection +from alicebot_api.store import ContinuityStore + + +def invoke_request( + method: str, + path: str, + *, + query_params: dict[str, str] | None = None, + payload: dict[str, Any] | None = None, +) -> tuple[int, dict[str, Any]]: + messages: list[dict[str, object]] = [] + encoded_body = b"" if payload is None else json.dumps(payload).encode() + request_received = False + + async def receive() -> dict[str, object]: + nonlocal request_received + if request_received: + return {"type": "http.disconnect"} + + request_received = True + return {"type": "http.request", "body": encoded_body, "more_body": False} + + async def send(message: dict[str, object]) -> None: + messages.append(message) + + query_string = urlencode(query_params or {}).encode() + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": query_string, + "headers": [(b"content-type", b"application/json")], + "client": ("testclient", 50000), + "server": ("testserver", 80), + "root_path": "", + } + + anyio.run(main_module.app, scope, receive, send) + + start_message = next(message for message in messages if message["type"] == "http.response.start") + body = b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + return start_message["status"], json.loads(body) + + +def seed_task_with_workspace(database_url: str, *, email: str) -> dict[str, UUID]: + user_id = uuid4() + + with user_connection(database_url, user_id) as conn: + store = ContinuityStore(conn) + store.create_user(user_id, email, email.split("@", 1)[0].title()) + thread = store.create_thread("Semantic artifact retrieval thread") + tool = store.create_tool( + tool_key="proxy.echo", + name="Proxy Echo", + description="Deterministic proxy handler.", + version="1.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["proxy"], + action_hints=["tool.run"], + scope_hints=["workspace"], + domain_hints=[], + risk_hints=[], + metadata={"transport": "proxy"}, + ) + task = store.create_task( + thread_id=thread["id"], + tool_id=tool["id"], + status="approved", + request={ + "thread_id": str(thread["id"]), + "tool_id": str(tool["id"]), + "action": "tool.run", + "scope": "workspace", + "domain_hint": None, + "risk_hint": None, + "attributes": {}, + }, + tool={ + "id": str(tool["id"]), + "tool_key": "proxy.echo", + "name": "Proxy Echo", + "description": "Deterministic proxy handler.", + "version": "1.0.0", + "metadata_version": "tool_metadata_v0", + "active": True, + "tags": ["proxy"], + "action_hints": ["tool.run"], + "scope_hints": ["workspace"], + "domain_hints": [], + "risk_hints": [], + "metadata": {"transport": "proxy"}, + "created_at": tool["created_at"].isoformat(), + }, + latest_approval_id=None, + latest_execution_id=None, + ) + workspace = store.create_task_workspace( + task_id=task["id"], + status="active", + local_path=f"/tmp/task-workspaces/{user_id}/{task['id']}", + ) + + return { + "user_id": user_id, + "task_id": task["id"], + "task_workspace_id": workspace["id"], + } + + +def seed_embedding_config( + database_url: str, + *, + user_id: UUID, + provider: str, + model: str, + version: str, + dimensions: int, +) -> UUID: + with user_connection(database_url, user_id) as conn: + created = ContinuityStore(conn).create_embedding_config( + provider=provider, + model=model, + version=version, + dimensions=dimensions, + status="active", + metadata={"task": "semantic_artifact_chunk_retrieval"}, + ) + return created["id"] + + +def create_artifact_with_chunk_embeddings( + database_url: str, + *, + user_id: UUID, + task_id: UUID, + task_workspace_id: UUID, + embedding_config_id: UUID | None, + relative_path: str, + chunks: list[tuple[str, list[float] | None]], + ingestion_status: str = "ingested", + media_type_hint: str | None = "text/plain", +) -> dict[str, object]: + with user_connection(database_url, user_id) as conn: + store = ContinuityStore(conn) + artifact = store.create_task_artifact( + task_id=task_id, + task_workspace_id=task_workspace_id, + status="registered", + ingestion_status=ingestion_status, + relative_path=relative_path, + media_type_hint=media_type_hint, + ) + created_chunks: list[dict[str, object]] = [] + char_start = 0 + for sequence_no, (text, vector) in enumerate(chunks, start=1): + chunk = store.create_task_artifact_chunk( + task_artifact_id=artifact["id"], + sequence_no=sequence_no, + char_start=char_start, + char_end_exclusive=char_start + len(text), + text=text, + ) + char_start += len(text) + created_chunks.append(chunk) + if embedding_config_id is not None and vector is not None: + store.create_task_artifact_chunk_embedding( + task_artifact_chunk_id=chunk["id"], + embedding_config_id=embedding_config_id, + dimensions=len(vector), + vector=vector, + ) + + return { + "artifact_id": artifact["id"], + "chunk_ids": [chunk["id"] for chunk in created_chunks], + } + + +def test_semantic_artifact_chunk_retrieval_endpoints_return_deterministic_task_and_artifact_results( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_task_with_workspace(migrated_database_urls["app"], email="owner@example.com") + config_id = seed_embedding_config( + migrated_database_urls["app"], + user_id=owner["user_id"], + provider="openai", + model="text-embedding-3-large", + version="2026-03-15", + dimensions=3, + ) + docs = create_artifact_with_chunk_embeddings( + migrated_database_urls["app"], + user_id=owner["user_id"], + task_id=owner["task_id"], + task_workspace_id=owner["task_workspace_id"], + embedding_config_id=config_id, + relative_path="docs/a.txt", + chunks=[("alpha doc", [1.0, 0.0, 0.0])], + media_type_hint="text/plain", + ) + notes = create_artifact_with_chunk_embeddings( + migrated_database_urls["app"], + user_id=owner["user_id"], + task_id=owner["task_id"], + task_workspace_id=owner["task_workspace_id"], + embedding_config_id=config_id, + relative_path="notes/b.md", + chunks=[("alpha note", [1.0, 0.0, 0.0])], + media_type_hint="text/markdown", + ) + weak = create_artifact_with_chunk_embeddings( + migrated_database_urls["app"], + user_id=owner["user_id"], + task_id=owner["task_id"], + task_workspace_id=owner["task_workspace_id"], + embedding_config_id=config_id, + relative_path="notes/c.txt", + chunks=[("beta weak", [0.0, 1.0, 0.0])], + media_type_hint="text/plain", + ) + pending = create_artifact_with_chunk_embeddings( + migrated_database_urls["app"], + user_id=owner["user_id"], + task_id=owner["task_id"], + task_workspace_id=owner["task_workspace_id"], + embedding_config_id=config_id, + relative_path="notes/pending.txt", + chunks=[("hidden pending", [1.0, 0.0, 0.0])], + ingestion_status="pending", + media_type_hint="text/plain", + ) + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + task_status, task_payload = invoke_request( + "POST", + f"/v0/tasks/{owner['task_id']}/artifact-chunks/semantic-retrieval", + payload={ + "user_id": str(owner["user_id"]), + "embedding_config_id": str(config_id), + "query_vector": [1.0, 0.0, 0.0], + "limit": 10, + }, + ) + artifact_status, artifact_payload = invoke_request( + "POST", + f"/v0/task-artifacts/{notes['artifact_id']}/chunks/semantic-retrieval", + payload={ + "user_id": str(owner["user_id"]), + "embedding_config_id": str(config_id), + "query_vector": [1.0, 0.0, 0.0], + "limit": 10, + }, + ) + + assert task_status == 200 + assert task_payload["summary"] == { + "embedding_config_id": str(config_id), + "query_vector_dimensions": 3, + "limit": 10, + "returned_count": 3, + "searched_artifact_count": 3, + "similarity_metric": "cosine_similarity", + "order": ["score_desc", "relative_path_asc", "sequence_no_asc", "id_asc"], + "scope": {"kind": "task", "task_id": str(owner["task_id"])}, + } + assert [item["id"] for item in task_payload["items"]] == [ + str(docs["chunk_ids"][0]), + str(notes["chunk_ids"][0]), + str(weak["chunk_ids"][0]), + ] + assert str(pending["chunk_ids"][0]) not in {item["id"] for item in task_payload["items"]} + assert task_payload["items"][0]["relative_path"] == "docs/a.txt" + assert task_payload["items"][1]["relative_path"] == "notes/b.md" + assert task_payload["items"][0]["score"] == pytest.approx(1.0) + assert task_payload["items"][1]["score"] == pytest.approx(1.0) + assert task_payload["items"][2]["score"] == pytest.approx(0.0) + assert set(task_payload["items"][0]) == { + "id", + "task_id", + "task_artifact_id", + "relative_path", + "media_type", + "sequence_no", + "char_start", + "char_end_exclusive", + "text", + "score", + } + + assert artifact_status == 200 + assert artifact_payload["summary"] == { + "embedding_config_id": str(config_id), + "query_vector_dimensions": 3, + "limit": 10, + "returned_count": 1, + "searched_artifact_count": 1, + "similarity_metric": "cosine_similarity", + "order": ["score_desc", "relative_path_asc", "sequence_no_asc", "id_asc"], + "scope": { + "kind": "artifact", + "task_id": str(owner["task_id"]), + "task_artifact_id": str(notes["artifact_id"]), + }, + } + assert artifact_payload["items"] == [ + { + "id": str(notes["chunk_ids"][0]), + "task_id": str(owner["task_id"]), + "task_artifact_id": str(notes["artifact_id"]), + "relative_path": "notes/b.md", + "media_type": "text/markdown", + "sequence_no": 1, + "char_start": 0, + "char_end_exclusive": len("alpha note"), + "text": "alpha note", + "score": artifact_payload["items"][0]["score"], + } + ] + assert artifact_payload["items"][0]["score"] == pytest.approx(1.0) + + +def test_semantic_artifact_chunk_retrieval_rejects_invalid_config_dimension_mismatch_and_cross_user_scope( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_task_with_workspace(migrated_database_urls["app"], email="owner@example.com") + intruder = seed_task_with_workspace(migrated_database_urls["app"], email="intruder@example.com") + owner_config_id = seed_embedding_config( + migrated_database_urls["app"], + user_id=owner["user_id"], + provider="openai", + model="text-embedding-3-large", + version="2026-03-15", + dimensions=3, + ) + intruder_config_id = seed_embedding_config( + migrated_database_urls["app"], + user_id=intruder["user_id"], + provider="openai", + model="text-embedding-3-large", + version="2026-03-15", + dimensions=3, + ) + owner_artifact = create_artifact_with_chunk_embeddings( + migrated_database_urls["app"], + user_id=owner["user_id"], + task_id=owner["task_id"], + task_workspace_id=owner["task_workspace_id"], + embedding_config_id=owner_config_id, + relative_path="docs/spec.txt", + chunks=[("owner chunk", [1.0, 0.0, 0.0])], + ) + create_artifact_with_chunk_embeddings( + migrated_database_urls["app"], + user_id=intruder["user_id"], + task_id=intruder["task_id"], + task_workspace_id=intruder["task_workspace_id"], + embedding_config_id=intruder_config_id, + relative_path="docs/intruder.txt", + chunks=[("intruder chunk", [1.0, 0.0, 0.0])], + ) + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + missing_status, missing_payload = invoke_request( + "POST", + f"/v0/tasks/{owner['task_id']}/artifact-chunks/semantic-retrieval", + payload={ + "user_id": str(owner["user_id"]), + "embedding_config_id": str(uuid4()), + "query_vector": [1.0, 0.0, 0.0], + "limit": 5, + }, + ) + mismatch_status, mismatch_payload = invoke_request( + "POST", + f"/v0/tasks/{owner['task_id']}/artifact-chunks/semantic-retrieval", + payload={ + "user_id": str(owner["user_id"]), + "embedding_config_id": str(owner_config_id), + "query_vector": [1.0, 0.0], + "limit": 5, + }, + ) + cross_user_task_status, cross_user_task_payload = invoke_request( + "POST", + f"/v0/tasks/{owner['task_id']}/artifact-chunks/semantic-retrieval", + payload={ + "user_id": str(intruder["user_id"]), + "embedding_config_id": str(intruder_config_id), + "query_vector": [1.0, 0.0, 0.0], + "limit": 5, + }, + ) + cross_user_artifact_status, cross_user_artifact_payload = invoke_request( + "POST", + f"/v0/task-artifacts/{owner_artifact['artifact_id']}/chunks/semantic-retrieval", + payload={ + "user_id": str(intruder["user_id"]), + "embedding_config_id": str(intruder_config_id), + "query_vector": [1.0, 0.0, 0.0], + "limit": 5, + }, + ) + cross_user_config_status, cross_user_config_payload = invoke_request( + "POST", + f"/v0/tasks/{owner['task_id']}/artifact-chunks/semantic-retrieval", + payload={ + "user_id": str(owner["user_id"]), + "embedding_config_id": str(intruder_config_id), + "query_vector": [1.0, 0.0, 0.0], + "limit": 5, + }, + ) + + assert missing_status == 400 + assert missing_payload["detail"].startswith( + "embedding_config_id must reference an existing embedding config owned by the user" + ) + assert mismatch_status == 400 + assert mismatch_payload["detail"] == "query_vector length must match embedding config dimensions (3): 2" + assert cross_user_task_status == 404 + assert cross_user_task_payload == {"detail": f"task {owner['task_id']} was not found"} + assert cross_user_artifact_status == 404 + assert cross_user_artifact_payload == { + "detail": f"task artifact {owner_artifact['artifact_id']} was not found" + } + assert cross_user_config_status == 400 + assert cross_user_config_payload["detail"] == ( + "embedding_config_id must reference an existing embedding config owned by the user: " + f"{intruder_config_id}" + ) + + +def test_semantic_artifact_chunk_retrieval_supports_empty_results_and_per_user_isolation( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_task_with_workspace(migrated_database_urls["app"], email="owner@example.com") + intruder = seed_task_with_workspace(migrated_database_urls["app"], email="intruder@example.com") + owner_config_id = seed_embedding_config( + migrated_database_urls["app"], + user_id=owner["user_id"], + provider="openai", + model="text-embedding-3-large", + version="2026-03-15", + dimensions=3, + ) + owner_empty_config_id = seed_embedding_config( + migrated_database_urls["app"], + user_id=owner["user_id"], + provider="openai", + model="text-embedding-3-small", + version="2026-03-15", + dimensions=3, + ) + intruder_config_id = seed_embedding_config( + migrated_database_urls["app"], + user_id=intruder["user_id"], + provider="openai", + model="text-embedding-3-large", + version="2026-03-15", + dimensions=3, + ) + owner_artifact = create_artifact_with_chunk_embeddings( + migrated_database_urls["app"], + user_id=owner["user_id"], + task_id=owner["task_id"], + task_workspace_id=owner["task_workspace_id"], + embedding_config_id=owner_config_id, + relative_path="docs/owner.txt", + chunks=[("owner semantic", [1.0, 0.0, 0.0])], + ) + intruder_artifact = create_artifact_with_chunk_embeddings( + migrated_database_urls["app"], + user_id=intruder["user_id"], + task_id=intruder["task_id"], + task_workspace_id=intruder["task_workspace_id"], + embedding_config_id=intruder_config_id, + relative_path="docs/intruder.txt", + chunks=[("intruder semantic", [1.0, 0.0, 0.0])], + ) + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + owner_status, owner_payload = invoke_request( + "POST", + f"/v0/tasks/{owner['task_id']}/artifact-chunks/semantic-retrieval", + payload={ + "user_id": str(owner["user_id"]), + "embedding_config_id": str(owner_config_id), + "query_vector": [1.0, 0.0, 0.0], + "limit": 5, + }, + ) + intruder_status, intruder_payload = invoke_request( + "POST", + f"/v0/tasks/{intruder['task_id']}/artifact-chunks/semantic-retrieval", + payload={ + "user_id": str(intruder["user_id"]), + "embedding_config_id": str(intruder_config_id), + "query_vector": [1.0, 0.0, 0.0], + "limit": 5, + }, + ) + empty_status, empty_payload = invoke_request( + "POST", + f"/v0/tasks/{owner['task_id']}/artifact-chunks/semantic-retrieval", + payload={ + "user_id": str(owner["user_id"]), + "embedding_config_id": str(owner_empty_config_id), + "query_vector": [1.0, 0.0, 0.0], + "limit": 5, + }, + ) + + assert owner_status == 200 + assert [item["id"] for item in owner_payload["items"]] == [str(owner_artifact["chunk_ids"][0])] + assert intruder_status == 200 + assert [item["id"] for item in intruder_payload["items"]] == [ + str(intruder_artifact["chunk_ids"][0]) + ] + assert empty_status == 200 + assert empty_payload == { + "items": [], + "summary": { + "embedding_config_id": str(owner_empty_config_id), + "query_vector_dimensions": 3, + "limit": 5, + "returned_count": 0, + "searched_artifact_count": 1, + "similarity_metric": "cosine_similarity", + "order": ["score_desc", "relative_path_asc", "sequence_no_asc", "id_asc"], + "scope": {"kind": "task", "task_id": str(owner["task_id"])}, + }, + } diff --git a/tests/integration/test_task_artifact_chunk_embeddings_api.py b/tests/integration/test_task_artifact_chunk_embeddings_api.py new file mode 100644 index 0000000..97559cf --- /dev/null +++ b/tests/integration/test_task_artifact_chunk_embeddings_api.py @@ -0,0 +1,474 @@ +from __future__ import annotations + +import json +from typing import Any +from urllib.parse import urlencode +from uuid import UUID, uuid4 + +import anyio + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.db import user_connection +from alicebot_api.store import ContinuityStore + + +def invoke_request( + method: str, + path: str, + *, + query_params: dict[str, str] | None = None, + payload: dict[str, Any] | None = None, +) -> tuple[int, dict[str, Any]]: + messages: list[dict[str, object]] = [] + encoded_body = b"" if payload is None else json.dumps(payload).encode() + request_received = False + + async def receive() -> dict[str, object]: + nonlocal request_received + if request_received: + return {"type": "http.disconnect"} + + request_received = True + return {"type": "http.request", "body": encoded_body, "more_body": False} + + async def send(message: dict[str, object]) -> None: + messages.append(message) + + query_string = urlencode(query_params or {}).encode() + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": query_string, + "headers": [(b"content-type", b"application/json")], + "client": ("testclient", 50000), + "server": ("testserver", 80), + "root_path": "", + } + + anyio.run(main_module.app, scope, receive, send) + + start_message = next(message for message in messages if message["type"] == "http.response.start") + body = b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + return start_message["status"], json.loads(body) + + +def seed_task_artifact_with_chunks(database_url: str, *, email: str) -> dict[str, UUID]: + user_id = uuid4() + + with user_connection(database_url, user_id) as conn: + store = ContinuityStore(conn) + store.create_user(user_id, email, email.split("@", 1)[0].title()) + thread = store.create_thread("Artifact chunk embedding thread") + tool = store.create_tool( + tool_key="proxy.echo", + name="Proxy Echo", + description="Deterministic proxy handler.", + version="1.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["proxy"], + action_hints=["tool.run"], + scope_hints=["workspace"], + domain_hints=[], + risk_hints=[], + metadata={"transport": "proxy"}, + ) + task = store.create_task( + thread_id=thread["id"], + tool_id=tool["id"], + status="approved", + request={ + "thread_id": str(thread["id"]), + "tool_id": str(tool["id"]), + "action": "tool.run", + "scope": "workspace", + "domain_hint": None, + "risk_hint": None, + "attributes": {}, + }, + tool={ + "id": str(tool["id"]), + "tool_key": "proxy.echo", + "name": "Proxy Echo", + "description": "Deterministic proxy handler.", + "version": "1.0.0", + "metadata_version": "tool_metadata_v0", + "active": True, + "tags": ["proxy"], + "action_hints": ["tool.run"], + "scope_hints": ["workspace"], + "domain_hints": [], + "risk_hints": [], + "metadata": {"transport": "proxy"}, + "created_at": tool["created_at"].isoformat(), + }, + latest_approval_id=None, + latest_execution_id=None, + ) + workspace = store.create_task_workspace( + task_id=task["id"], + status="active", + local_path=f"/tmp/task-workspaces/{user_id}/{task['id']}", + ) + artifact = store.create_task_artifact( + task_id=task["id"], + task_workspace_id=workspace["id"], + status="registered", + ingestion_status="ingested", + relative_path="docs/spec.txt", + media_type_hint="text/plain", + ) + first_chunk = store.create_task_artifact_chunk( + task_artifact_id=artifact["id"], + sequence_no=1, + char_start=0, + char_end_exclusive=12, + text="alpha chunk", + ) + second_chunk = store.create_task_artifact_chunk( + task_artifact_id=artifact["id"], + sequence_no=2, + char_start=12, + char_end_exclusive=24, + text="beta chunk", + ) + + return { + "user_id": user_id, + "task_id": task["id"], + "task_artifact_id": artifact["id"], + "first_chunk_id": first_chunk["id"], + "second_chunk_id": second_chunk["id"], + } + + +def seed_embedding_config( + database_url: str, + *, + user_id: UUID, + provider: str, + model: str, + version: str, + dimensions: int, +) -> UUID: + with user_connection(database_url, user_id) as conn: + created = ContinuityStore(conn).create_embedding_config( + provider=provider, + model=model, + version=version, + dimensions=dimensions, + status="active", + metadata={"task": "artifact_chunk_retrieval"}, + ) + return created["id"] + + +def test_task_artifact_chunk_embedding_endpoints_persist_and_read_embeddings( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_task_artifact_with_chunks( + migrated_database_urls["app"], + email="owner@example.com", + ) + first_config_id = seed_embedding_config( + migrated_database_urls["app"], + user_id=seeded["user_id"], + provider="openai", + model="text-embedding-3-small", + version="2026-03-14", + dimensions=3, + ) + second_config_id = seed_embedding_config( + migrated_database_urls["app"], + user_id=seeded["user_id"], + provider="openai", + model="text-embedding-3-large", + version="2026-03-15", + dimensions=3, + ) + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + second_write_status, second_write_payload = invoke_request( + "POST", + "/v0/task-artifact-chunk-embeddings", + payload={ + "user_id": str(seeded["user_id"]), + "task_artifact_chunk_id": str(seeded["second_chunk_id"]), + "embedding_config_id": str(first_config_id), + "vector": [0.4, 0.5, 0.6], + }, + ) + first_write_status, first_write_payload = invoke_request( + "POST", + "/v0/task-artifact-chunk-embeddings", + payload={ + "user_id": str(seeded["user_id"]), + "task_artifact_chunk_id": str(seeded["first_chunk_id"]), + "embedding_config_id": str(second_config_id), + "vector": [0.1, 0.2, 0.3], + }, + ) + update_status, update_payload = invoke_request( + "POST", + "/v0/task-artifact-chunk-embeddings", + payload={ + "user_id": str(seeded["user_id"]), + "task_artifact_chunk_id": str(seeded["second_chunk_id"]), + "embedding_config_id": str(first_config_id), + "vector": [0.9, 0.8, 0.7], + }, + ) + artifact_list_status, artifact_list_payload = invoke_request( + "GET", + f"/v0/task-artifacts/{seeded['task_artifact_id']}/chunk-embeddings", + query_params={"user_id": str(seeded["user_id"])}, + ) + chunk_list_status, chunk_list_payload = invoke_request( + "GET", + f"/v0/task-artifact-chunks/{seeded['second_chunk_id']}/embeddings", + query_params={"user_id": str(seeded["user_id"])}, + ) + detail_status, detail_payload = invoke_request( + "GET", + f"/v0/task-artifact-chunk-embeddings/{update_payload['embedding']['id']}", + query_params={"user_id": str(seeded["user_id"])}, + ) + + assert second_write_status == 201 + assert second_write_payload["write_mode"] == "created" + assert first_write_status == 201 + assert first_write_payload["write_mode"] == "created" + assert update_status == 201 + assert update_payload["write_mode"] == "updated" + assert update_payload["embedding"]["vector"] == [0.9, 0.8, 0.7] + assert artifact_list_status == 200 + assert artifact_list_payload["summary"] == { + "total_count": 2, + "order": ["task_artifact_chunk_sequence_no_asc", "created_at_asc", "id_asc"], + "scope": { + "kind": "artifact", + "task_artifact_id": str(seeded["task_artifact_id"]), + }, + } + assert chunk_list_status == 200 + assert chunk_list_payload["summary"] == { + "total_count": 1, + "order": ["task_artifact_chunk_sequence_no_asc", "created_at_asc", "id_asc"], + "scope": { + "kind": "chunk", + "task_artifact_id": str(seeded["task_artifact_id"]), + "task_artifact_chunk_id": str(seeded["second_chunk_id"]), + }, + } + assert detail_status == 200 + assert detail_payload["embedding"]["id"] == update_payload["embedding"]["id"] + assert detail_payload["embedding"]["task_artifact_chunk_sequence_no"] == 2 + assert set(detail_payload["embedding"]) == { + "id", + "task_artifact_id", + "task_artifact_chunk_id", + "task_artifact_chunk_sequence_no", + "embedding_config_id", + "dimensions", + "vector", + "created_at", + "updated_at", + } + + with user_connection(migrated_database_urls["app"], seeded["user_id"]) as conn: + stored = ContinuityStore(conn).list_task_artifact_chunk_embeddings_for_artifact( + seeded["task_artifact_id"] + ) + + assert [item["id"] for item in artifact_list_payload["items"]] == [ + str(embedding["id"]) for embedding in stored + ] + assert [item["task_artifact_chunk_id"] for item in artifact_list_payload["items"]] == [ + str(seeded["first_chunk_id"]), + str(seeded["second_chunk_id"]), + ] + + +def test_task_artifact_chunk_embedding_writes_reject_invalid_refs_dimension_mismatches_and_cross_user_refs( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_task_artifact_with_chunks(migrated_database_urls["app"], email="owner@example.com") + intruder = seed_task_artifact_with_chunks( + migrated_database_urls["app"], + email="intruder@example.com", + ) + owner_config_id = seed_embedding_config( + migrated_database_urls["app"], + user_id=owner["user_id"], + provider="openai", + model="text-embedding-3-large", + version="2026-03-14", + dimensions=3, + ) + intruder_config_id = seed_embedding_config( + migrated_database_urls["app"], + user_id=intruder["user_id"], + provider="openai", + model="text-embedding-3-large", + version="2026-03-14", + dimensions=3, + ) + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + missing_config_status, missing_config_payload = invoke_request( + "POST", + "/v0/task-artifact-chunk-embeddings", + payload={ + "user_id": str(owner["user_id"]), + "task_artifact_chunk_id": str(owner["first_chunk_id"]), + "embedding_config_id": str(uuid4()), + "vector": [0.1, 0.2, 0.3], + }, + ) + missing_chunk_status, missing_chunk_payload = invoke_request( + "POST", + "/v0/task-artifact-chunk-embeddings", + payload={ + "user_id": str(owner["user_id"]), + "task_artifact_chunk_id": str(uuid4()), + "embedding_config_id": str(owner_config_id), + "vector": [0.1, 0.2, 0.3], + }, + ) + mismatch_status, mismatch_payload = invoke_request( + "POST", + "/v0/task-artifact-chunk-embeddings", + payload={ + "user_id": str(owner["user_id"]), + "task_artifact_chunk_id": str(owner["first_chunk_id"]), + "embedding_config_id": str(owner_config_id), + "vector": [0.1, 0.2], + }, + ) + cross_user_chunk_status, cross_user_chunk_payload = invoke_request( + "POST", + "/v0/task-artifact-chunk-embeddings", + payload={ + "user_id": str(intruder["user_id"]), + "task_artifact_chunk_id": str(owner["first_chunk_id"]), + "embedding_config_id": str(intruder_config_id), + "vector": [0.1, 0.2, 0.3], + }, + ) + cross_user_config_status, cross_user_config_payload = invoke_request( + "POST", + "/v0/task-artifact-chunk-embeddings", + payload={ + "user_id": str(intruder["user_id"]), + "task_artifact_chunk_id": str(intruder["first_chunk_id"]), + "embedding_config_id": str(owner_config_id), + "vector": [0.1, 0.2, 0.3], + }, + ) + + assert missing_config_status == 400 + assert missing_config_payload["detail"].startswith( + "embedding_config_id must reference an existing embedding config owned by the user" + ) + assert missing_chunk_status == 400 + assert missing_chunk_payload["detail"].startswith( + "task_artifact_chunk_id must reference an existing task artifact chunk owned by the user" + ) + assert mismatch_status == 400 + assert mismatch_payload["detail"] == "vector length must match embedding config dimensions (3): 2" + assert cross_user_chunk_status == 400 + assert cross_user_chunk_payload["detail"] == ( + "task_artifact_chunk_id must reference an existing task artifact chunk owned by the " + f"user: {owner['first_chunk_id']}" + ) + assert cross_user_config_status == 400 + assert cross_user_config_payload["detail"] == ( + "embedding_config_id must reference an existing embedding config owned by the user: " + f"{owner_config_id}" + ) + + +def test_task_artifact_chunk_embedding_reads_respect_per_user_isolation( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_task_artifact_with_chunks(migrated_database_urls["app"], email="owner@example.com") + intruder = seed_task_artifact_with_chunks( + migrated_database_urls["app"], + email="intruder@example.com", + ) + owner_config_id = seed_embedding_config( + migrated_database_urls["app"], + user_id=owner["user_id"], + provider="openai", + model="text-embedding-3-large", + version="2026-03-14", + dimensions=3, + ) + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + write_status, write_payload = invoke_request( + "POST", + "/v0/task-artifact-chunk-embeddings", + payload={ + "user_id": str(owner["user_id"]), + "task_artifact_chunk_id": str(owner["first_chunk_id"]), + "embedding_config_id": str(owner_config_id), + "vector": [0.1, 0.2, 0.3], + }, + ) + artifact_list_status, artifact_list_payload = invoke_request( + "GET", + f"/v0/task-artifacts/{owner['task_artifact_id']}/chunk-embeddings", + query_params={"user_id": str(intruder["user_id"])}, + ) + chunk_list_status, chunk_list_payload = invoke_request( + "GET", + f"/v0/task-artifact-chunks/{owner['first_chunk_id']}/embeddings", + query_params={"user_id": str(intruder["user_id"])}, + ) + detail_status, detail_payload = invoke_request( + "GET", + f"/v0/task-artifact-chunk-embeddings/{write_payload['embedding']['id']}", + query_params={"user_id": str(intruder["user_id"])}, + ) + + assert write_status == 201 + assert artifact_list_status == 404 + assert artifact_list_payload == { + "detail": f"task artifact {owner['task_artifact_id']} was not found" + } + assert chunk_list_status == 404 + assert chunk_list_payload == { + "detail": f"task artifact chunk {owner['first_chunk_id']} was not found" + } + assert detail_status == 404 + assert detail_payload == { + "detail": ( + f"task artifact chunk embedding {write_payload['embedding']['id']} was not found" + ) + } diff --git a/tests/integration/test_task_artifacts_api.py b/tests/integration/test_task_artifacts_api.py new file mode 100644 index 0000000..2c61dd3 --- /dev/null +++ b/tests/integration/test_task_artifacts_api.py @@ -0,0 +1,2066 @@ +from __future__ import annotations + +import io +import json +import zlib +from pathlib import Path +from typing import Any +from urllib.parse import urlencode +from uuid import UUID, uuid4 +from xml.sax.saxutils import escape +import zipfile + +import anyio +import psycopg + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.artifacts import TASK_ARTIFACT_CHUNK_RETRIEVAL_MATCHING_RULE +from alicebot_api.db import user_connection +from alicebot_api.store import ContinuityStore + + +def _escape_pdf_literal_string(value: str) -> str: + return value.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)") + + +def _build_pdf_bytes( + pages: list[list[str]], + *, + compress_streams: bool = True, + textless: bool = False, +) -> bytes: + objects: dict[int, bytes] = { + 1: b"<< /Type /Catalog /Pages 2 0 R >>", + 3: b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + } + page_refs: list[str] = [] + next_object_id = 4 + for page_lines in pages: + page_object_id = next_object_id + content_object_id = next_object_id + 1 + next_object_id += 2 + page_refs.append(f"{page_object_id} 0 R") + + if textless: + content_stream = b"q 10 10 100 100 re S Q\n" + else: + commands = [b"BT", b"/F1 12 Tf", b"72 720 Td"] + for index, line in enumerate(page_lines): + if index > 0: + commands.append(b"T*") + commands.append(f"({_escape_pdf_literal_string(line)}) Tj".encode("latin-1")) + commands.append(b"ET") + content_stream = b"\n".join(commands) + b"\n" + + if compress_streams: + encoded_stream = zlib.compress(content_stream) + content_body = ( + f"<< /Length {len(encoded_stream)} /Filter /FlateDecode >>\n".encode("ascii") + + b"stream\n" + + encoded_stream + + b"\nendstream" + ) + else: + content_body = ( + f"<< /Length {len(content_stream)} >>\n".encode("ascii") + + b"stream\n" + + content_stream + + b"endstream" + ) + + objects[page_object_id] = ( + f"<< /Type /Page /Parent 2 0 R /Resources << /Font << /F1 3 0 R >> >> " + f"/MediaBox [0 0 612 792] /Contents {content_object_id} 0 R >>" + ).encode("ascii") + objects[content_object_id] = content_body + + objects[2] = ( + f"<< /Type /Pages /Count {len(page_refs)} /Kids [{' '.join(page_refs)}] >>" + ).encode("ascii") + + document = bytearray(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n") + max_object_id = max(objects) + offsets = [0] * (max_object_id + 1) + for object_id in range(1, max_object_id + 1): + offsets[object_id] = len(document) + document.extend(f"{object_id} 0 obj\n".encode("ascii")) + document.extend(objects[object_id]) + document.extend(b"\nendobj\n") + + xref_offset = len(document) + document.extend(f"xref\n0 {max_object_id + 1}\n".encode("ascii")) + document.extend(b"0000000000 65535 f \n") + for object_id in range(1, max_object_id + 1): + document.extend(f"{offsets[object_id]:010d} 00000 n \n".encode("ascii")) + document.extend( + ( + f"trailer\n<< /Size {max_object_id + 1} /Root 1 0 R >>\n" + f"startxref\n{xref_offset}\n%%EOF\n" + ).encode("ascii") + ) + return bytes(document) + + +def _build_docx_bytes( + paragraphs: list[str], + *, + include_document_xml: bool = True, + malformed_document_xml: bool = False, +) -> bytes: + document_xml = ( + b"<w:document" + if malformed_document_xml + else ( + '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' + '<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">' + "<w:body>" + + "".join( + ( + "<w:p><w:r><w:t xml:space=\"preserve\">" + f"{escape(paragraph)}" + "</w:t></w:r></w:p>" + ) + for paragraph in paragraphs + ) + + ( + "<w:sectPr>" + "<w:pgSz w:w=\"12240\" w:h=\"15840\"/>" + "<w:pgMar w:top=\"1440\" w:right=\"1440\" w:bottom=\"1440\" w:left=\"1440\" " + "w:header=\"708\" w:footer=\"708\" w:gutter=\"0\"/>" + "</w:sectPr>" + "</w:body>" + "</w:document>" + ) + ) + ) + + archive_buffer = io.BytesIO() + with zipfile.ZipFile(archive_buffer, "w", compression=zipfile.ZIP_STORED) as archive: + entries = { + "[Content_Types].xml": ( + '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' + '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' + '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' + '<Default Extension="xml" ContentType="application/xml"/>' + '<Override PartName="/word/document.xml" ' + 'ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>' + "</Types>" + ).encode("utf-8"), + "_rels/.rels": ( + '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' + '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' + '<Relationship Id="rId1" ' + 'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" ' + 'Target="word/document.xml"/>' + "</Relationships>" + ).encode("utf-8"), + } + if include_document_xml: + entries["word/document.xml"] = document_xml + + for name, payload in entries.items(): + info = zipfile.ZipInfo(filename=name) + info.date_time = (2026, 3, 13, 10, 0, 0) + info.compress_type = zipfile.ZIP_STORED + archive.writestr(info, payload) + + return archive_buffer.getvalue() + + +def _build_rfc822_email_bytes( + *, + headers: list[tuple[str, str]] | None = None, + plain_body: str | None = None, + plain_parts: list[str] | None = None, + html_body: str | None = None, + attachment_text: str | None = None, + nested_message_bytes: bytes | None = None, + malformed_multipart: bool = False, +) -> bytes: + header_lines = [ + f"{name}: {value}" + for name, value in ( + headers + if headers is not None + else [ + ("From", "Alice <alice@example.com>"), + ("To", "Bob <bob@example.com>"), + ("Subject", "Sprint Update"), + ] + ) + ] + if malformed_multipart: + return ( + "\r\n".join( + [ + *header_lines, + "MIME-Version: 1.0", + "Content-Type: multipart/mixed", + "", + "--broken-boundary", + 'Content-Type: text/plain; charset="utf-8"', + "", + "broken", + "--broken-boundary--", + "", + ] + ).encode("utf-8") + ) + + if ( + plain_parts is None + and html_body is None + and attachment_text is None + and nested_message_bytes is None + ): + return ( + "\r\n".join( + [ + *header_lines, + 'Content-Type: text/plain; charset="utf-8"', + "Content-Transfer-Encoding: 8bit", + "", + plain_body or "", + ] + ).encode("utf-8") + ) + + boundary = "alicebot-boundary-001" + lines = [ + *header_lines, + "MIME-Version: 1.0", + f'Content-Type: multipart/mixed; boundary="{boundary}"', + "", + ] + for part_text in plain_parts or []: + lines.extend( + [ + f"--{boundary}", + 'Content-Type: text/plain; charset="utf-8"', + "Content-Transfer-Encoding: 8bit", + "", + part_text, + ] + ) + if html_body is not None: + lines.extend( + [ + f"--{boundary}", + 'Content-Type: text/html; charset="utf-8"', + "Content-Transfer-Encoding: 8bit", + "", + html_body, + ] + ) + if attachment_text is not None: + lines.extend( + [ + f"--{boundary}", + 'Content-Type: text/plain; charset="utf-8"', + 'Content-Disposition: attachment; filename="note.txt"', + "Content-Transfer-Encoding: 8bit", + "", + attachment_text, + ] + ) + if nested_message_bytes is not None: + lines.extend( + [ + f"--{boundary}", + "Content-Type: message/rfc822", + "Content-Transfer-Encoding: 8bit", + "", + nested_message_bytes.decode("utf-8"), + ] + ) + lines.extend([f"--{boundary}--", ""]) + return "\r\n".join(lines).encode("utf-8") + + +def invoke_request( + method: str, + path: str, + *, + query_params: dict[str, str] | None = None, + payload: dict[str, Any] | None = None, +) -> tuple[int, dict[str, Any]]: + messages: list[dict[str, object]] = [] + encoded_body = b"" if payload is None else json.dumps(payload).encode() + request_received = False + + async def receive() -> dict[str, object]: + nonlocal request_received + if request_received: + return {"type": "http.disconnect"} + + request_received = True + return {"type": "http.request", "body": encoded_body, "more_body": False} + + async def send(message: dict[str, object]) -> None: + messages.append(message) + + query_string = urlencode(query_params or {}).encode() + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": query_string, + "headers": [(b"content-type", b"application/json")], + "client": ("testclient", 50000), + "server": ("testserver", 80), + "root_path": "", + } + + anyio.run(main_module.app, scope, receive, send) + + start_message = next(message for message in messages if message["type"] == "http.response.start") + body = b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + return start_message["status"], json.loads(body) + + +def seed_task(database_url: str, *, email: str) -> dict[str, UUID]: + user_id = uuid4() + + with user_connection(database_url, user_id) as conn: + store = ContinuityStore(conn) + store.create_user(user_id, email, email.split("@", 1)[0].title()) + thread = store.create_thread("Artifact thread") + tool = store.create_tool( + tool_key="proxy.echo", + name="Proxy Echo", + description="Deterministic proxy handler.", + version="1.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["proxy"], + action_hints=["tool.run"], + scope_hints=["workspace"], + domain_hints=[], + risk_hints=[], + metadata={"transport": "proxy"}, + ) + task = store.create_task( + thread_id=thread["id"], + tool_id=tool["id"], + status="approved", + request={ + "thread_id": str(thread["id"]), + "tool_id": str(tool["id"]), + "action": "tool.run", + "scope": "workspace", + "domain_hint": None, + "risk_hint": None, + "attributes": {}, + }, + tool={ + "id": str(tool["id"]), + "tool_key": "proxy.echo", + "name": "Proxy Echo", + "description": "Deterministic proxy handler.", + "version": "1.0.0", + "metadata_version": "tool_metadata_v0", + "active": True, + "tags": ["proxy"], + "action_hints": ["tool.run"], + "scope_hints": ["workspace"], + "domain_hints": [], + "risk_hints": [], + "metadata": {"transport": "proxy"}, + "created_at": tool["created_at"].isoformat(), + }, + latest_approval_id=None, + latest_execution_id=None, + ) + + return { + "user_id": user_id, + "task_id": task["id"], + } + + +def test_task_artifact_endpoints_register_list_detail_isolate_and_reject_duplicates( + migrated_database_urls, + monkeypatch, + tmp_path, +) -> None: + owner = seed_task(migrated_database_urls["app"], email="owner@example.com") + intruder = seed_task(migrated_database_urls["app"], email="intruder@example.com") + workspace_root = tmp_path / "task-workspaces" + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + task_workspace_root=str(workspace_root), + ), + ) + + workspace_status, workspace_payload = invoke_request( + "POST", + f"/v0/tasks/{owner['task_id']}/workspace", + payload={"user_id": str(owner["user_id"])}, + ) + assert workspace_status == 201 + + workspace_path = Path(workspace_payload["workspace"]["local_path"]) + first_file = workspace_path / "docs" / "spec.txt" + first_file.parent.mkdir(parents=True) + first_file.write_text("spec") + second_file = workspace_path / "notes" / "plan.md" + second_file.parent.mkdir(parents=True) + second_file.write_text("plan") + outside_file = tmp_path / "escape.txt" + outside_file.write_text("escape") + + first_create_status, first_create_payload = invoke_request( + "POST", + f"/v0/task-workspaces/{workspace_payload['workspace']['id']}/artifacts", + payload={ + "user_id": str(owner["user_id"]), + "local_path": str(first_file), + "media_type_hint": "text/plain", + }, + ) + second_create_status, second_create_payload = invoke_request( + "POST", + f"/v0/task-workspaces/{workspace_payload['workspace']['id']}/artifacts", + payload={ + "user_id": str(owner["user_id"]), + "local_path": str(second_file), + "media_type_hint": "text/markdown", + }, + ) + list_status, list_payload = invoke_request( + "GET", + "/v0/task-artifacts", + query_params={"user_id": str(owner["user_id"])}, + ) + detail_status, detail_payload = invoke_request( + "GET", + f"/v0/task-artifacts/{first_create_payload['artifact']['id']}", + query_params={"user_id": str(owner["user_id"])}, + ) + duplicate_status, duplicate_payload = invoke_request( + "POST", + f"/v0/task-workspaces/{workspace_payload['workspace']['id']}/artifacts", + payload={ + "user_id": str(owner["user_id"]), + "local_path": str(first_file), + "media_type_hint": "text/plain", + }, + ) + escaped_status, escaped_payload = invoke_request( + "POST", + f"/v0/task-workspaces/{workspace_payload['workspace']['id']}/artifacts", + payload={ + "user_id": str(owner["user_id"]), + "local_path": str(outside_file), + }, + ) + isolated_list_status, isolated_list_payload = invoke_request( + "GET", + "/v0/task-artifacts", + query_params={"user_id": str(intruder["user_id"])}, + ) + isolated_detail_status, isolated_detail_payload = invoke_request( + "GET", + f"/v0/task-artifacts/{first_create_payload['artifact']['id']}", + query_params={"user_id": str(intruder["user_id"])}, + ) + isolated_create_status, isolated_create_payload = invoke_request( + "POST", + f"/v0/task-workspaces/{workspace_payload['workspace']['id']}/artifacts", + payload={ + "user_id": str(intruder["user_id"]), + "local_path": str(first_file), + }, + ) + + assert first_create_status == 201 + assert first_create_payload == { + "artifact": { + "id": first_create_payload["artifact"]["id"], + "task_id": str(owner["task_id"]), + "task_workspace_id": workspace_payload["workspace"]["id"], + "status": "registered", + "ingestion_status": "pending", + "relative_path": "docs/spec.txt", + "media_type_hint": "text/plain", + "created_at": first_create_payload["artifact"]["created_at"], + "updated_at": first_create_payload["artifact"]["updated_at"], + } + } + + assert second_create_status == 201 + assert second_create_payload == { + "artifact": { + "id": second_create_payload["artifact"]["id"], + "task_id": str(owner["task_id"]), + "task_workspace_id": workspace_payload["workspace"]["id"], + "status": "registered", + "ingestion_status": "pending", + "relative_path": "notes/plan.md", + "media_type_hint": "text/markdown", + "created_at": second_create_payload["artifact"]["created_at"], + "updated_at": second_create_payload["artifact"]["updated_at"], + } + } + + assert list_status == 200 + assert list_payload == { + "items": [ + first_create_payload["artifact"], + second_create_payload["artifact"], + ], + "summary": {"total_count": 2, "order": ["created_at_asc", "id_asc"]}, + } + + assert detail_status == 200 + assert detail_payload == {"artifact": first_create_payload["artifact"]} + + assert duplicate_status == 409 + assert duplicate_payload == { + "detail": ( + "artifact docs/spec.txt is already registered for task workspace " + f"{workspace_payload['workspace']['id']}" + ) + } + + assert escaped_status == 400 + assert escaped_payload == { + "detail": f"artifact path {outside_file.resolve()} escapes workspace root {workspace_path.resolve()}" + } + + assert isolated_list_status == 200 + assert isolated_list_payload == { + "items": [], + "summary": {"total_count": 0, "order": ["created_at_asc", "id_asc"]}, + } + + assert isolated_detail_status == 404 + assert isolated_detail_payload == { + "detail": f"task artifact {first_create_payload['artifact']['id']} was not found" + } + + assert isolated_create_status == 404 + assert isolated_create_payload == { + "detail": f"task workspace {workspace_payload['workspace']['id']} was not found" + } + + +def test_task_artifact_ingestion_and_chunk_endpoints_are_deterministic_and_isolated( + migrated_database_urls, + monkeypatch, + tmp_path, +) -> None: + owner = seed_task(migrated_database_urls["app"], email="owner@example.com") + intruder = seed_task(migrated_database_urls["app"], email="intruder@example.com") + workspace_root = tmp_path / "task-workspaces" + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + task_workspace_root=str(workspace_root), + ), + ) + + workspace_status, workspace_payload = invoke_request( + "POST", + f"/v0/tasks/{owner['task_id']}/workspace", + payload={"user_id": str(owner["user_id"])}, + ) + assert workspace_status == 201 + + workspace_path = Path(workspace_payload["workspace"]["local_path"]) + supported_file = workspace_path / "docs" / "spec.txt" + supported_file.parent.mkdir(parents=True) + supported_file.write_text(("A" * 998) + "\r\n" + ("B" * 5) + "\rC") + unsupported_file = workspace_path / "docs" / "manual.bin" + unsupported_file.write_bytes(b"\x00\x01\x02") + + register_status, register_payload = invoke_request( + "POST", + f"/v0/task-workspaces/{workspace_payload['workspace']['id']}/artifacts", + payload={ + "user_id": str(owner["user_id"]), + "local_path": str(supported_file), + "media_type_hint": "text/plain", + }, + ) + assert register_status == 201 + + ingest_status, ingest_payload = invoke_request( + "POST", + f"/v0/task-artifacts/{register_payload['artifact']['id']}/ingest", + payload={"user_id": str(owner["user_id"])}, + ) + chunk_list_status, chunk_list_payload = invoke_request( + "GET", + f"/v0/task-artifacts/{register_payload['artifact']['id']}/chunks", + query_params={"user_id": str(owner["user_id"])}, + ) + isolated_chunk_list_status, isolated_chunk_list_payload = invoke_request( + "GET", + f"/v0/task-artifacts/{register_payload['artifact']['id']}/chunks", + query_params={"user_id": str(intruder["user_id"])}, + ) + isolated_ingest_status, isolated_ingest_payload = invoke_request( + "POST", + f"/v0/task-artifacts/{register_payload['artifact']['id']}/ingest", + payload={"user_id": str(intruder["user_id"])}, + ) + + unsupported_register_status, unsupported_register_payload = invoke_request( + "POST", + f"/v0/task-workspaces/{workspace_payload['workspace']['id']}/artifacts", + payload={ + "user_id": str(owner["user_id"]), + "local_path": str(unsupported_file), + "media_type_hint": "application/octet-stream", + }, + ) + assert unsupported_register_status == 201 + unsupported_ingest_status, unsupported_ingest_payload = invoke_request( + "POST", + f"/v0/task-artifacts/{unsupported_register_payload['artifact']['id']}/ingest", + payload={"user_id": str(owner["user_id"])}, + ) + + assert ingest_status == 200 + assert ingest_payload == { + "artifact": { + "id": register_payload["artifact"]["id"], + "task_id": str(owner["task_id"]), + "task_workspace_id": workspace_payload["workspace"]["id"], + "status": "registered", + "ingestion_status": "ingested", + "relative_path": "docs/spec.txt", + "media_type_hint": "text/plain", + "created_at": register_payload["artifact"]["created_at"], + "updated_at": ingest_payload["artifact"]["updated_at"], + }, + "summary": { + "total_count": 2, + "total_characters": 1006, + "media_type": "text/plain", + "chunking_rule": "normalized_utf8_text_fixed_window_1000_chars_v1", + "order": ["sequence_no_asc", "id_asc"], + }, + } + + assert chunk_list_status == 200 + assert chunk_list_payload == { + "items": [ + { + "id": chunk_list_payload["items"][0]["id"], + "task_artifact_id": register_payload["artifact"]["id"], + "sequence_no": 1, + "char_start": 0, + "char_end_exclusive": 1000, + "text": ("A" * 998) + "\n" + "B", + "created_at": chunk_list_payload["items"][0]["created_at"], + "updated_at": chunk_list_payload["items"][0]["updated_at"], + }, + { + "id": chunk_list_payload["items"][1]["id"], + "task_artifact_id": register_payload["artifact"]["id"], + "sequence_no": 2, + "char_start": 1000, + "char_end_exclusive": 1006, + "text": "BBBB\nC", + "created_at": chunk_list_payload["items"][1]["created_at"], + "updated_at": chunk_list_payload["items"][1]["updated_at"], + }, + ], + "summary": { + "total_count": 2, + "total_characters": 1006, + "media_type": "text/plain", + "chunking_rule": "normalized_utf8_text_fixed_window_1000_chars_v1", + "order": ["sequence_no_asc", "id_asc"], + }, + } + + assert isolated_chunk_list_status == 404 + assert isolated_chunk_list_payload == { + "detail": f"task artifact {register_payload['artifact']['id']} was not found" + } + + assert isolated_ingest_status == 404 + assert isolated_ingest_payload == { + "detail": f"task artifact {register_payload['artifact']['id']} was not found" + } + + assert unsupported_ingest_status == 400 + assert unsupported_ingest_payload == { + "detail": ( + "artifact docs/manual.bin has unsupported media type application/octet-stream; " + "supported types: text/plain, text/markdown, application/pdf, " + "application/vnd.openxmlformats-officedocument.wordprocessingml.document, " + "message/rfc822" + ) + } + + +def test_task_artifact_pdf_ingestion_and_chunk_endpoints_are_deterministic_and_isolated( + migrated_database_urls, + monkeypatch, + tmp_path, +) -> None: + owner = seed_task(migrated_database_urls["app"], email="owner@example.com") + intruder = seed_task(migrated_database_urls["app"], email="intruder@example.com") + workspace_root = tmp_path / "task-workspaces" + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + task_workspace_root=str(workspace_root), + ), + ) + + workspace_status, workspace_payload = invoke_request( + "POST", + f"/v0/tasks/{owner['task_id']}/workspace", + payload={"user_id": str(owner["user_id"])}, + ) + assert workspace_status == 201 + + workspace_path = Path(workspace_payload["workspace"]["local_path"]) + pdf_file = workspace_path / "docs" / "spec.pdf" + pdf_file.parent.mkdir(parents=True) + pdf_file.write_bytes(_build_pdf_bytes([["A" * 998, "B" * 5, "C"]])) + + register_status, register_payload = invoke_request( + "POST", + f"/v0/task-workspaces/{workspace_payload['workspace']['id']}/artifacts", + payload={ + "user_id": str(owner["user_id"]), + "local_path": str(pdf_file), + "media_type_hint": "application/pdf", + }, + ) + assert register_status == 201 + + ingest_status, ingest_payload = invoke_request( + "POST", + f"/v0/task-artifacts/{register_payload['artifact']['id']}/ingest", + payload={"user_id": str(owner["user_id"])}, + ) + chunk_list_status, chunk_list_payload = invoke_request( + "GET", + f"/v0/task-artifacts/{register_payload['artifact']['id']}/chunks", + query_params={"user_id": str(owner["user_id"])}, + ) + isolated_chunk_list_status, isolated_chunk_list_payload = invoke_request( + "GET", + f"/v0/task-artifacts/{register_payload['artifact']['id']}/chunks", + query_params={"user_id": str(intruder["user_id"])}, + ) + isolated_ingest_status, isolated_ingest_payload = invoke_request( + "POST", + f"/v0/task-artifacts/{register_payload['artifact']['id']}/ingest", + payload={"user_id": str(intruder["user_id"])}, + ) + + assert ingest_status == 200 + assert ingest_payload == { + "artifact": { + "id": register_payload["artifact"]["id"], + "task_id": str(owner["task_id"]), + "task_workspace_id": workspace_payload["workspace"]["id"], + "status": "registered", + "ingestion_status": "ingested", + "relative_path": "docs/spec.pdf", + "media_type_hint": "application/pdf", + "created_at": register_payload["artifact"]["created_at"], + "updated_at": ingest_payload["artifact"]["updated_at"], + }, + "summary": { + "total_count": 2, + "total_characters": 1006, + "media_type": "application/pdf", + "chunking_rule": "normalized_utf8_text_fixed_window_1000_chars_v1", + "order": ["sequence_no_asc", "id_asc"], + }, + } + + assert chunk_list_status == 200 + assert chunk_list_payload == { + "items": [ + { + "id": chunk_list_payload["items"][0]["id"], + "task_artifact_id": register_payload["artifact"]["id"], + "sequence_no": 1, + "char_start": 0, + "char_end_exclusive": 1000, + "text": ("A" * 998) + "\n" + "B", + "created_at": chunk_list_payload["items"][0]["created_at"], + "updated_at": chunk_list_payload["items"][0]["updated_at"], + }, + { + "id": chunk_list_payload["items"][1]["id"], + "task_artifact_id": register_payload["artifact"]["id"], + "sequence_no": 2, + "char_start": 1000, + "char_end_exclusive": 1006, + "text": "BBBB\nC", + "created_at": chunk_list_payload["items"][1]["created_at"], + "updated_at": chunk_list_payload["items"][1]["updated_at"], + }, + ], + "summary": { + "total_count": 2, + "total_characters": 1006, + "media_type": "application/pdf", + "chunking_rule": "normalized_utf8_text_fixed_window_1000_chars_v1", + "order": ["sequence_no_asc", "id_asc"], + }, + } + + assert isolated_chunk_list_status == 404 + assert isolated_chunk_list_payload == { + "detail": f"task artifact {register_payload['artifact']['id']} was not found" + } + + assert isolated_ingest_status == 404 + assert isolated_ingest_payload == { + "detail": f"task artifact {register_payload['artifact']['id']} was not found" + } + + +def test_task_artifact_docx_ingestion_and_chunk_endpoints_are_deterministic_and_isolated( + migrated_database_urls, + monkeypatch, + tmp_path, +) -> None: + owner = seed_task(migrated_database_urls["app"], email="owner@example.com") + intruder = seed_task(migrated_database_urls["app"], email="intruder@example.com") + workspace_root = tmp_path / "task-workspaces" + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + task_workspace_root=str(workspace_root), + ), + ) + + workspace_status, workspace_payload = invoke_request( + "POST", + f"/v0/tasks/{owner['task_id']}/workspace", + payload={"user_id": str(owner["user_id"])}, + ) + assert workspace_status == 201 + + workspace_path = Path(workspace_payload["workspace"]["local_path"]) + docx_file = workspace_path / "docs" / "spec.docx" + docx_file.parent.mkdir(parents=True) + docx_file.write_bytes(_build_docx_bytes(["A" * 998, "B" * 5, "C"])) + + register_status, register_payload = invoke_request( + "POST", + f"/v0/task-workspaces/{workspace_payload['workspace']['id']}/artifacts", + payload={ + "user_id": str(owner["user_id"]), + "local_path": str(docx_file), + "media_type_hint": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + }, + ) + assert register_status == 201 + + ingest_status, ingest_payload = invoke_request( + "POST", + f"/v0/task-artifacts/{register_payload['artifact']['id']}/ingest", + payload={"user_id": str(owner["user_id"])}, + ) + chunk_list_status, chunk_list_payload = invoke_request( + "GET", + f"/v0/task-artifacts/{register_payload['artifact']['id']}/chunks", + query_params={"user_id": str(owner["user_id"])}, + ) + isolated_chunk_list_status, isolated_chunk_list_payload = invoke_request( + "GET", + f"/v0/task-artifacts/{register_payload['artifact']['id']}/chunks", + query_params={"user_id": str(intruder["user_id"])}, + ) + isolated_ingest_status, isolated_ingest_payload = invoke_request( + "POST", + f"/v0/task-artifacts/{register_payload['artifact']['id']}/ingest", + payload={"user_id": str(intruder["user_id"])}, + ) + + assert ingest_status == 200 + assert ingest_payload == { + "artifact": { + "id": register_payload["artifact"]["id"], + "task_id": str(owner["task_id"]), + "task_workspace_id": workspace_payload["workspace"]["id"], + "status": "registered", + "ingestion_status": "ingested", + "relative_path": "docs/spec.docx", + "media_type_hint": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "created_at": register_payload["artifact"]["created_at"], + "updated_at": ingest_payload["artifact"]["updated_at"], + }, + "summary": { + "total_count": 2, + "total_characters": 1006, + "media_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "chunking_rule": "normalized_utf8_text_fixed_window_1000_chars_v1", + "order": ["sequence_no_asc", "id_asc"], + }, + } + + assert chunk_list_status == 200 + assert chunk_list_payload == { + "items": [ + { + "id": chunk_list_payload["items"][0]["id"], + "task_artifact_id": register_payload["artifact"]["id"], + "sequence_no": 1, + "char_start": 0, + "char_end_exclusive": 1000, + "text": ("A" * 998) + "\n" + "B", + "created_at": chunk_list_payload["items"][0]["created_at"], + "updated_at": chunk_list_payload["items"][0]["updated_at"], + }, + { + "id": chunk_list_payload["items"][1]["id"], + "task_artifact_id": register_payload["artifact"]["id"], + "sequence_no": 2, + "char_start": 1000, + "char_end_exclusive": 1006, + "text": "BBBB\nC", + "created_at": chunk_list_payload["items"][1]["created_at"], + "updated_at": chunk_list_payload["items"][1]["updated_at"], + }, + ], + "summary": { + "total_count": 2, + "total_characters": 1006, + "media_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "chunking_rule": "normalized_utf8_text_fixed_window_1000_chars_v1", + "order": ["sequence_no_asc", "id_asc"], + }, + } + + assert isolated_chunk_list_status == 404 + assert isolated_chunk_list_payload == { + "detail": f"task artifact {register_payload['artifact']['id']} was not found" + } + + assert isolated_ingest_status == 404 + assert isolated_ingest_payload == { + "detail": f"task artifact {register_payload['artifact']['id']} was not found" + } + + +def test_task_artifact_rfc822_ingestion_and_chunk_endpoints_are_deterministic_and_isolated( + migrated_database_urls, + monkeypatch, + tmp_path, +) -> None: + owner = seed_task(migrated_database_urls["app"], email="owner@example.com") + intruder = seed_task(migrated_database_urls["app"], email="intruder@example.com") + workspace_root = tmp_path / "task-workspaces" + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + task_workspace_root=str(workspace_root), + ), + ) + + workspace_status, workspace_payload = invoke_request( + "POST", + f"/v0/tasks/{owner['task_id']}/workspace", + payload={"user_id": str(owner["user_id"])}, + ) + assert workspace_status == 201 + + workspace_path = Path(workspace_payload["workspace"]["local_path"]) + email_file = workspace_path / "mail" / "update.eml" + email_file.parent.mkdir(parents=True) + email_file.write_bytes( + _build_rfc822_email_bytes( + plain_body=("A" * 916) + "\r\n" + ("B" * 5) + "\rC", + ) + ) + + register_status, register_payload = invoke_request( + "POST", + f"/v0/task-workspaces/{workspace_payload['workspace']['id']}/artifacts", + payload={ + "user_id": str(owner["user_id"]), + "local_path": str(email_file), + "media_type_hint": "message/rfc822", + }, + ) + assert register_status == 201 + + ingest_status, ingest_payload = invoke_request( + "POST", + f"/v0/task-artifacts/{register_payload['artifact']['id']}/ingest", + payload={"user_id": str(owner["user_id"])}, + ) + chunk_list_status, chunk_list_payload = invoke_request( + "GET", + f"/v0/task-artifacts/{register_payload['artifact']['id']}/chunks", + query_params={"user_id": str(owner["user_id"])}, + ) + isolated_chunk_list_status, isolated_chunk_list_payload = invoke_request( + "GET", + f"/v0/task-artifacts/{register_payload['artifact']['id']}/chunks", + query_params={"user_id": str(intruder["user_id"])}, + ) + isolated_ingest_status, isolated_ingest_payload = invoke_request( + "POST", + f"/v0/task-artifacts/{register_payload['artifact']['id']}/ingest", + payload={"user_id": str(intruder["user_id"])}, + ) + + header_block = ( + "From: Alice <alice@example.com>\n" + "To: Bob <bob@example.com>\n" + "Subject: Sprint Update\n\n" + ) + assert ingest_status == 200 + assert ingest_payload == { + "artifact": { + "id": register_payload["artifact"]["id"], + "task_id": str(owner["task_id"]), + "task_workspace_id": workspace_payload["workspace"]["id"], + "status": "registered", + "ingestion_status": "ingested", + "relative_path": "mail/update.eml", + "media_type_hint": "message/rfc822", + "created_at": register_payload["artifact"]["created_at"], + "updated_at": ingest_payload["artifact"]["updated_at"], + }, + "summary": { + "total_count": 2, + "total_characters": 1006, + "media_type": "message/rfc822", + "chunking_rule": "normalized_utf8_text_fixed_window_1000_chars_v1", + "order": ["sequence_no_asc", "id_asc"], + }, + } + + assert chunk_list_status == 200 + assert chunk_list_payload == { + "items": [ + { + "id": chunk_list_payload["items"][0]["id"], + "task_artifact_id": register_payload["artifact"]["id"], + "sequence_no": 1, + "char_start": 0, + "char_end_exclusive": 1000, + "text": header_block + ("A" * 916) + "\n" + "B", + "created_at": chunk_list_payload["items"][0]["created_at"], + "updated_at": chunk_list_payload["items"][0]["updated_at"], + }, + { + "id": chunk_list_payload["items"][1]["id"], + "task_artifact_id": register_payload["artifact"]["id"], + "sequence_no": 2, + "char_start": 1000, + "char_end_exclusive": 1006, + "text": "BBBB\nC", + "created_at": chunk_list_payload["items"][1]["created_at"], + "updated_at": chunk_list_payload["items"][1]["updated_at"], + }, + ], + "summary": { + "total_count": 2, + "total_characters": 1006, + "media_type": "message/rfc822", + "chunking_rule": "normalized_utf8_text_fixed_window_1000_chars_v1", + "order": ["sequence_no_asc", "id_asc"], + }, + } + + assert isolated_chunk_list_status == 404 + assert isolated_chunk_list_payload == { + "detail": f"task artifact {register_payload['artifact']['id']} was not found" + } + + assert isolated_ingest_status == 404 + assert isolated_ingest_payload == { + "detail": f"task artifact {register_payload['artifact']['id']} was not found" + } + + +def test_task_artifact_rfc822_ingestion_excludes_nested_email_bodies( + migrated_database_urls, + monkeypatch, + tmp_path, +) -> None: + owner = seed_task(migrated_database_urls["app"], email="owner@example.com") + workspace_root = tmp_path / "task-workspaces" + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + task_workspace_root=str(workspace_root), + ), + ) + + workspace_status, workspace_payload = invoke_request( + "POST", + f"/v0/tasks/{owner['task_id']}/workspace", + payload={"user_id": str(owner["user_id"])}, + ) + assert workspace_status == 201 + + workspace_path = Path(workspace_payload["workspace"]["local_path"]) + email_file = workspace_path / "mail" / "forwarded.eml" + email_file.parent.mkdir(parents=True) + email_file.write_bytes( + _build_rfc822_email_bytes( + plain_parts=["Outer body"], + nested_message_bytes=_build_rfc822_email_bytes( + headers=[ + ("From", "Nested <nested@example.com>"), + ("To", "Team <team@example.com>"), + ("Subject", "Nested"), + ], + plain_body="Inner body", + ), + ) + ) + + register_status, register_payload = invoke_request( + "POST", + f"/v0/task-workspaces/{workspace_payload['workspace']['id']}/artifacts", + payload={ + "user_id": str(owner["user_id"]), + "local_path": str(email_file), + "media_type_hint": "message/rfc822", + }, + ) + assert register_status == 201 + + ingest_status, ingest_payload = invoke_request( + "POST", + f"/v0/task-artifacts/{register_payload['artifact']['id']}/ingest", + payload={"user_id": str(owner["user_id"])}, + ) + chunk_list_status, chunk_list_payload = invoke_request( + "GET", + f"/v0/task-artifacts/{register_payload['artifact']['id']}/chunks", + query_params={"user_id": str(owner["user_id"])}, + ) + + expected_text = ( + "From: Alice <alice@example.com>\n" + "To: Bob <bob@example.com>\n" + "Subject: Sprint Update\n\n" + "Outer body" + ) + assert ingest_status == 200 + assert ingest_payload == { + "artifact": { + "id": register_payload["artifact"]["id"], + "task_id": str(owner["task_id"]), + "task_workspace_id": workspace_payload["workspace"]["id"], + "status": "registered", + "ingestion_status": "ingested", + "relative_path": "mail/forwarded.eml", + "media_type_hint": "message/rfc822", + "created_at": register_payload["artifact"]["created_at"], + "updated_at": ingest_payload["artifact"]["updated_at"], + }, + "summary": { + "total_count": 1, + "total_characters": len(expected_text), + "media_type": "message/rfc822", + "chunking_rule": "normalized_utf8_text_fixed_window_1000_chars_v1", + "order": ["sequence_no_asc", "id_asc"], + }, + } + + assert chunk_list_status == 200 + assert chunk_list_payload == { + "items": [ + { + "id": chunk_list_payload["items"][0]["id"], + "task_artifact_id": register_payload["artifact"]["id"], + "sequence_no": 1, + "char_start": 0, + "char_end_exclusive": len(expected_text), + "text": expected_text, + "created_at": chunk_list_payload["items"][0]["created_at"], + "updated_at": chunk_list_payload["items"][0]["updated_at"], + } + ], + "summary": { + "total_count": 1, + "total_characters": len(expected_text), + "media_type": "message/rfc822", + "chunking_rule": "normalized_utf8_text_fixed_window_1000_chars_v1", + "order": ["sequence_no_asc", "id_asc"], + }, + } + + +def test_task_artifact_ingestion_supports_markdown_and_reingest_is_idempotent( + migrated_database_urls, + monkeypatch, + tmp_path, +) -> None: + owner = seed_task(migrated_database_urls["app"], email="owner@example.com") + workspace_root = tmp_path / "task-workspaces" + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + task_workspace_root=str(workspace_root), + ), + ) + + workspace_status, workspace_payload = invoke_request( + "POST", + f"/v0/tasks/{owner['task_id']}/workspace", + payload={"user_id": str(owner["user_id"])}, + ) + assert workspace_status == 201 + + workspace_path = Path(workspace_payload["workspace"]["local_path"]) + markdown_file = workspace_path / "notes" / "plan.md" + markdown_file.parent.mkdir(parents=True) + markdown_file.write_text("# Plan\r\n\r\n- Ship ingestion\n- Keep scope narrow\r") + + register_status, register_payload = invoke_request( + "POST", + f"/v0/task-workspaces/{workspace_payload['workspace']['id']}/artifacts", + payload={ + "user_id": str(owner["user_id"]), + "local_path": str(markdown_file), + "media_type_hint": "text/markdown", + }, + ) + assert register_status == 201 + + first_ingest_status, first_ingest_payload = invoke_request( + "POST", + f"/v0/task-artifacts/{register_payload['artifact']['id']}/ingest", + payload={"user_id": str(owner["user_id"])}, + ) + second_ingest_status, second_ingest_payload = invoke_request( + "POST", + f"/v0/task-artifacts/{register_payload['artifact']['id']}/ingest", + payload={"user_id": str(owner["user_id"])}, + ) + chunk_list_status, chunk_list_payload = invoke_request( + "GET", + f"/v0/task-artifacts/{register_payload['artifact']['id']}/chunks", + query_params={"user_id": str(owner["user_id"])}, + ) + + assert first_ingest_status == 200 + assert first_ingest_payload == { + "artifact": { + "id": register_payload["artifact"]["id"], + "task_id": str(owner["task_id"]), + "task_workspace_id": workspace_payload["workspace"]["id"], + "status": "registered", + "ingestion_status": "ingested", + "relative_path": "notes/plan.md", + "media_type_hint": "text/markdown", + "created_at": register_payload["artifact"]["created_at"], + "updated_at": first_ingest_payload["artifact"]["updated_at"], + }, + "summary": { + "total_count": 1, + "total_characters": 45, + "media_type": "text/markdown", + "chunking_rule": "normalized_utf8_text_fixed_window_1000_chars_v1", + "order": ["sequence_no_asc", "id_asc"], + }, + } + assert second_ingest_status == 200 + assert second_ingest_payload == first_ingest_payload + assert chunk_list_status == 200 + assert chunk_list_payload == { + "items": [ + { + "id": chunk_list_payload["items"][0]["id"], + "task_artifact_id": register_payload["artifact"]["id"], + "sequence_no": 1, + "char_start": 0, + "char_end_exclusive": 45, + "text": "# Plan\n\n- Ship ingestion\n- Keep scope narrow\n", + "created_at": chunk_list_payload["items"][0]["created_at"], + "updated_at": chunk_list_payload["items"][0]["updated_at"], + } + ], + "summary": { + "total_count": 1, + "total_characters": 45, + "media_type": "text/markdown", + "chunking_rule": "normalized_utf8_text_fixed_window_1000_chars_v1", + "order": ["sequence_no_asc", "id_asc"], + }, + } + + +def test_task_artifact_ingestion_rejects_invalid_utf8_content( + migrated_database_urls, + monkeypatch, + tmp_path, +) -> None: + owner = seed_task(migrated_database_urls["app"], email="owner@example.com") + workspace_root = tmp_path / "task-workspaces" + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + task_workspace_root=str(workspace_root), + ), + ) + + workspace_status, workspace_payload = invoke_request( + "POST", + f"/v0/tasks/{owner['task_id']}/workspace", + payload={"user_id": str(owner["user_id"])}, + ) + assert workspace_status == 201 + + workspace_path = Path(workspace_payload["workspace"]["local_path"]) + broken_file = workspace_path / "docs" / "broken.txt" + broken_file.parent.mkdir(parents=True) + broken_file.write_bytes(b"\xff\xfe\xfd") + + register_status, register_payload = invoke_request( + "POST", + f"/v0/task-workspaces/{workspace_payload['workspace']['id']}/artifacts", + payload={ + "user_id": str(owner["user_id"]), + "local_path": str(broken_file), + "media_type_hint": "text/plain", + }, + ) + assert register_status == 201 + + ingest_status, ingest_payload = invoke_request( + "POST", + f"/v0/task-artifacts/{register_payload['artifact']['id']}/ingest", + payload={"user_id": str(owner["user_id"])}, + ) + + assert ingest_status == 400 + assert ingest_payload == { + "detail": "artifact docs/broken.txt is not valid UTF-8 text" + } + + +def test_task_artifact_ingestion_rejects_textless_pdf_content( + migrated_database_urls, + monkeypatch, + tmp_path, +) -> None: + owner = seed_task(migrated_database_urls["app"], email="owner@example.com") + workspace_root = tmp_path / "task-workspaces" + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + task_workspace_root=str(workspace_root), + ), + ) + + workspace_status, workspace_payload = invoke_request( + "POST", + f"/v0/tasks/{owner['task_id']}/workspace", + payload={"user_id": str(owner["user_id"])}, + ) + assert workspace_status == 201 + + workspace_path = Path(workspace_payload["workspace"]["local_path"]) + textless_pdf = workspace_path / "docs" / "scanned.pdf" + textless_pdf.parent.mkdir(parents=True) + textless_pdf.write_bytes(_build_pdf_bytes([[]], textless=True)) + + register_status, register_payload = invoke_request( + "POST", + f"/v0/task-workspaces/{workspace_payload['workspace']['id']}/artifacts", + payload={ + "user_id": str(owner["user_id"]), + "local_path": str(textless_pdf), + "media_type_hint": "application/pdf", + }, + ) + assert register_status == 201 + + ingest_status, ingest_payload = invoke_request( + "POST", + f"/v0/task-artifacts/{register_payload['artifact']['id']}/ingest", + payload={"user_id": str(owner["user_id"])}, + ) + + assert ingest_status == 400 + assert ingest_payload == { + "detail": "artifact docs/scanned.pdf does not contain extractable PDF text" + } + + +def test_task_artifact_ingestion_rejects_textless_or_malformed_docx( + migrated_database_urls, + monkeypatch, + tmp_path, +) -> None: + owner = seed_task(migrated_database_urls["app"], email="owner@example.com") + workspace_root = tmp_path / "task-workspaces" + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + task_workspace_root=str(workspace_root), + ), + ) + + workspace_status, workspace_payload = invoke_request( + "POST", + f"/v0/tasks/{owner['task_id']}/workspace", + payload={"user_id": str(owner["user_id"])}, + ) + assert workspace_status == 201 + + workspace_path = Path(workspace_payload["workspace"]["local_path"]) + textless_docx = workspace_path / "docs" / "empty.docx" + textless_docx.parent.mkdir(parents=True) + textless_docx.write_bytes(_build_docx_bytes(["", ""])) + malformed_docx = workspace_path / "docs" / "broken.docx" + malformed_docx.write_bytes(_build_docx_bytes(["broken"], malformed_document_xml=True)) + + textless_register_status, textless_register_payload = invoke_request( + "POST", + f"/v0/task-workspaces/{workspace_payload['workspace']['id']}/artifacts", + payload={ + "user_id": str(owner["user_id"]), + "local_path": str(textless_docx), + "media_type_hint": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + }, + ) + malformed_register_status, malformed_register_payload = invoke_request( + "POST", + f"/v0/task-workspaces/{workspace_payload['workspace']['id']}/artifacts", + payload={ + "user_id": str(owner["user_id"]), + "local_path": str(malformed_docx), + "media_type_hint": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + }, + ) + assert textless_register_status == 201 + assert malformed_register_status == 201 + + textless_ingest_status, textless_ingest_payload = invoke_request( + "POST", + f"/v0/task-artifacts/{textless_register_payload['artifact']['id']}/ingest", + payload={"user_id": str(owner["user_id"])}, + ) + malformed_ingest_status, malformed_ingest_payload = invoke_request( + "POST", + f"/v0/task-artifacts/{malformed_register_payload['artifact']['id']}/ingest", + payload={"user_id": str(owner["user_id"])}, + ) + + assert textless_ingest_status == 400 + assert textless_ingest_payload == { + "detail": "artifact docs/empty.docx does not contain extractable DOCX text" + } + assert malformed_ingest_status == 400 + assert malformed_ingest_payload == { + "detail": "artifact docs/broken.docx is not a valid DOCX" + } + + +def test_task_artifact_ingestion_rejects_textless_or_malformed_rfc822_email( + migrated_database_urls, + monkeypatch, + tmp_path, +) -> None: + owner = seed_task(migrated_database_urls["app"], email="owner@example.com") + workspace_root = tmp_path / "task-workspaces" + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + task_workspace_root=str(workspace_root), + ), + ) + + workspace_status, workspace_payload = invoke_request( + "POST", + f"/v0/tasks/{owner['task_id']}/workspace", + payload={"user_id": str(owner["user_id"])}, + ) + assert workspace_status == 201 + + workspace_path = Path(workspace_payload["workspace"]["local_path"]) + textless_email = workspace_path / "mail" / "empty.eml" + textless_email.parent.mkdir(parents=True) + textless_email.write_bytes(_build_rfc822_email_bytes(html_body="<p>html only</p>")) + malformed_email = workspace_path / "mail" / "broken.eml" + malformed_email.write_bytes(_build_rfc822_email_bytes(malformed_multipart=True)) + + textless_register_status, textless_register_payload = invoke_request( + "POST", + f"/v0/task-workspaces/{workspace_payload['workspace']['id']}/artifacts", + payload={ + "user_id": str(owner["user_id"]), + "local_path": str(textless_email), + "media_type_hint": "message/rfc822", + }, + ) + malformed_register_status, malformed_register_payload = invoke_request( + "POST", + f"/v0/task-workspaces/{workspace_payload['workspace']['id']}/artifacts", + payload={ + "user_id": str(owner["user_id"]), + "local_path": str(malformed_email), + "media_type_hint": "message/rfc822", + }, + ) + assert textless_register_status == 201 + assert malformed_register_status == 201 + + textless_ingest_status, textless_ingest_payload = invoke_request( + "POST", + f"/v0/task-artifacts/{textless_register_payload['artifact']['id']}/ingest", + payload={"user_id": str(owner["user_id"])}, + ) + malformed_ingest_status, malformed_ingest_payload = invoke_request( + "POST", + f"/v0/task-artifacts/{malformed_register_payload['artifact']['id']}/ingest", + payload={"user_id": str(owner["user_id"])}, + ) + + assert textless_ingest_status == 400 + assert textless_ingest_payload == { + "detail": "artifact mail/empty.eml does not contain extractable RFC822 email text" + } + assert malformed_ingest_status == 400 + assert malformed_ingest_payload == { + "detail": "artifact mail/broken.eml is not a valid RFC822 email" + } + + +def test_task_artifact_ingestion_enforces_rooted_workspace_paths( + migrated_database_urls, + monkeypatch, + tmp_path, +) -> None: + owner = seed_task(migrated_database_urls["app"], email="owner@example.com") + workspace_root = tmp_path / "task-workspaces" + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + task_workspace_root=str(workspace_root), + ), + ) + + workspace_status, workspace_payload = invoke_request( + "POST", + f"/v0/tasks/{owner['task_id']}/workspace", + payload={"user_id": str(owner["user_id"])}, + ) + assert workspace_status == 201 + + workspace_path = Path(workspace_payload["workspace"]["local_path"]) + safe_file = workspace_path / "docs" / "spec.pdf" + safe_file.parent.mkdir(parents=True) + safe_file.write_bytes(_build_pdf_bytes([["spec"]])) + outside_file = tmp_path / "escape.pdf" + outside_file.write_bytes(_build_pdf_bytes([["escape"]])) + + register_status, register_payload = invoke_request( + "POST", + f"/v0/task-workspaces/{workspace_payload['workspace']['id']}/artifacts", + payload={ + "user_id": str(owner["user_id"]), + "local_path": str(safe_file), + "media_type_hint": "application/pdf", + }, + ) + assert register_status == 201 + + with psycopg.connect(migrated_database_urls["admin"]) as conn: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE task_artifacts + SET relative_path = '../../../escape.pdf' + WHERE id = %s + """, + (register_payload["artifact"]["id"],), + ) + conn.commit() + + ingest_status, ingest_payload = invoke_request( + "POST", + f"/v0/task-artifacts/{register_payload['artifact']['id']}/ingest", + payload={"user_id": str(owner["user_id"])}, + ) + + assert ingest_status == 400 + assert ingest_payload == { + "detail": f"artifact path {outside_file.resolve()} escapes workspace root {workspace_path.resolve()}" + } + + +def test_task_artifact_docx_ingestion_enforces_rooted_workspace_paths( + migrated_database_urls, + monkeypatch, + tmp_path, +) -> None: + owner = seed_task(migrated_database_urls["app"], email="owner@example.com") + workspace_root = tmp_path / "task-workspaces" + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + task_workspace_root=str(workspace_root), + ), + ) + + workspace_status, workspace_payload = invoke_request( + "POST", + f"/v0/tasks/{owner['task_id']}/workspace", + payload={"user_id": str(owner["user_id"])}, + ) + assert workspace_status == 201 + + workspace_path = Path(workspace_payload["workspace"]["local_path"]) + safe_file = workspace_path / "docs" / "spec.docx" + safe_file.parent.mkdir(parents=True) + safe_file.write_bytes(_build_docx_bytes(["spec"])) + outside_file = tmp_path / "escape.docx" + outside_file.write_bytes(_build_docx_bytes(["escape"])) + + register_status, register_payload = invoke_request( + "POST", + f"/v0/task-workspaces/{workspace_payload['workspace']['id']}/artifacts", + payload={ + "user_id": str(owner["user_id"]), + "local_path": str(safe_file), + "media_type_hint": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + }, + ) + assert register_status == 201 + + with psycopg.connect(migrated_database_urls["admin"]) as conn: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE task_artifacts + SET relative_path = '../../../escape.docx' + WHERE id = %s + """, + (register_payload["artifact"]["id"],), + ) + conn.commit() + + ingest_status, ingest_payload = invoke_request( + "POST", + f"/v0/task-artifacts/{register_payload['artifact']['id']}/ingest", + payload={"user_id": str(owner["user_id"])}, + ) + + assert ingest_status == 400 + assert ingest_payload == { + "detail": f"artifact path {outside_file.resolve()} escapes workspace root {workspace_path.resolve()}" + } + + +def test_task_artifact_rfc822_ingestion_enforces_rooted_workspace_paths( + migrated_database_urls, + monkeypatch, + tmp_path, +) -> None: + owner = seed_task(migrated_database_urls["app"], email="owner@example.com") + workspace_root = tmp_path / "task-workspaces" + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + task_workspace_root=str(workspace_root), + ), + ) + + workspace_status, workspace_payload = invoke_request( + "POST", + f"/v0/tasks/{owner['task_id']}/workspace", + payload={"user_id": str(owner["user_id"])}, + ) + assert workspace_status == 201 + + workspace_path = Path(workspace_payload["workspace"]["local_path"]) + safe_file = workspace_path / "mail" / "update.eml" + safe_file.parent.mkdir(parents=True) + safe_file.write_bytes(_build_rfc822_email_bytes(plain_body="spec")) + outside_file = tmp_path / "escape.eml" + outside_file.write_bytes(_build_rfc822_email_bytes(plain_body="escape")) + + register_status, register_payload = invoke_request( + "POST", + f"/v0/task-workspaces/{workspace_payload['workspace']['id']}/artifacts", + payload={ + "user_id": str(owner["user_id"]), + "local_path": str(safe_file), + "media_type_hint": "message/rfc822", + }, + ) + assert register_status == 201 + + with psycopg.connect(migrated_database_urls["admin"]) as conn: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE task_artifacts + SET relative_path = '../../../escape.eml' + WHERE id = %s + """, + (register_payload["artifact"]["id"],), + ) + conn.commit() + + ingest_status, ingest_payload = invoke_request( + "POST", + f"/v0/task-artifacts/{register_payload['artifact']['id']}/ingest", + payload={"user_id": str(owner["user_id"])}, + ) + + assert ingest_status == 400 + assert ingest_payload == { + "detail": f"artifact path {outside_file.resolve()} escapes workspace root {workspace_path.resolve()}" + } + + +def test_task_artifact_chunk_retrieval_endpoints_are_scoped_deterministic_and_isolated( + migrated_database_urls, + monkeypatch, + tmp_path, +) -> None: + owner = seed_task(migrated_database_urls["app"], email="owner@example.com") + intruder = seed_task(migrated_database_urls["app"], email="intruder@example.com") + workspace_root = tmp_path / "task-workspaces" + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + task_workspace_root=str(workspace_root), + ), + ) + + owner_workspace_status, owner_workspace_payload = invoke_request( + "POST", + f"/v0/tasks/{owner['task_id']}/workspace", + payload={"user_id": str(owner["user_id"])}, + ) + assert owner_workspace_status == 201 + owner_workspace_path = Path(owner_workspace_payload["workspace"]["local_path"]) + + docs_file = owner_workspace_path / "docs" / "a.txt" + docs_file.parent.mkdir(parents=True) + docs_file.write_text("beta alpha doc") + notes_file = owner_workspace_path / "notes" / "b.md" + notes_file.parent.mkdir(parents=True) + notes_file.write_text("alpha beta note") + weak_file = owner_workspace_path / "notes" / "c.txt" + weak_file.write_text("beta only") + pending_file = owner_workspace_path / "notes" / "hidden.txt" + pending_file.write_text("alpha beta hidden") + + docs_register_status, docs_register_payload = invoke_request( + "POST", + f"/v0/task-workspaces/{owner_workspace_payload['workspace']['id']}/artifacts", + payload={ + "user_id": str(owner["user_id"]), + "local_path": str(docs_file), + "media_type_hint": "text/plain", + }, + ) + notes_register_status, notes_register_payload = invoke_request( + "POST", + f"/v0/task-workspaces/{owner_workspace_payload['workspace']['id']}/artifacts", + payload={ + "user_id": str(owner["user_id"]), + "local_path": str(notes_file), + "media_type_hint": "text/markdown", + }, + ) + weak_register_status, weak_register_payload = invoke_request( + "POST", + f"/v0/task-workspaces/{owner_workspace_payload['workspace']['id']}/artifacts", + payload={ + "user_id": str(owner["user_id"]), + "local_path": str(weak_file), + "media_type_hint": "text/plain", + }, + ) + pending_register_status, pending_register_payload = invoke_request( + "POST", + f"/v0/task-workspaces/{owner_workspace_payload['workspace']['id']}/artifacts", + payload={ + "user_id": str(owner["user_id"]), + "local_path": str(pending_file), + "media_type_hint": "text/plain", + }, + ) + assert docs_register_status == 201 + assert notes_register_status == 201 + assert weak_register_status == 201 + assert pending_register_status == 201 + + docs_ingest_status, _ = invoke_request( + "POST", + f"/v0/task-artifacts/{docs_register_payload['artifact']['id']}/ingest", + payload={"user_id": str(owner["user_id"])}, + ) + notes_ingest_status, _ = invoke_request( + "POST", + f"/v0/task-artifacts/{notes_register_payload['artifact']['id']}/ingest", + payload={"user_id": str(owner["user_id"])}, + ) + weak_ingest_status, _ = invoke_request( + "POST", + f"/v0/task-artifacts/{weak_register_payload['artifact']['id']}/ingest", + payload={"user_id": str(owner["user_id"])}, + ) + assert docs_ingest_status == 200 + assert notes_ingest_status == 200 + assert weak_ingest_status == 200 + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + store = ContinuityStore(conn) + store.create_task_artifact_chunk( + task_artifact_id=UUID(pending_register_payload["artifact"]["id"]), + sequence_no=1, + char_start=0, + char_end_exclusive=17, + text="alpha beta hidden", + ) + + intruder_workspace_status, intruder_workspace_payload = invoke_request( + "POST", + f"/v0/tasks/{intruder['task_id']}/workspace", + payload={"user_id": str(intruder["user_id"])}, + ) + assert intruder_workspace_status == 201 + intruder_workspace_path = Path(intruder_workspace_payload["workspace"]["local_path"]) + intruder_file = intruder_workspace_path / "docs" / "secret.txt" + intruder_file.parent.mkdir(parents=True) + intruder_file.write_text("alpha beta intruder") + + intruder_register_status, intruder_register_payload = invoke_request( + "POST", + f"/v0/task-workspaces/{intruder_workspace_payload['workspace']['id']}/artifacts", + payload={ + "user_id": str(intruder["user_id"]), + "local_path": str(intruder_file), + "media_type_hint": "text/plain", + }, + ) + assert intruder_register_status == 201 + intruder_ingest_status, _ = invoke_request( + "POST", + f"/v0/task-artifacts/{intruder_register_payload['artifact']['id']}/ingest", + payload={"user_id": str(intruder["user_id"])}, + ) + assert intruder_ingest_status == 200 + + task_retrieve_status, task_retrieve_payload = invoke_request( + "POST", + f"/v0/tasks/{owner['task_id']}/artifact-chunks/retrieve", + payload={"user_id": str(owner["user_id"]), "query": "Alpha beta"}, + ) + artifact_retrieve_status, artifact_retrieve_payload = invoke_request( + "POST", + f"/v0/task-artifacts/{notes_register_payload['artifact']['id']}/chunks/retrieve", + payload={"user_id": str(owner["user_id"]), "query": "Alpha beta"}, + ) + empty_retrieve_status, empty_retrieve_payload = invoke_request( + "POST", + f"/v0/tasks/{owner['task_id']}/artifact-chunks/retrieve", + payload={"user_id": str(owner["user_id"]), "query": "missing"}, + ) + isolated_task_retrieve_status, isolated_task_retrieve_payload = invoke_request( + "POST", + f"/v0/tasks/{owner['task_id']}/artifact-chunks/retrieve", + payload={"user_id": str(intruder["user_id"]), "query": "Alpha beta"}, + ) + isolated_artifact_retrieve_status, isolated_artifact_retrieve_payload = invoke_request( + "POST", + f"/v0/task-artifacts/{notes_register_payload['artifact']['id']}/chunks/retrieve", + payload={"user_id": str(intruder["user_id"]), "query": "Alpha beta"}, + ) + + assert task_retrieve_status == 200 + assert task_retrieve_payload == { + "items": [ + { + "id": task_retrieve_payload["items"][0]["id"], + "task_id": str(owner["task_id"]), + "task_artifact_id": docs_register_payload["artifact"]["id"], + "relative_path": "docs/a.txt", + "media_type": "text/plain", + "sequence_no": 1, + "char_start": 0, + "char_end_exclusive": 14, + "text": "beta alpha doc", + "match": { + "matched_query_terms": ["alpha", "beta"], + "matched_query_term_count": 2, + "first_match_char_start": 0, + }, + }, + { + "id": task_retrieve_payload["items"][1]["id"], + "task_id": str(owner["task_id"]), + "task_artifact_id": notes_register_payload["artifact"]["id"], + "relative_path": "notes/b.md", + "media_type": "text/markdown", + "sequence_no": 1, + "char_start": 0, + "char_end_exclusive": 15, + "text": "alpha beta note", + "match": { + "matched_query_terms": ["alpha", "beta"], + "matched_query_term_count": 2, + "first_match_char_start": 0, + }, + }, + { + "id": task_retrieve_payload["items"][2]["id"], + "task_id": str(owner["task_id"]), + "task_artifact_id": weak_register_payload["artifact"]["id"], + "relative_path": "notes/c.txt", + "media_type": "text/plain", + "sequence_no": 1, + "char_start": 0, + "char_end_exclusive": 9, + "text": "beta only", + "match": { + "matched_query_terms": ["beta"], + "matched_query_term_count": 1, + "first_match_char_start": 0, + }, + }, + ], + "summary": { + "total_count": 3, + "searched_artifact_count": 3, + "query": "Alpha beta", + "query_terms": ["alpha", "beta"], + "matching_rule": TASK_ARTIFACT_CHUNK_RETRIEVAL_MATCHING_RULE, + "order": [ + "matched_query_term_count_desc", + "first_match_char_start_asc", + "relative_path_asc", + "sequence_no_asc", + "id_asc", + ], + "scope": { + "kind": "task", + "task_id": str(owner["task_id"]), + }, + }, + } + + assert artifact_retrieve_status == 200 + assert artifact_retrieve_payload == { + "items": [ + { + "id": artifact_retrieve_payload["items"][0]["id"], + "task_id": str(owner["task_id"]), + "task_artifact_id": notes_register_payload["artifact"]["id"], + "relative_path": "notes/b.md", + "media_type": "text/markdown", + "sequence_no": 1, + "char_start": 0, + "char_end_exclusive": 15, + "text": "alpha beta note", + "match": { + "matched_query_terms": ["alpha", "beta"], + "matched_query_term_count": 2, + "first_match_char_start": 0, + }, + } + ], + "summary": { + "total_count": 1, + "searched_artifact_count": 1, + "query": "Alpha beta", + "query_terms": ["alpha", "beta"], + "matching_rule": TASK_ARTIFACT_CHUNK_RETRIEVAL_MATCHING_RULE, + "order": [ + "matched_query_term_count_desc", + "first_match_char_start_asc", + "relative_path_asc", + "sequence_no_asc", + "id_asc", + ], + "scope": { + "kind": "artifact", + "task_id": str(owner["task_id"]), + "task_artifact_id": notes_register_payload["artifact"]["id"], + }, + }, + } + + assert empty_retrieve_status == 200 + assert empty_retrieve_payload == { + "items": [], + "summary": { + "total_count": 0, + "searched_artifact_count": 3, + "query": "missing", + "query_terms": ["missing"], + "matching_rule": TASK_ARTIFACT_CHUNK_RETRIEVAL_MATCHING_RULE, + "order": [ + "matched_query_term_count_desc", + "first_match_char_start_asc", + "relative_path_asc", + "sequence_no_asc", + "id_asc", + ], + "scope": { + "kind": "task", + "task_id": str(owner["task_id"]), + }, + }, + } + + assert isolated_task_retrieve_status == 404 + assert isolated_task_retrieve_payload == { + "detail": f"task {owner['task_id']} was not found" + } + + assert isolated_artifact_retrieve_status == 404 + assert isolated_artifact_retrieve_payload == { + "detail": f"task artifact {notes_register_payload['artifact']['id']} was not found" + } diff --git a/tests/integration/test_task_runs_api.py b/tests/integration/test_task_runs_api.py new file mode 100644 index 0000000..3e3664c --- /dev/null +++ b/tests/integration/test_task_runs_api.py @@ -0,0 +1,360 @@ +from __future__ import annotations + +import json +from typing import Any +from urllib.parse import urlencode +from uuid import UUID, uuid4 + +import anyio + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.db import user_connection +from alicebot_api.store import ContinuityStore + + +def invoke_request( + method: str, + path: str, + *, + query_params: dict[str, str] | None = None, + payload: dict[str, Any] | None = None, +) -> tuple[int, dict[str, Any]]: + messages: list[dict[str, object]] = [] + encoded_body = b"" if payload is None else json.dumps(payload).encode() + request_received = False + + async def receive() -> dict[str, object]: + nonlocal request_received + if request_received: + return {"type": "http.disconnect"} + + request_received = True + return {"type": "http.request", "body": encoded_body, "more_body": False} + + async def send(message: dict[str, object]) -> None: + messages.append(message) + + query_string = urlencode(query_params or {}).encode() + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": query_string, + "headers": [(b"content-type", b"application/json")], + "client": ("testclient", 50000), + "server": ("testserver", 80), + "root_path": "", + } + + anyio.run(main_module.app, scope, receive, send) + + start_message = next(message for message in messages if message["type"] == "http.response.start") + body = b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + return start_message["status"], json.loads(body) + + +def seed_user(database_url: str, *, email: str) -> dict[str, UUID]: + user_id = uuid4() + + with user_connection(database_url, user_id) as conn: + store = ContinuityStore(conn) + store.create_user(user_id, email, email.split("@", 1)[0].title()) + thread = store.create_thread("Task run thread") + + return { + "user_id": user_id, + "thread_id": thread["id"], + } + + +def seed_task(database_url: str, *, user_id: UUID, thread_id: UUID) -> UUID: + with user_connection(database_url, user_id) as conn: + store = ContinuityStore(conn) + tool = store.create_tool( + tool_key="proxy.echo", + name="Proxy Echo", + description="Deterministic proxy handler.", + version="1.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["proxy"], + action_hints=["tool.run"], + scope_hints=["workspace"], + domain_hints=[], + risk_hints=[], + metadata={"transport": "proxy"}, + ) + task = store.create_task( + thread_id=thread_id, + tool_id=tool["id"], + status="approved", + request={ + "thread_id": str(thread_id), + "tool_id": str(tool["id"]), + "action": "tool.run", + "scope": "workspace", + "domain_hint": None, + "risk_hint": None, + "attributes": {}, + }, + tool={ + "id": str(tool["id"]), + "tool_key": tool["tool_key"], + "name": tool["name"], + "description": tool["description"], + "version": tool["version"], + "metadata_version": tool["metadata_version"], + "active": tool["active"], + "tags": tool["tags"], + "action_hints": tool["action_hints"], + "scope_hints": tool["scope_hints"], + "domain_hints": tool["domain_hints"], + "risk_hints": tool["risk_hints"], + "metadata": tool["metadata"], + "created_at": tool["created_at"].isoformat(), + }, + latest_approval_id=None, + latest_execution_id=None, + ) + return task["id"] + + +def test_task_run_endpoints_create_list_get_tick_and_user_isolation( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_user(migrated_database_urls["app"], email="owner@example.com") + intruder = seed_user(migrated_database_urls["app"], email="intruder@example.com") + task_id = seed_task( + migrated_database_urls["app"], + user_id=owner["user_id"], + thread_id=owner["thread_id"], + ) + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + + create_status, create_payload = invoke_request( + "POST", + f"/v0/tasks/{task_id}/runs", + payload={ + "user_id": str(owner["user_id"]), + "max_ticks": 3, + "checkpoint": { + "cursor": 0, + "target_steps": 2, + "wait_for_signal": False, + }, + }, + ) + + list_status, list_payload = invoke_request( + "GET", + f"/v0/tasks/{task_id}/runs", + query_params={"user_id": str(owner["user_id"])}, + ) + detail_status, detail_payload = invoke_request( + "GET", + f"/v0/task-runs/{create_payload['task_run']['id']}", + query_params={"user_id": str(owner["user_id"])}, + ) + tick_status, tick_payload = invoke_request( + "POST", + f"/v0/task-runs/{create_payload['task_run']['id']}/tick", + payload={"user_id": str(owner["user_id"])}, + ) + + isolated_list_status, isolated_list_payload = invoke_request( + "GET", + f"/v0/tasks/{task_id}/runs", + query_params={"user_id": str(intruder["user_id"])}, + ) + isolated_detail_status, isolated_detail_payload = invoke_request( + "GET", + f"/v0/task-runs/{create_payload['task_run']['id']}", + query_params={"user_id": str(intruder["user_id"])}, + ) + + assert create_status == 201 + assert create_payload["task_run"]["task_id"] == str(task_id) + assert create_payload["task_run"]["status"] == "queued" + assert create_payload["task_run"]["tick_count"] == 0 + assert create_payload["task_run"]["step_count"] == 0 + assert create_payload["task_run"]["stop_reason"] is None + assert list_status == 200 + assert [item["id"] for item in list_payload["items"]] == [create_payload["task_run"]["id"]] + assert list_payload["summary"] == { + "task_id": str(task_id), + "total_count": 1, + "order": ["created_at_asc", "id_asc"], + } + assert detail_status == 200 + assert detail_payload == {"task_run": create_payload["task_run"]} + assert tick_status == 200 + assert tick_payload["previous_status"] == "queued" + assert tick_payload["task_run"]["status"] == "running" + assert tick_payload["task_run"]["checkpoint"]["cursor"] == 1 + assert tick_payload["task_run"]["tick_count"] == 1 + assert tick_payload["task_run"]["step_count"] == 1 + + assert isolated_list_status == 404 + assert isolated_list_payload == {"detail": f"task {task_id} was not found"} + assert isolated_detail_status == 404 + assert isolated_detail_payload == { + "detail": f"task run {create_payload['task_run']['id']} was not found" + } + + +def test_task_run_endpoints_cover_budget_wait_resume_pause_cancel_and_conflicts( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_user(migrated_database_urls["app"], email="owner-lifecycle@example.com") + task_id = seed_task( + migrated_database_urls["app"], + user_id=owner["user_id"], + thread_id=owner["thread_id"], + ) + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + + budget_create_status, budget_create_payload = invoke_request( + "POST", + f"/v0/tasks/{task_id}/runs", + payload={ + "user_id": str(owner["user_id"]), + "max_ticks": 1, + "checkpoint": { + "cursor": 0, + "target_steps": 3, + "wait_for_signal": False, + }, + }, + ) + assert budget_create_status == 201 + budget_run_id = budget_create_payload["task_run"]["id"] + + first_tick_status, first_tick_payload = invoke_request( + "POST", + f"/v0/task-runs/{budget_run_id}/tick", + payload={"user_id": str(owner["user_id"])}, + ) + second_tick_status, second_tick_payload = invoke_request( + "POST", + f"/v0/task-runs/{budget_run_id}/tick", + payload={"user_id": str(owner["user_id"])}, + ) + budget_resume_status, budget_resume_payload = invoke_request( + "POST", + f"/v0/task-runs/{budget_run_id}/resume", + payload={"user_id": str(owner["user_id"])}, + ) + + assert first_tick_status == 200 + assert first_tick_payload["task_run"]["status"] == "running" + assert second_tick_status == 200 + assert second_tick_payload["task_run"]["status"] == "failed" + assert second_tick_payload["task_run"]["stop_reason"] == "budget_exhausted" + assert second_tick_payload["task_run"]["failure_class"] == "budget" + assert second_tick_payload["task_run"]["retry_posture"] == "terminal" + assert budget_resume_status == 409 + assert budget_resume_payload == { + "detail": ( + f"task run {budget_run_id} is failed and cannot be resumed because failure class is terminal" + ) + } + + wait_create_status, wait_create_payload = invoke_request( + "POST", + f"/v0/tasks/{task_id}/runs", + payload={ + "user_id": str(owner["user_id"]), + "max_ticks": 3, + "checkpoint": { + "cursor": 0, + "target_steps": 2, + "wait_for_signal": True, + }, + }, + ) + assert wait_create_status == 201 + wait_run_id = wait_create_payload["task_run"]["id"] + + wait_tick_status, wait_tick_payload = invoke_request( + "POST", + f"/v0/task-runs/{wait_run_id}/tick", + payload={"user_id": str(owner["user_id"])}, + ) + wait_resume_status, wait_resume_payload = invoke_request( + "POST", + f"/v0/task-runs/{wait_run_id}/resume", + payload={"user_id": str(owner["user_id"])}, + ) + wait_pause_status, wait_pause_payload = invoke_request( + "POST", + f"/v0/task-runs/{wait_run_id}/pause", + payload={"user_id": str(owner["user_id"])}, + ) + wait_cancel_status, wait_cancel_payload = invoke_request( + "POST", + f"/v0/task-runs/{wait_run_id}/cancel", + payload={"user_id": str(owner["user_id"])}, + ) + wait_resume_conflict_status, wait_resume_conflict_payload = invoke_request( + "POST", + f"/v0/task-runs/{wait_run_id}/resume", + payload={"user_id": str(owner["user_id"])}, + ) + + assert wait_tick_status == 200 + assert wait_tick_payload["task_run"]["status"] == "waiting_user" + assert wait_tick_payload["task_run"]["stop_reason"] == "waiting_user" + assert wait_resume_status == 200 + assert wait_resume_payload["task_run"]["status"] == "running" + assert wait_resume_payload["task_run"]["checkpoint"]["wait_for_signal"] is False + assert wait_pause_status == 200 + assert wait_pause_payload["task_run"]["status"] == "paused" + assert wait_pause_payload["task_run"]["stop_reason"] == "paused" + assert wait_cancel_status == 200 + assert wait_cancel_payload["task_run"]["status"] == "cancelled" + assert wait_cancel_payload["task_run"]["stop_reason"] == "cancelled" + assert wait_resume_conflict_status == 409 + assert wait_resume_conflict_payload == { + "detail": f"task run {wait_run_id} is cancelled and cannot be resumed" + } + + +def test_task_run_create_endpoint_rejects_invalid_checkpoint( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_user(migrated_database_urls["app"], email="owner-invalid@example.com") + task_id = seed_task( + migrated_database_urls["app"], + user_id=owner["user_id"], + thread_id=owner["thread_id"], + ) + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + + status_code, payload = invoke_request( + "POST", + f"/v0/tasks/{task_id}/runs", + payload={ + "user_id": str(owner["user_id"]), + "max_ticks": 1, + "checkpoint": { + "cursor": "zero", + "target_steps": 1, + "wait_for_signal": False, + }, + }, + ) + + assert status_code == 400 + assert payload == {"detail": "checkpoint.cursor must be an integer"} diff --git a/tests/integration/test_task_workspaces_api.py b/tests/integration/test_task_workspaces_api.py new file mode 100644 index 0000000..31aa9d5 --- /dev/null +++ b/tests/integration/test_task_workspaces_api.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any +from urllib.parse import urlencode +from uuid import UUID, uuid4 + +import anyio + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.db import user_connection +from alicebot_api.store import ContinuityStore + + +def invoke_request( + method: str, + path: str, + *, + query_params: dict[str, str] | None = None, + payload: dict[str, Any] | None = None, +) -> tuple[int, dict[str, Any]]: + messages: list[dict[str, object]] = [] + encoded_body = b"" if payload is None else json.dumps(payload).encode() + request_received = False + + async def receive() -> dict[str, object]: + nonlocal request_received + if request_received: + return {"type": "http.disconnect"} + + request_received = True + return {"type": "http.request", "body": encoded_body, "more_body": False} + + async def send(message: dict[str, object]) -> None: + messages.append(message) + + query_string = urlencode(query_params or {}).encode() + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": query_string, + "headers": [(b"content-type", b"application/json")], + "client": ("testclient", 50000), + "server": ("testserver", 80), + "root_path": "", + } + + anyio.run(main_module.app, scope, receive, send) + + start_message = next(message for message in messages if message["type"] == "http.response.start") + body = b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + return start_message["status"], json.loads(body) + + +def seed_task(database_url: str, *, email: str) -> dict[str, UUID]: + user_id = uuid4() + + with user_connection(database_url, user_id) as conn: + store = ContinuityStore(conn) + store.create_user(user_id, email, email.split("@", 1)[0].title()) + thread = store.create_thread("Workspace thread") + tool = store.create_tool( + tool_key="proxy.echo", + name="Proxy Echo", + description="Deterministic proxy handler.", + version="1.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["proxy"], + action_hints=["tool.run"], + scope_hints=["workspace"], + domain_hints=[], + risk_hints=[], + metadata={"transport": "proxy"}, + ) + task = store.create_task( + thread_id=thread["id"], + tool_id=tool["id"], + status="approved", + request={ + "thread_id": str(thread["id"]), + "tool_id": str(tool["id"]), + "action": "tool.run", + "scope": "workspace", + "domain_hint": None, + "risk_hint": None, + "attributes": {}, + }, + tool={ + "id": str(tool["id"]), + "tool_key": "proxy.echo", + "name": "Proxy Echo", + "description": "Deterministic proxy handler.", + "version": "1.0.0", + "metadata_version": "tool_metadata_v0", + "active": True, + "tags": ["proxy"], + "action_hints": ["tool.run"], + "scope_hints": ["workspace"], + "domain_hints": [], + "risk_hints": [], + "metadata": {"transport": "proxy"}, + "created_at": tool["created_at"].isoformat(), + }, + latest_approval_id=None, + latest_execution_id=None, + ) + + return { + "user_id": user_id, + "task_id": task["id"], + } + + +def test_task_workspace_endpoints_provision_read_isolate_and_reject_duplicates( + migrated_database_urls, + monkeypatch, + tmp_path, +) -> None: + owner = seed_task(migrated_database_urls["app"], email="owner@example.com") + intruder = seed_task(migrated_database_urls["app"], email="intruder@example.com") + workspace_root = tmp_path / "task-workspaces" + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings( + database_url=migrated_database_urls["app"], + task_workspace_root=str(workspace_root), + ), + ) + + create_status, create_payload = invoke_request( + "POST", + f"/v0/tasks/{owner['task_id']}/workspace", + payload={"user_id": str(owner["user_id"])}, + ) + list_status, list_payload = invoke_request( + "GET", + "/v0/task-workspaces", + query_params={"user_id": str(owner["user_id"])}, + ) + detail_status, detail_payload = invoke_request( + "GET", + f"/v0/task-workspaces/{create_payload['workspace']['id']}", + query_params={"user_id": str(owner["user_id"])}, + ) + duplicate_status, duplicate_payload = invoke_request( + "POST", + f"/v0/tasks/{owner['task_id']}/workspace", + payload={"user_id": str(owner["user_id"])}, + ) + isolated_list_status, isolated_list_payload = invoke_request( + "GET", + "/v0/task-workspaces", + query_params={"user_id": str(intruder["user_id"])}, + ) + isolated_detail_status, isolated_detail_payload = invoke_request( + "GET", + f"/v0/task-workspaces/{create_payload['workspace']['id']}", + query_params={"user_id": str(intruder["user_id"])}, + ) + isolated_create_status, isolated_create_payload = invoke_request( + "POST", + f"/v0/tasks/{owner['task_id']}/workspace", + payload={"user_id": str(intruder["user_id"])}, + ) + + expected_path = (workspace_root / str(owner["user_id"]) / str(owner["task_id"])).resolve() + + assert create_status == 201 + assert create_payload["workspace"] == { + "id": create_payload["workspace"]["id"], + "task_id": str(owner["task_id"]), + "status": "active", + "local_path": str(expected_path), + "created_at": create_payload["workspace"]["created_at"], + "updated_at": create_payload["workspace"]["updated_at"], + } + assert Path(create_payload["workspace"]["local_path"]).is_dir() + + assert list_status == 200 + assert list_payload == { + "items": [create_payload["workspace"]], + "summary": {"total_count": 1, "order": ["created_at_asc", "id_asc"]}, + } + + assert detail_status == 200 + assert detail_payload == {"workspace": create_payload["workspace"]} + + assert duplicate_status == 409 + assert duplicate_payload == { + "detail": f"task {owner['task_id']} already has active workspace {create_payload['workspace']['id']}" + } + + assert isolated_list_status == 200 + assert isolated_list_payload == { + "items": [], + "summary": {"total_count": 0, "order": ["created_at_asc", "id_asc"]}, + } + + assert isolated_detail_status == 404 + assert isolated_detail_payload == { + "detail": f"task workspace {create_payload['workspace']['id']} was not found" + } + + assert isolated_create_status == 404 + assert isolated_create_payload == {"detail": f"task {owner['task_id']} was not found"} diff --git a/tests/integration/test_tasks_api.py b/tests/integration/test_tasks_api.py new file mode 100644 index 0000000..2987567 --- /dev/null +++ b/tests/integration/test_tasks_api.py @@ -0,0 +1,946 @@ +from __future__ import annotations + +import json +from typing import Any +from urllib.parse import urlencode +from uuid import UUID, uuid4 + +import anyio + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.db import user_connection +from alicebot_api.store import ContinuityStore + + +def invoke_request( + method: str, + path: str, + *, + query_params: dict[str, str] | None = None, + payload: dict[str, Any] | None = None, +) -> tuple[int, dict[str, Any]]: + messages: list[dict[str, object]] = [] + encoded_body = b"" if payload is None else json.dumps(payload).encode() + request_received = False + + async def receive() -> dict[str, object]: + nonlocal request_received + if request_received: + return {"type": "http.disconnect"} + + request_received = True + return {"type": "http.request", "body": encoded_body, "more_body": False} + + async def send(message: dict[str, object]) -> None: + messages.append(message) + + query_string = urlencode(query_params or {}).encode() + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": query_string, + "headers": [(b"content-type", b"application/json")], + "client": ("testclient", 50000), + "server": ("testserver", 80), + "root_path": "", + } + + anyio.run(main_module.app, scope, receive, send) + + start_message = next(message for message in messages if message["type"] == "http.response.start") + body = b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + return start_message["status"], json.loads(body) + + +def seed_user(database_url: str, *, email: str) -> dict[str, UUID]: + user_id = uuid4() + + with user_connection(database_url, user_id) as conn: + store = ContinuityStore(conn) + store.create_user(user_id, email, email.split("@", 1)[0].title()) + thread = store.create_thread("Task thread") + + return { + "user_id": user_id, + "thread_id": thread["id"], + } + + +def test_task_endpoints_list_detail_lifecycle_and_user_isolation( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_user(migrated_database_urls["app"], email="owner@example.com") + intruder = seed_user(migrated_database_urls["app"], email="intruder@example.com") + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + store = ContinuityStore(conn) + store.create_consent( + consent_key="web_access", + status="granted", + metadata={"source": "settings"}, + ) + approval_tool = store.create_tool( + tool_key="proxy.echo", + name="Proxy Echo", + description="Deterministic proxy handler.", + version="1.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["proxy"], + action_hints=["tool.run"], + scope_hints=["workspace"], + domain_hints=[], + risk_hints=[], + metadata={"transport": "proxy"}, + ) + ready_tool = store.create_tool( + tool_key="browser.open", + name="Browser Open", + description="Open documentation pages.", + version="1.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["browser"], + action_hints=["tool.run"], + scope_hints=["workspace"], + domain_hints=["docs"], + risk_hints=[], + metadata={"transport": "proxy"}, + ) + denied_tool = store.create_tool( + tool_key="calendar.read", + name="Calendar Read", + description="Read calendars.", + version="1.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["calendar"], + action_hints=["calendar.read"], + scope_hints=["calendar"], + domain_hints=[], + risk_hints=[], + metadata={}, + ) + store.create_policy( + name="Require proxy approval", + action="tool.run", + scope="workspace", + effect="require_approval", + priority=10, + active=True, + conditions={"tool_key": "proxy.echo"}, + required_consents=[], + ) + store.create_policy( + name="Allow docs browser", + action="tool.run", + scope="workspace", + effect="allow", + priority=20, + active=True, + conditions={"tool_key": "browser.open", "domain_hint": "docs"}, + required_consents=["web_access"], + ) + + pending_status, pending_payload = invoke_request( + "POST", + "/v0/approvals/requests", + payload={ + "user_id": str(owner["user_id"]), + "thread_id": str(owner["thread_id"]), + "tool_id": str(approval_tool["id"]), + "action": "tool.run", + "scope": "workspace", + "attributes": {"message": "hello"}, + }, + ) + assert pending_status == 200 + assert pending_payload["task"]["status"] == "pending_approval" + + approve_status, approve_payload = invoke_request( + "POST", + f"/v0/approvals/{pending_payload['approval']['id']}/approve", + payload={"user_id": str(owner["user_id"])}, + ) + assert approve_status == 200 + assert approve_payload["approval"]["status"] == "approved" + + execute_status, execute_payload = invoke_request( + "POST", + f"/v0/approvals/{pending_payload['approval']['id']}/execute", + payload={"user_id": str(owner["user_id"])}, + ) + assert execute_status == 200 + assert execute_payload["result"]["status"] == "completed" + + ready_status, ready_payload = invoke_request( + "POST", + "/v0/approvals/requests", + payload={ + "user_id": str(owner["user_id"]), + "thread_id": str(owner["thread_id"]), + "tool_id": str(ready_tool["id"]), + "action": "tool.run", + "scope": "workspace", + "domain_hint": "docs", + "attributes": {}, + }, + ) + assert ready_status == 200 + assert ready_payload["task"]["status"] == "approved" + + denied_status, denied_payload = invoke_request( + "POST", + "/v0/approvals/requests", + payload={ + "user_id": str(owner["user_id"]), + "thread_id": str(owner["thread_id"]), + "tool_id": str(denied_tool["id"]), + "action": "tool.run", + "scope": "workspace", + "attributes": {}, + }, + ) + assert denied_status == 200 + assert denied_payload["task"]["status"] == "denied" + + list_status, list_payload = invoke_request( + "GET", + "/v0/tasks", + query_params={"user_id": str(owner["user_id"])}, + ) + detail_status, detail_payload = invoke_request( + "GET", + f"/v0/tasks/{pending_payload['task']['id']}", + query_params={"user_id": str(owner["user_id"])}, + ) + step_list_status, step_list_payload = invoke_request( + "GET", + f"/v0/tasks/{pending_payload['task']['id']}/steps", + query_params={"user_id": str(owner["user_id"])}, + ) + step_detail_status, step_detail_payload = invoke_request( + "GET", + f"/v0/task-steps/{step_list_payload['items'][0]['id']}", + query_params={"user_id": str(owner['user_id'])}, + ) + isolated_list_status, isolated_list_payload = invoke_request( + "GET", + "/v0/tasks", + query_params={"user_id": str(intruder["user_id"])}, + ) + isolated_detail_status, isolated_detail_payload = invoke_request( + "GET", + f"/v0/tasks/{pending_payload['task']['id']}", + query_params={"user_id": str(intruder['user_id'])}, + ) + isolated_step_list_status, isolated_step_list_payload = invoke_request( + "GET", + f"/v0/tasks/{pending_payload['task']['id']}/steps", + query_params={"user_id": str(intruder['user_id'])}, + ) + isolated_step_detail_status, isolated_step_detail_payload = invoke_request( + "GET", + f"/v0/task-steps/{step_list_payload['items'][0]['id']}", + query_params={"user_id": str(intruder['user_id'])}, + ) + + assert list_status == 200 + assert [item["id"] for item in list_payload["items"]] == [ + pending_payload["task"]["id"], + ready_payload["task"]["id"], + denied_payload["task"]["id"], + ] + assert [item["status"] for item in list_payload["items"]] == [ + "executed", + "approved", + "denied", + ] + assert list_payload["summary"] == { + "total_count": 3, + "order": ["created_at_asc", "id_asc"], + } + + assert detail_status == 200 + assert detail_payload["task"]["id"] == pending_payload["task"]["id"] + assert detail_payload["task"]["status"] == "executed" + assert detail_payload["task"]["latest_approval_id"] == pending_payload["approval"]["id"] + assert detail_payload["task"]["latest_execution_id"] is not None + assert step_list_status == 200 + assert [item["sequence_no"] for item in step_list_payload["items"]] == [1] + assert step_list_payload["summary"] == { + "task_id": pending_payload["task"]["id"], + "total_count": 1, + "latest_sequence_no": 1, + "latest_status": "executed", + "next_sequence_no": 2, + "append_allowed": True, + "order": ["sequence_no_asc", "created_at_asc", "id_asc"], + } + assert step_list_payload["items"][0] == { + "id": step_list_payload["items"][0]["id"], + "task_id": pending_payload["task"]["id"], + "sequence_no": 1, + "lineage": { + "parent_step_id": None, + "source_approval_id": None, + "source_execution_id": None, + }, + "kind": "governed_request", + "status": "executed", + "request": pending_payload["request"], + "outcome": { + "routing_decision": "approval_required", + "approval_id": pending_payload["approval"]["id"], + "approval_status": "approved", + "execution_id": detail_payload["task"]["latest_execution_id"], + "execution_status": "completed", + "blocked_reason": None, + }, + "trace": { + "trace_id": execute_payload["trace"]["trace_id"], + "trace_kind": "tool.proxy.execute", + }, + "created_at": step_list_payload["items"][0]["created_at"], + "updated_at": step_list_payload["items"][0]["updated_at"], + } + assert step_detail_status == 200 + assert step_detail_payload == {"task_step": step_list_payload["items"][0]} + + assert isolated_list_status == 200 + assert isolated_list_payload == { + "items": [], + "summary": {"total_count": 0, "order": ["created_at_asc", "id_asc"]}, + } + assert isolated_detail_status == 404 + assert isolated_detail_payload == { + "detail": f"task {pending_payload['task']['id']} was not found" + } + assert isolated_step_list_status == 404 + assert isolated_step_list_payload == { + "detail": f"task {pending_payload['task']['id']} was not found" + } + assert isolated_step_detail_status == 404 + assert isolated_step_detail_payload == { + "detail": f"task step {step_list_payload['items'][0]['id']} was not found" + } + + +def test_task_step_sequence_and_transition_endpoints_preserve_parent_consistency_trace_and_isolation( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_user(migrated_database_urls["app"], email="owner-sequence@example.com") + intruder = seed_user(migrated_database_urls["app"], email="intruder-sequence@example.com") + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + store = ContinuityStore(conn) + tool = store.create_tool( + tool_key="proxy.echo", + name="Proxy Echo", + description="Deterministic proxy handler.", + version="1.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["proxy"], + action_hints=["tool.run"], + scope_hints=["workspace"], + domain_hints=[], + risk_hints=[], + metadata={"transport": "proxy"}, + ) + store.create_policy( + name="Require proxy approval", + action="tool.run", + scope="workspace", + effect="require_approval", + priority=10, + active=True, + conditions={"tool_key": "proxy.echo"}, + required_consents=[], + ) + + request_status, request_payload = invoke_request( + "POST", + "/v0/approvals/requests", + payload={ + "user_id": str(owner["user_id"]), + "thread_id": str(owner["thread_id"]), + "tool_id": str(tool["id"]), + "action": "tool.run", + "scope": "workspace", + "attributes": {"message": "seed-step"}, + }, + ) + assert request_status == 200 + approve_status, approve_payload = invoke_request( + "POST", + f"/v0/approvals/{request_payload['approval']['id']}/approve", + payload={"user_id": str(owner["user_id"])}, + ) + assert approve_status == 200 + execute_status, execute_payload = invoke_request( + "POST", + f"/v0/approvals/{request_payload['approval']['id']}/execute", + payload={"user_id": str(owner["user_id"])}, + ) + assert execute_status == 200 + initial_detail_status, initial_detail_payload = invoke_request( + "GET", + f"/v0/tasks/{request_payload['task']['id']}", + query_params={"user_id": str(owner["user_id"])}, + ) + assert initial_detail_status == 200 + initial_step_list_status, initial_step_list_payload = invoke_request( + "GET", + f"/v0/tasks/{request_payload['task']['id']}/steps", + query_params={"user_id": str(owner["user_id"])}, + ) + assert initial_step_list_status == 200 + initial_execution_id = initial_detail_payload["task"]["latest_execution_id"] + assert initial_execution_id is not None + + create_status, create_payload = invoke_request( + "POST", + f"/v0/tasks/{request_payload['task']['id']}/steps", + payload={ + "user_id": str(owner["user_id"]), + "kind": "governed_request", + "status": "created", + "request": { + "thread_id": str(owner["thread_id"]), + "tool_id": str(tool["id"]), + "action": "tool.run", + "scope": "workspace", + "attributes": {"message": "step-2"}, + }, + "outcome": { + "routing_decision": "approval_required", + "approval_status": None, + "approval_id": None, + "execution_id": None, + "execution_status": None, + "blocked_reason": None, + }, + "lineage": { + "parent_step_id": initial_step_list_payload["items"][0]["id"], + "source_approval_id": request_payload["approval"]["id"], + "source_execution_id": initial_execution_id, + }, + }, + ) + + assert create_status == 201 + assert create_payload["task"]["status"] == "pending_approval" + assert create_payload["task"]["latest_approval_id"] == request_payload["approval"]["id"] + assert create_payload["task_step"]["sequence_no"] == 2 + assert create_payload["task_step"]["status"] == "created" + assert create_payload["task_step"]["lineage"] == { + "parent_step_id": initial_step_list_payload["items"][0]["id"], + "source_approval_id": request_payload["approval"]["id"], + "source_execution_id": initial_execution_id, + } + assert create_payload["sequencing"] == { + "task_id": request_payload["task"]["id"], + "total_count": 2, + "latest_sequence_no": 2, + "latest_status": "created", + "next_sequence_no": 3, + "append_allowed": False, + "order": ["sequence_no_asc", "created_at_asc", "id_asc"], + } + + duplicate_create_status, duplicate_create_payload = invoke_request( + "POST", + f"/v0/tasks/{request_payload['task']['id']}/steps", + payload={ + "user_id": str(owner["user_id"]), + "kind": "governed_request", + "status": "created", + "request": { + "thread_id": str(owner["thread_id"]), + "tool_id": str(tool["id"]), + "action": "tool.run", + "scope": "workspace", + "attributes": {"message": "step-3"}, + }, + "outcome": { + "routing_decision": "approval_required", + "approval_status": None, + "approval_id": None, + "execution_id": None, + "execution_status": None, + "blocked_reason": None, + }, + "lineage": { + "parent_step_id": create_payload["task_step"]["id"], + "source_approval_id": request_payload["approval"]["id"], + "source_execution_id": initial_execution_id, + }, + }, + ) + assert duplicate_create_status == 409 + assert duplicate_create_payload["detail"] == ( + f"task {request_payload['task']['id']} latest step {create_payload['task_step']['id']} is created and cannot append a next step" + ) + + invalid_transition_status, invalid_transition_payload = invoke_request( + "POST", + f"/v0/task-steps/{create_payload['task_step']['id']}/transition", + payload={ + "user_id": str(owner["user_id"]), + "status": "executed", + "outcome": { + "routing_decision": "approval_required", + "approval_status": "approved", + "approval_id": str(uuid4()), + "execution_id": str(uuid4()), + "execution_status": "completed", + "blocked_reason": None, + }, + }, + ) + assert invalid_transition_status == 409 + assert invalid_transition_payload["detail"] == ( + f"task step {create_payload['task_step']['id']} is created and cannot transition to executed; allowed: approved, denied" + ) + + approve_step_status, approve_step_payload = invoke_request( + "POST", + f"/v0/task-steps/{create_payload['task_step']['id']}/transition", + payload={ + "user_id": str(owner["user_id"]), + "status": "approved", + "outcome": { + "routing_decision": "approval_required", + "approval_status": "approved", + "approval_id": request_payload["approval"]["id"], + "execution_id": None, + "execution_status": None, + "blocked_reason": None, + }, + }, + ) + assert approve_step_status == 200 + assert approve_step_payload["task"]["status"] == "approved" + assert approve_step_payload["task"]["latest_approval_id"] == request_payload["approval"]["id"] + assert approve_step_payload["task"]["latest_execution_id"] is None + assert approve_step_payload["task_step"]["status"] == "approved" + + execute_step_status, execute_step_payload = invoke_request( + "POST", + f"/v0/task-steps/{create_payload['task_step']['id']}/transition", + payload={ + "user_id": str(owner["user_id"]), + "status": "executed", + "outcome": { + "routing_decision": "approval_required", + "approval_status": "approved", + "approval_id": request_payload["approval"]["id"], + "execution_id": initial_execution_id, + "execution_status": "completed", + "blocked_reason": None, + }, + }, + ) + assert execute_step_status == 200 + assert execute_step_payload["task"]["status"] == "executed" + assert execute_step_payload["task"]["latest_approval_id"] == request_payload["approval"]["id"] + assert execute_step_payload["task"]["latest_execution_id"] == initial_execution_id + assert execute_step_payload["task_step"]["status"] == "executed" + assert execute_step_payload["sequencing"] == { + "task_id": request_payload["task"]["id"], + "total_count": 2, + "latest_sequence_no": 2, + "latest_status": "executed", + "next_sequence_no": 3, + "append_allowed": True, + "order": ["sequence_no_asc", "created_at_asc", "id_asc"], + } + + detail_status, detail_payload = invoke_request( + "GET", + f"/v0/tasks/{request_payload['task']['id']}", + query_params={"user_id": str(owner["user_id"])}, + ) + step_list_status, step_list_payload = invoke_request( + "GET", + f"/v0/tasks/{request_payload['task']['id']}/steps", + query_params={"user_id": str(owner["user_id"])}, + ) + step_detail_status, step_detail_payload = invoke_request( + "GET", + f"/v0/task-steps/{create_payload['task_step']['id']}", + query_params={"user_id": str(owner["user_id"])}, + ) + assert detail_status == 200 + assert detail_payload["task"]["status"] == "executed" + assert detail_payload["task"]["latest_approval_id"] == request_payload["approval"]["id"] + assert detail_payload["task"]["latest_execution_id"] == initial_execution_id + assert step_list_status == 200 + assert [item["sequence_no"] for item in step_list_payload["items"]] == [1, 2] + assert step_list_payload["items"][1]["lineage"] == create_payload["task_step"]["lineage"] + assert step_list_payload["summary"] == { + "task_id": request_payload["task"]["id"], + "total_count": 2, + "latest_sequence_no": 2, + "latest_status": "executed", + "next_sequence_no": 3, + "append_allowed": True, + "order": ["sequence_no_asc", "created_at_asc", "id_asc"], + } + assert step_detail_status == 200 + assert step_detail_payload["task_step"] == step_list_payload["items"][1] + assert step_detail_payload["task_step"]["lineage"] == create_payload["task_step"]["lineage"] + assert step_detail_payload["task_step"]["outcome"] == { + "routing_decision": "approval_required", + "approval_id": request_payload["approval"]["id"], + "approval_status": "approved", + "execution_id": initial_execution_id, + "execution_status": "completed", + "blocked_reason": None, + } + + isolated_create_status, isolated_create_payload = invoke_request( + "POST", + f"/v0/tasks/{request_payload['task']['id']}/steps", + payload={ + "user_id": str(intruder["user_id"]), + "kind": "governed_request", + "status": "created", + "request": { + "thread_id": str(owner["thread_id"]), + "tool_id": str(tool["id"]), + "action": "tool.run", + "scope": "workspace", + "attributes": {}, + }, + "outcome": { + "routing_decision": "approval_required", + "approval_status": None, + "approval_id": None, + "execution_id": None, + "execution_status": None, + "blocked_reason": None, + }, + "lineage": { + "parent_step_id": create_payload["task_step"]["id"], + "source_approval_id": request_payload["approval"]["id"], + "source_execution_id": initial_execution_id, + }, + }, + ) + isolated_transition_status, isolated_transition_payload = invoke_request( + "POST", + f"/v0/task-steps/{create_payload['task_step']['id']}/transition", + payload={ + "user_id": str(intruder["user_id"]), + "status": "approved", + "outcome": { + "routing_decision": "approval_required", + "approval_status": "approved", + "approval_id": str(uuid4()), + "execution_id": None, + "execution_status": None, + "blocked_reason": None, + }, + }, + ) + assert isolated_create_status == 404 + assert isolated_create_payload == { + "detail": f"task {request_payload['task']['id']} was not found" + } + assert isolated_transition_status == 404 + assert isolated_transition_payload == { + "detail": f"task step {create_payload['task_step']['id']} was not found" + } + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + store = ContinuityStore(conn) + create_trace_events = store.list_trace_events(UUID(create_payload["trace"]["trace_id"])) + transition_trace_events = store.list_trace_events(UUID(execute_step_payload["trace"]["trace_id"])) + + assert [event["kind"] for event in create_trace_events] == [ + "task.step.continuation.request", + "task.step.continuation.lineage", + "task.step.continuation.summary", + "task.lifecycle.state", + "task.lifecycle.summary", + "task.step.lifecycle.state", + "task.step.lifecycle.summary", + ] + assert create_trace_events[1]["payload"] == { + "task_id": request_payload["task"]["id"], + "parent_task_step_id": step_list_payload["items"][0]["id"], + "parent_sequence_no": 1, + "parent_status": "executed", + "source_approval_id": request_payload["approval"]["id"], + "source_execution_id": initial_execution_id, + } + assert [event["kind"] for event in transition_trace_events] == [ + "task.step.transition.request", + "task.step.transition.state", + "task.step.transition.summary", + "task.lifecycle.state", + "task.lifecycle.summary", + "task.step.lifecycle.state", + "task.step.lifecycle.summary", + ] + assert transition_trace_events[1]["payload"] == { + "task_id": request_payload["task"]["id"], + "task_step_id": create_payload["task_step"]["id"], + "sequence_no": 2, + "previous_status": "approved", + "current_status": "executed", + "allowed_next_statuses": ["executed", "blocked"], + "trace": { + "trace_id": execute_step_payload["trace"]["trace_id"], + "trace_kind": "task.step.transition", + }, + } + assert transition_trace_events[2]["payload"] == { + "task_id": request_payload["task"]["id"], + "task_step_id": create_payload["task_step"]["id"], + "sequence_no": 2, + "final_status": "executed", + "parent_task_status": "executed", + "trace": { + "trace_id": execute_step_payload["trace"]["trace_id"], + "trace_kind": "task.step.transition", + }, + } + + +def test_task_step_mutations_reject_visible_links_from_other_task_lineages( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_user(migrated_database_urls["app"], email="owner-lineage@example.com") + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + store = ContinuityStore(conn) + tool = store.create_tool( + tool_key="proxy.echo", + name="Proxy Echo", + description="Deterministic proxy handler.", + version="1.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["proxy"], + action_hints=["tool.run"], + scope_hints=["workspace"], + domain_hints=[], + risk_hints=[], + metadata={"transport": "proxy"}, + ) + store.create_policy( + name="Require proxy approval", + action="tool.run", + scope="workspace", + effect="require_approval", + priority=10, + active=True, + conditions={"tool_key": "proxy.echo"}, + required_consents=[], + ) + + first_request_status, first_request_payload = invoke_request( + "POST", + "/v0/approvals/requests", + payload={ + "user_id": str(owner["user_id"]), + "thread_id": str(owner["thread_id"]), + "tool_id": str(tool["id"]), + "action": "tool.run", + "scope": "workspace", + "attributes": {"message": "first"}, + }, + ) + assert first_request_status == 200 + first_approve_status, _ = invoke_request( + "POST", + f"/v0/approvals/{first_request_payload['approval']['id']}/approve", + payload={"user_id": str(owner["user_id"])}, + ) + assert first_approve_status == 200 + first_execute_status, _ = invoke_request( + "POST", + f"/v0/approvals/{first_request_payload['approval']['id']}/execute", + payload={"user_id": str(owner["user_id"])}, + ) + assert first_execute_status == 200 + first_detail_status, first_detail_payload = invoke_request( + "GET", + f"/v0/tasks/{first_request_payload['task']['id']}", + query_params={"user_id": str(owner["user_id"])}, + ) + assert first_detail_status == 200 + first_step_list_status, first_step_list_payload = invoke_request( + "GET", + f"/v0/tasks/{first_request_payload['task']['id']}/steps", + query_params={"user_id": str(owner["user_id"])}, + ) + assert first_step_list_status == 200 + first_step_id = first_step_list_payload["items"][0]["id"] + first_execution_id = first_detail_payload["task"]["latest_execution_id"] + assert first_execution_id is not None + + second_request_status, second_request_payload = invoke_request( + "POST", + "/v0/approvals/requests", + payload={ + "user_id": str(owner["user_id"]), + "thread_id": str(owner["thread_id"]), + "tool_id": str(tool["id"]), + "action": "tool.run", + "scope": "workspace", + "attributes": {"message": "second"}, + }, + ) + assert second_request_status == 200 + second_approve_status, _ = invoke_request( + "POST", + f"/v0/approvals/{second_request_payload['approval']['id']}/approve", + payload={"user_id": str(owner["user_id"])}, + ) + assert second_approve_status == 200 + second_execute_status, _ = invoke_request( + "POST", + f"/v0/approvals/{second_request_payload['approval']['id']}/execute", + payload={"user_id": str(owner["user_id"])}, + ) + assert second_execute_status == 200 + second_detail_status, second_detail_payload = invoke_request( + "GET", + f"/v0/tasks/{second_request_payload['task']['id']}", + query_params={"user_id": str(owner["user_id"])}, + ) + assert second_detail_status == 200 + second_execution_id = second_detail_payload["task"]["latest_execution_id"] + assert second_execution_id is not None + + wrong_create_status, wrong_create_payload = invoke_request( + "POST", + f"/v0/tasks/{first_request_payload['task']['id']}/steps", + payload={ + "user_id": str(owner["user_id"]), + "kind": "governed_request", + "status": "created", + "request": { + "thread_id": str(owner["thread_id"]), + "tool_id": str(tool["id"]), + "action": "tool.run", + "scope": "workspace", + "attributes": {"message": "lineage-mismatch"}, + }, + "outcome": { + "routing_decision": "approval_required", + "approval_status": None, + "approval_id": None, + "execution_id": None, + "execution_status": None, + "blocked_reason": None, + }, + "lineage": { + "parent_step_id": first_step_id, + "source_approval_id": second_request_payload["approval"]["id"], + "source_execution_id": None, + }, + }, + ) + assert wrong_create_status == 409 + assert wrong_create_payload == { + "detail": ( + f"approval {second_request_payload['approval']['id']} does not belong to task {first_request_payload['task']['id']}" + ) + } + + create_status, create_payload = invoke_request( + "POST", + f"/v0/tasks/{first_request_payload['task']['id']}/steps", + payload={ + "user_id": str(owner["user_id"]), + "kind": "governed_request", + "status": "created", + "request": { + "thread_id": str(owner["thread_id"]), + "tool_id": str(tool["id"]), + "action": "tool.run", + "scope": "workspace", + "attributes": {"message": "valid"}, + }, + "outcome": { + "routing_decision": "approval_required", + "approval_status": None, + "approval_id": None, + "execution_id": None, + "execution_status": None, + "blocked_reason": None, + }, + "lineage": { + "parent_step_id": first_step_id, + "source_approval_id": first_request_payload["approval"]["id"], + "source_execution_id": first_execution_id, + }, + }, + ) + assert create_status == 201 + + approve_status, approve_payload = invoke_request( + "POST", + f"/v0/task-steps/{create_payload['task_step']['id']}/transition", + payload={ + "user_id": str(owner["user_id"]), + "status": "approved", + "outcome": { + "routing_decision": "approval_required", + "approval_status": "approved", + "approval_id": first_request_payload["approval"]["id"], + "execution_id": None, + "execution_status": None, + "blocked_reason": None, + }, + }, + ) + assert approve_status == 200 + + wrong_execute_status, wrong_execute_payload = invoke_request( + "POST", + f"/v0/task-steps/{create_payload['task_step']['id']}/transition", + payload={ + "user_id": str(owner["user_id"]), + "status": "executed", + "outcome": { + "routing_decision": "approval_required", + "approval_status": "approved", + "approval_id": first_request_payload["approval"]["id"], + "execution_id": second_execution_id, + "execution_status": "completed", + "blocked_reason": None, + }, + }, + ) + assert wrong_execute_status == 409 + assert wrong_execute_payload == { + "detail": ( + f"tool execution {second_execution_id} does not belong to task {first_request_payload['task']['id']}" + ) + } + + assert first_execution_id != second_execution_id + assert first_request_payload["approval"]["id"] != second_request_payload["approval"]["id"] + assert approve_payload["task"]["latest_approval_id"] == first_request_payload["approval"]["id"] diff --git a/tests/integration/test_tool_api.py b/tests/integration/test_tool_api.py new file mode 100644 index 0000000..df7afd3 --- /dev/null +++ b/tests/integration/test_tool_api.py @@ -0,0 +1,930 @@ +from __future__ import annotations + +import json +from typing import Any +from urllib.parse import urlencode +from uuid import UUID, uuid4 + +import anyio + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.db import user_connection +from alicebot_api.store import ContinuityStore + + +def invoke_request( + method: str, + path: str, + *, + query_params: dict[str, str] | None = None, + payload: dict[str, Any] | None = None, +) -> tuple[int, dict[str, Any]]: + messages: list[dict[str, object]] = [] + encoded_body = b"" if payload is None else json.dumps(payload).encode() + request_received = False + + async def receive() -> dict[str, object]: + nonlocal request_received + if request_received: + return {"type": "http.disconnect"} + + request_received = True + return {"type": "http.request", "body": encoded_body, "more_body": False} + + async def send(message: dict[str, object]) -> None: + messages.append(message) + + query_string = urlencode(query_params or {}).encode() + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": query_string, + "headers": [(b"content-type", b"application/json")], + "client": ("testclient", 50000), + "server": ("testserver", 80), + "root_path": "", + } + + anyio.run(main_module.app, scope, receive, send) + + start_message = next(message for message in messages if message["type"] == "http.response.start") + body = b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + return start_message["status"], json.loads(body) + + +def seed_user(database_url: str, *, email: str) -> dict[str, UUID]: + user_id = uuid4() + + with user_connection(database_url, user_id) as conn: + store = ContinuityStore(conn) + store.create_user(user_id, email, email.split("@", 1)[0].title()) + thread = store.create_thread("Tool thread") + + return { + "user_id": user_id, + "thread_id": thread["id"], + } + + +def test_tool_endpoints_create_list_and_get_in_deterministic_order(migrated_database_urls, monkeypatch) -> None: + seeded = seed_user(migrated_database_urls["app"], email="owner@example.com") + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + + second_status, second_payload = invoke_request( + "POST", + "/v0/tools", + payload={ + "user_id": str(seeded["user_id"]), + "tool_key": "zeta.fetch", + "name": "Zeta Fetch", + "description": "Fetch zeta records.", + "version": "2.0.0", + "active": True, + "tags": ["fetch"], + "action_hints": ["tool.run"], + "scope_hints": ["workspace"], + "domain_hints": [], + "risk_hints": [], + "metadata": {"transport": "proxy"}, + }, + ) + first_status, first_payload = invoke_request( + "POST", + "/v0/tools", + payload={ + "user_id": str(seeded["user_id"]), + "tool_key": "alpha.open", + "name": "Alpha Open", + "description": "Open alpha pages.", + "version": "1.0.0", + "active": True, + "tags": ["browser"], + "action_hints": ["tool.run"], + "scope_hints": ["workspace"], + "domain_hints": ["docs"], + "risk_hints": [], + "metadata": {"transport": "proxy"}, + }, + ) + list_status, list_payload = invoke_request( + "GET", + "/v0/tools", + query_params={"user_id": str(seeded["user_id"])}, + ) + detail_status, detail_payload = invoke_request( + "GET", + f"/v0/tools/{second_payload['tool']['id']}", + query_params={"user_id": str(seeded['user_id'])}, + ) + + assert first_status == 201 + assert second_status == 201 + assert list_status == 200 + assert [item["tool_key"] for item in list_payload["items"]] == ["alpha.open", "zeta.fetch"] + assert list_payload["summary"] == { + "total_count": 2, + "order": ["tool_key_asc", "version_asc", "created_at_asc", "id_asc"], + } + assert detail_status == 200 + assert detail_payload == {"tool": second_payload["tool"]} + assert first_payload["tool"]["metadata_version"] == "tool_metadata_v0" + + +def test_tool_allowlist_evaluation_returns_allowed_denied_and_approval_required_with_trace( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_user(migrated_database_urls["app"], email="owner@example.com") + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + + with user_connection(migrated_database_urls["app"], seeded["user_id"]) as conn: + store = ContinuityStore(conn) + store.create_consent( + consent_key="web_access", + status="granted", + metadata={"source": "settings"}, + ) + allowed_tool = store.create_tool( + tool_key="browser.open", + name="Browser Open", + description="Open documentation pages.", + version="1.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["browser"], + action_hints=["tool.run"], + scope_hints=["workspace"], + domain_hints=["docs"], + risk_hints=[], + metadata={"transport": "proxy"}, + ) + denied_by_metadata_tool = store.create_tool( + tool_key="calendar.read", + name="Calendar Read", + description="Read calendars.", + version="1.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["calendar"], + action_hints=["calendar.read"], + scope_hints=["calendar"], + domain_hints=[], + risk_hints=[], + metadata={}, + ) + denied_by_consent_tool = store.create_tool( + tool_key="contacts.export", + name="Contacts Export", + description="Export contacts.", + version="1.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["contacts"], + action_hints=["tool.run"], + scope_hints=["workspace"], + domain_hints=["docs"], + risk_hints=[], + metadata={"transport": "proxy"}, + ) + approval_tool = store.create_tool( + tool_key="shell.exec", + name="Shell Exec", + description="Run shell commands.", + version="1.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["shell"], + action_hints=["tool.run"], + scope_hints=["workspace"], + domain_hints=[], + risk_hints=[], + metadata={"transport": "local"}, + ) + store.create_policy( + name="Allow docs browser", + action="tool.run", + scope="workspace", + effect="allow", + priority=10, + active=True, + conditions={"tool_key": "browser.open", "domain_hint": "docs"}, + required_consents=["web_access"], + ) + store.create_policy( + name="Allow contacts export with consent", + action="tool.run", + scope="workspace", + effect="allow", + priority=20, + active=True, + conditions={"tool_key": "contacts.export", "domain_hint": "docs"}, + required_consents=["contacts_consent"], + ) + store.create_policy( + name="Require shell approval", + action="tool.run", + scope="workspace", + effect="require_approval", + priority=30, + active=True, + conditions={"tool_key": "shell.exec"}, + required_consents=[], + ) + + status_code, payload = invoke_request( + "POST", + "/v0/tools/allowlist/evaluate", + payload={ + "user_id": str(seeded["user_id"]), + "thread_id": str(seeded["thread_id"]), + "action": "tool.run", + "scope": "workspace", + "domain_hint": "docs", + "attributes": {"channel": "chat"}, + }, + ) + + assert status_code == 200 + assert [item["tool"]["id"] for item in payload["allowed"]] == [str(allowed_tool["id"])] + assert [item["tool"]["id"] for item in payload["approval_required"]] == [str(approval_tool["id"])] + assert [item["tool"]["id"] for item in payload["denied"]] == [ + str(denied_by_metadata_tool["id"]), + str(denied_by_consent_tool["id"]), + ] + assert [reason["code"] for reason in payload["denied"][0]["reasons"]] == [ + "tool_action_unsupported", + "tool_scope_unsupported", + ] + assert [reason["code"] for reason in payload["denied"][1]["reasons"]] == [ + "tool_metadata_matched", + "matched_policy", + "consent_missing", + ] + assert payload["summary"] == { + "action": "tool.run", + "scope": "workspace", + "domain_hint": "docs", + "risk_hint": None, + "evaluated_tool_count": 4, + "allowed_count": 1, + "denied_count": 2, + "approval_required_count": 1, + "order": ["tool_key_asc", "version_asc", "created_at_asc", "id_asc"], + } + assert payload["trace"]["trace_event_count"] == 7 + + with user_connection(migrated_database_urls["app"], seeded["user_id"]) as conn: + store = ContinuityStore(conn) + trace = store.get_trace(UUID(payload["trace"]["trace_id"])) + trace_events = store.list_trace_events(UUID(payload["trace"]["trace_id"])) + + assert trace["kind"] == "tool.allowlist.evaluate" + assert trace["compiler_version"] == "tool_allowlist_evaluation_v0" + assert trace["limits"] == { + "order": ["tool_key_asc", "version_asc", "created_at_asc", "id_asc"], + "active_tool_count": 4, + "active_policy_count": 3, + "consent_count": 1, + } + assert [event["kind"] for event in trace_events] == [ + "tool.allowlist.request", + "tool.allowlist.order", + "tool.allowlist.decision", + "tool.allowlist.decision", + "tool.allowlist.decision", + "tool.allowlist.decision", + "tool.allowlist.summary", + ] + assert trace_events[2]["payload"]["decision"] == "allowed" + assert trace_events[-1]["payload"] == { + "allowed_count": 1, + "denied_count": 2, + "approval_required_count": 1, + } + + +def test_tool_route_returns_ready_denied_and_approval_required_with_trace( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_user(migrated_database_urls["app"], email="owner@example.com") + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + + with user_connection(migrated_database_urls["app"], seeded["user_id"]) as conn: + store = ContinuityStore(conn) + store.create_consent( + consent_key="web_access", + status="granted", + metadata={"source": "settings"}, + ) + ready_tool = store.create_tool( + tool_key="browser.open", + name="Browser Open", + description="Open documentation pages.", + version="1.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["browser"], + action_hints=["tool.run"], + scope_hints=["workspace"], + domain_hints=["docs"], + risk_hints=[], + metadata={"transport": "proxy"}, + ) + denied_tool = store.create_tool( + tool_key="calendar.read", + name="Calendar Read", + description="Read calendars.", + version="1.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["calendar"], + action_hints=["calendar.read"], + scope_hints=["calendar"], + domain_hints=[], + risk_hints=[], + metadata={}, + ) + approval_tool = store.create_tool( + tool_key="shell.exec", + name="Shell Exec", + description="Run shell commands.", + version="1.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["shell"], + action_hints=["tool.run"], + scope_hints=["workspace"], + domain_hints=[], + risk_hints=[], + metadata={"transport": "local"}, + ) + ready_policy = store.create_policy( + name="Allow docs browser", + action="tool.run", + scope="workspace", + effect="allow", + priority=10, + active=True, + conditions={"tool_key": "browser.open", "domain_hint": "docs"}, + required_consents=["web_access"], + ) + approval_policy = store.create_policy( + name="Require shell approval", + action="tool.run", + scope="workspace", + effect="require_approval", + priority=20, + active=True, + conditions={"tool_key": "shell.exec"}, + required_consents=[], + ) + + ready_status, ready_payload = invoke_request( + "POST", + "/v0/tools/route", + payload={ + "user_id": str(seeded["user_id"]), + "thread_id": str(seeded["thread_id"]), + "tool_id": str(ready_tool["id"]), + "action": "tool.run", + "scope": "workspace", + "domain_hint": "docs", + "attributes": {"channel": "chat"}, + }, + ) + denied_status, denied_payload = invoke_request( + "POST", + "/v0/tools/route", + payload={ + "user_id": str(seeded["user_id"]), + "thread_id": str(seeded["thread_id"]), + "tool_id": str(denied_tool["id"]), + "action": "tool.run", + "scope": "workspace", + "attributes": {}, + }, + ) + approval_status, approval_payload = invoke_request( + "POST", + "/v0/tools/route", + payload={ + "user_id": str(seeded["user_id"]), + "thread_id": str(seeded["thread_id"]), + "tool_id": str(approval_tool["id"]), + "action": "tool.run", + "scope": "workspace", + "attributes": {}, + }, + ) + + assert ready_status == 200 + assert list(ready_payload) == ["request", "decision", "tool", "reasons", "summary", "trace"] + assert ready_payload["decision"] == "ready" + assert ready_payload["request"] == { + "thread_id": str(seeded["thread_id"]), + "tool_id": str(ready_tool["id"]), + "action": "tool.run", + "scope": "workspace", + "domain_hint": "docs", + "risk_hint": None, + "attributes": {"channel": "chat"}, + } + assert ready_payload["tool"]["id"] == str(ready_tool["id"]) + assert [reason["code"] for reason in ready_payload["reasons"]] == [ + "tool_metadata_matched", + "matched_policy", + "policy_effect_allow", + ] + assert ready_payload["summary"] == { + "thread_id": str(seeded["thread_id"]), + "tool_id": str(ready_tool["id"]), + "action": "tool.run", + "scope": "workspace", + "domain_hint": "docs", + "risk_hint": None, + "decision": "ready", + "evaluated_tool_count": 1, + "active_policy_count": 2, + "consent_count": 1, + "order": ["tool_key_asc", "version_asc", "created_at_asc", "id_asc"], + } + assert ready_payload["trace"]["trace_event_count"] == 3 + + assert denied_status == 200 + assert denied_payload["decision"] == "denied" + assert [reason["code"] for reason in denied_payload["reasons"]] == [ + "tool_action_unsupported", + "tool_scope_unsupported", + ] + assert denied_payload["summary"]["decision"] == "denied" + + assert approval_status == 200 + assert approval_payload["decision"] == "approval_required" + assert approval_payload["summary"]["decision"] == "approval_required" + assert approval_payload["reasons"][-1] == { + "code": "policy_effect_require_approval", + "source": "policy", + "message": "Policy effect resolved the decision to 'require_approval'.", + "tool_id": str(approval_tool["id"]), + "policy_id": str(approval_policy["id"]), + "consent_key": None, + } + + with user_connection(migrated_database_urls["app"], seeded["user_id"]) as conn: + store = ContinuityStore(conn) + ready_trace = store.get_trace(UUID(ready_payload["trace"]["trace_id"])) + ready_trace_events = store.list_trace_events(UUID(ready_payload["trace"]["trace_id"])) + + assert ready_trace["kind"] == "tool.route" + assert ready_trace["compiler_version"] == "tool_routing_v0" + assert ready_trace["limits"] == { + "order": ["tool_key_asc", "version_asc", "created_at_asc", "id_asc"], + "evaluated_tool_count": 1, + "active_policy_count": 2, + "consent_count": 1, + } + assert [event["kind"] for event in ready_trace_events] == [ + "tool.route.request", + "tool.route.decision", + "tool.route.summary", + ] + assert ready_trace_events[1]["payload"] == { + "tool_id": str(ready_tool["id"]), + "tool_key": "browser.open", + "tool_version": "1.0.0", + "allowlist_decision": "allowed", + "routing_decision": "ready", + "matched_policy_id": str(ready_policy["id"]), + "reasons": ready_payload["reasons"], + } + assert ready_trace_events[2]["payload"] == { + "decision": "ready", + "evaluated_tool_count": 1, + "active_policy_count": 2, + "consent_count": 1, + } + + +def test_tool_route_validates_invalid_thread_and_tool(migrated_database_urls, monkeypatch) -> None: + seeded = seed_user(migrated_database_urls["app"], email="owner@example.com") + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + + with user_connection(migrated_database_urls["app"], seeded["user_id"]) as conn: + tool = ContinuityStore(conn).create_tool( + tool_key="browser.open", + name="Browser Open", + description="Open documentation pages.", + version="1.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["browser"], + action_hints=["tool.run"], + scope_hints=["workspace"], + domain_hints=[], + risk_hints=[], + metadata={"transport": "proxy"}, + ) + + invalid_thread_status, invalid_thread_payload = invoke_request( + "POST", + "/v0/tools/route", + payload={ + "user_id": str(seeded["user_id"]), + "thread_id": str(uuid4()), + "tool_id": str(tool["id"]), + "action": "tool.run", + "scope": "workspace", + "attributes": {}, + }, + ) + invalid_tool_status, invalid_tool_payload = invoke_request( + "POST", + "/v0/tools/route", + payload={ + "user_id": str(seeded["user_id"]), + "thread_id": str(seeded["thread_id"]), + "tool_id": str(uuid4()), + "action": "tool.run", + "scope": "workspace", + "attributes": {}, + }, + ) + + assert invalid_thread_status == 400 + assert invalid_thread_payload == { + "detail": "thread_id must reference an existing thread owned by the user" + } + assert invalid_tool_status == 400 + assert invalid_tool_payload == { + "detail": "tool_id must reference an existing active tool owned by the user" + } + + +def test_tool_endpoints_and_allowlist_enforce_per_user_isolation(migrated_database_urls, monkeypatch) -> None: + owner = seed_user(migrated_database_urls["app"], email="owner@example.com") + intruder = seed_user(migrated_database_urls["app"], email="intruder@example.com") + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + owner_tool = ContinuityStore(conn).create_tool( + tool_key="browser.open", + name="Browser Open", + description="Open documentation pages.", + version="1.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["browser"], + action_hints=["tool.run"], + scope_hints=["workspace"], + domain_hints=["docs"], + risk_hints=[], + metadata={"transport": "proxy"}, + ) + + list_status, list_payload = invoke_request( + "GET", + "/v0/tools", + query_params={"user_id": str(intruder["user_id"])}, + ) + detail_status, detail_payload = invoke_request( + "GET", + f"/v0/tools/{owner_tool['id']}", + query_params={"user_id": str(intruder["user_id"])}, + ) + evaluation_status, evaluation_payload = invoke_request( + "POST", + "/v0/tools/allowlist/evaluate", + payload={ + "user_id": str(intruder["user_id"]), + "thread_id": str(intruder["thread_id"]), + "action": "tool.run", + "scope": "workspace", + "attributes": {}, + }, + ) + + assert list_status == 200 + assert list_payload == { + "items": [], + "summary": { + "total_count": 0, + "order": ["tool_key_asc", "version_asc", "created_at_asc", "id_asc"], + }, + } + assert detail_status == 404 + assert detail_payload == {"detail": f"tool {owner_tool['id']} was not found"} + assert evaluation_status == 200 + assert evaluation_payload["allowed"] == [] + assert evaluation_payload["denied"] == [] + assert evaluation_payload["approval_required"] == [] + assert evaluation_payload["summary"]["evaluated_tool_count"] == 0 + + +def test_tool_routing_returns_ready_denied_and_approval_required_with_trace( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_user(migrated_database_urls["app"], email="owner@example.com") + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + + with user_connection(migrated_database_urls["app"], seeded["user_id"]) as conn: + store = ContinuityStore(conn) + store.create_consent( + consent_key="web_access", + status="granted", + metadata={"source": "settings"}, + ) + ready_tool = store.create_tool( + tool_key="browser.open", + name="Browser Open", + description="Open documentation pages.", + version="1.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["browser"], + action_hints=["tool.run"], + scope_hints=["workspace"], + domain_hints=["docs"], + risk_hints=[], + metadata={"transport": "proxy"}, + ) + denied_tool = store.create_tool( + tool_key="contacts.export", + name="Contacts Export", + description="Export contacts.", + version="1.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["contacts"], + action_hints=["tool.run"], + scope_hints=["workspace"], + domain_hints=["docs"], + risk_hints=[], + metadata={"transport": "proxy"}, + ) + approval_tool = store.create_tool( + tool_key="shell.exec", + name="Shell Exec", + description="Run shell commands.", + version="1.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["shell"], + action_hints=["tool.run"], + scope_hints=["workspace"], + domain_hints=[], + risk_hints=[], + metadata={"transport": "local"}, + ) + store.create_policy( + name="Allow docs browser", + action="tool.run", + scope="workspace", + effect="allow", + priority=10, + active=True, + conditions={"tool_key": "browser.open", "domain_hint": "docs"}, + required_consents=["web_access"], + ) + store.create_policy( + name="Allow contacts export with consent", + action="tool.run", + scope="workspace", + effect="allow", + priority=20, + active=True, + conditions={"tool_key": "contacts.export", "domain_hint": "docs"}, + required_consents=["contacts_consent"], + ) + store.create_policy( + name="Require shell approval", + action="tool.run", + scope="workspace", + effect="require_approval", + priority=30, + active=True, + conditions={"tool_key": "shell.exec"}, + required_consents=[], + ) + + ready_status, ready_payload = invoke_request( + "POST", + "/v0/tools/route", + payload={ + "user_id": str(seeded["user_id"]), + "thread_id": str(seeded["thread_id"]), + "tool_id": str(ready_tool["id"]), + "action": "tool.run", + "scope": "workspace", + "domain_hint": "docs", + "attributes": {"channel": "chat"}, + }, + ) + denied_status, denied_payload = invoke_request( + "POST", + "/v0/tools/route", + payload={ + "user_id": str(seeded["user_id"]), + "thread_id": str(seeded["thread_id"]), + "tool_id": str(denied_tool["id"]), + "action": "tool.run", + "scope": "workspace", + "domain_hint": "docs", + "attributes": {}, + }, + ) + approval_status, approval_payload = invoke_request( + "POST", + "/v0/tools/route", + payload={ + "user_id": str(seeded["user_id"]), + "thread_id": str(seeded["thread_id"]), + "tool_id": str(approval_tool["id"]), + "action": "tool.run", + "scope": "workspace", + "attributes": {}, + }, + ) + + assert ready_status == 200 + assert ready_payload["decision"] == "ready" + assert ready_payload["tool"]["id"] == str(ready_tool["id"]) + assert ready_payload["summary"] == { + "thread_id": str(seeded["thread_id"]), + "tool_id": str(ready_tool["id"]), + "action": "tool.run", + "scope": "workspace", + "domain_hint": "docs", + "risk_hint": None, + "decision": "ready", + "evaluated_tool_count": 1, + "active_policy_count": 3, + "consent_count": 1, + "order": ["tool_key_asc", "version_asc", "created_at_asc", "id_asc"], + } + assert ready_payload["trace"]["trace_event_count"] == 3 + + assert denied_status == 200 + assert denied_payload["decision"] == "denied" + assert [reason["code"] for reason in denied_payload["reasons"]] == [ + "tool_metadata_matched", + "matched_policy", + "consent_missing", + ] + + assert approval_status == 200 + assert approval_payload["decision"] == "approval_required" + assert approval_payload["reasons"][-1]["code"] == "policy_effect_require_approval" + + with user_connection(migrated_database_urls["app"], seeded["user_id"]) as conn: + store = ContinuityStore(conn) + trace = store.get_trace(UUID(ready_payload["trace"]["trace_id"])) + trace_events = store.list_trace_events(UUID(ready_payload["trace"]["trace_id"])) + + assert trace["kind"] == "tool.route" + assert trace["compiler_version"] == "tool_routing_v0" + assert trace["limits"] == { + "order": ["tool_key_asc", "version_asc", "created_at_asc", "id_asc"], + "evaluated_tool_count": 1, + "active_policy_count": 3, + "consent_count": 1, + } + assert [event["kind"] for event in trace_events] == [ + "tool.route.request", + "tool.route.decision", + "tool.route.summary", + ] + assert trace_events[1]["payload"]["allowlist_decision"] == "allowed" + assert trace_events[1]["payload"]["routing_decision"] == "ready" + assert trace_events[2]["payload"] == { + "decision": "ready", + "evaluated_tool_count": 1, + "active_policy_count": 3, + "consent_count": 1, + } + + +def test_tool_routing_validates_invalid_references_and_per_user_isolation( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_user(migrated_database_urls["app"], email="owner@example.com") + intruder = seed_user(migrated_database_urls["app"], email="intruder@example.com") + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + owner_tool = ContinuityStore(conn).create_tool( + tool_key="browser.open", + name="Browser Open", + description="Open documentation pages.", + version="1.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["browser"], + action_hints=["tool.run"], + scope_hints=["workspace"], + domain_hints=["docs"], + risk_hints=[], + metadata={"transport": "proxy"}, + ) + + invalid_thread_status, invalid_thread_payload = invoke_request( + "POST", + "/v0/tools/route", + payload={ + "user_id": str(owner["user_id"]), + "thread_id": str(uuid4()), + "tool_id": str(owner_tool["id"]), + "action": "tool.run", + "scope": "workspace", + "attributes": {}, + }, + ) + invalid_tool_status, invalid_tool_payload = invoke_request( + "POST", + "/v0/tools/route", + payload={ + "user_id": str(owner["user_id"]), + "thread_id": str(owner["thread_id"]), + "tool_id": str(uuid4()), + "action": "tool.run", + "scope": "workspace", + "attributes": {}, + }, + ) + isolation_status, isolation_payload = invoke_request( + "POST", + "/v0/tools/route", + payload={ + "user_id": str(intruder["user_id"]), + "thread_id": str(intruder["thread_id"]), + "tool_id": str(owner_tool["id"]), + "action": "tool.run", + "scope": "workspace", + "attributes": {}, + }, + ) + + assert invalid_thread_status == 400 + assert invalid_thread_payload == { + "detail": "thread_id must reference an existing thread owned by the user" + } + assert invalid_tool_status == 400 + assert invalid_tool_payload == { + "detail": "tool_id must reference an existing active tool owned by the user" + } + assert isolation_status == 400 + assert isolation_payload == { + "detail": "tool_id must reference an existing active tool owned by the user" + } + + +def test_tool_route_enforces_per_user_isolation(migrated_database_urls, monkeypatch) -> None: + owner = seed_user(migrated_database_urls["app"], email="owner@example.com") + intruder = seed_user(migrated_database_urls["app"], email="intruder@example.com") + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url=migrated_database_urls["app"])) + + with user_connection(migrated_database_urls["app"], owner["user_id"]) as conn: + owner_tool = ContinuityStore(conn).create_tool( + tool_key="browser.open", + name="Browser Open", + description="Open documentation pages.", + version="1.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["browser"], + action_hints=["tool.run"], + scope_hints=["workspace"], + domain_hints=["docs"], + risk_hints=[], + metadata={"transport": "proxy"}, + ) + + route_status, route_payload = invoke_request( + "POST", + "/v0/tools/route", + payload={ + "user_id": str(intruder["user_id"]), + "thread_id": str(intruder["thread_id"]), + "tool_id": str(owner_tool["id"]), + "action": "tool.run", + "scope": "workspace", + "attributes": {}, + }, + ) + + assert route_status == 400 + assert route_payload == { + "detail": "tool_id must reference an existing active tool owned by the user" + } diff --git a/tests/integration/test_traces_api.py b/tests/integration/test_traces_api.py new file mode 100644 index 0000000..3daf0d7 --- /dev/null +++ b/tests/integration/test_traces_api.py @@ -0,0 +1,269 @@ +from __future__ import annotations + +import json +from typing import Any +from urllib.parse import urlencode +from uuid import UUID, uuid4 + +import anyio + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.db import user_connection +from alicebot_api.store import ContinuityStore + + +def invoke_request( + method: str, + path: str, + *, + query_params: dict[str, str] | None = None, + payload: dict[str, Any] | None = None, +) -> tuple[int, dict[str, Any]]: + messages: list[dict[str, object]] = [] + encoded_body = b"" if payload is None else json.dumps(payload).encode() + request_received = False + + async def receive() -> dict[str, object]: + nonlocal request_received + if request_received: + return {"type": "http.disconnect"} + + request_received = True + return {"type": "http.request", "body": encoded_body, "more_body": False} + + async def send(message: dict[str, object]) -> None: + messages.append(message) + + query_string = urlencode(query_params or {}).encode() + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": query_string, + "headers": [(b"content-type", b"application/json")], + "client": ("testclient", 50000), + "server": ("testserver", 80), + "root_path": "", + } + + anyio.run(main_module.app, scope, receive, send) + + start_message = next(message for message in messages if message["type"] == "http.response.start") + body = b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + return start_message["status"], json.loads(body) + + +def create_user(database_url: str, *, email: str) -> UUID: + user_id = uuid4() + with user_connection(database_url, user_id) as conn: + ContinuityStore(conn).create_user(user_id, email, email.split("@", 1)[0].title()) + return user_id + + +def seed_user_with_traces(database_url: str, *, email: str) -> dict[str, object]: + user_id = create_user(database_url, email=email) + + with user_connection(database_url, user_id) as conn: + store = ContinuityStore(conn) + thread = store.create_thread("Trace review thread") + first_trace = store.create_trace( + user_id=user_id, + thread_id=thread["id"], + kind="context.compile", + compiler_version="continuity_v0", + status="completed", + limits={"max_sessions": 3, "max_events": 8}, + ) + second_trace = store.create_trace( + user_id=user_id, + thread_id=thread["id"], + kind="tool.proxy.execute", + compiler_version="response_generation_v0", + status="completed", + limits={"max_sessions": 1, "max_events": 2}, + ) + first_trace_event = store.append_trace_event( + trace_id=second_trace["id"], + sequence_no=2, + kind="tool.proxy.execute.summary", + payload={"approval_id": "approval-2"}, + ) + second_trace_event = store.append_trace_event( + trace_id=second_trace["id"], + sequence_no=1, + kind="tool.proxy.execute.request", + payload={"approval_id": "approval-2"}, + ) + third_trace_event = store.append_trace_event( + trace_id=first_trace["id"], + sequence_no=1, + kind="context.summary", + payload={"thread_id": str(thread["id"])}, + ) + + return { + "user_id": user_id, + "thread_id": thread["id"], + "first_trace": first_trace, + "second_trace": second_trace, + "events": { + "second_trace_second": first_trace_event, + "second_trace_first": second_trace_event, + "first_trace_only": third_trace_event, + }, + } + + +def serialize_trace_summary(trace: dict[str, Any], *, trace_event_count: int) -> dict[str, Any]: + return { + "id": str(trace["id"]), + "thread_id": str(trace["thread_id"]), + "kind": trace["kind"], + "compiler_version": trace["compiler_version"], + "status": trace["status"], + "created_at": trace["created_at"].isoformat(), + "trace_event_count": trace_event_count, + } + + +def serialize_trace_detail(trace: dict[str, Any], *, trace_event_count: int) -> dict[str, Any]: + return { + **serialize_trace_summary(trace, trace_event_count=trace_event_count), + "limits": trace["limits"], + } + + +def serialize_trace_event(trace_event: dict[str, Any]) -> dict[str, Any]: + return { + "id": str(trace_event["id"]), + "trace_id": str(trace_event["trace_id"]), + "sequence_no": trace_event["sequence_no"], + "kind": trace_event["kind"], + "payload": trace_event["payload"], + "created_at": trace_event["created_at"].isoformat(), + } + + +def test_trace_review_endpoints_list_detail_and_events_with_deterministic_order( + migrated_database_urls, + monkeypatch, +) -> None: + seeded = seed_user_with_traces(migrated_database_urls["app"], email="owner@example.com") + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + list_status, list_payload = invoke_request( + "GET", + "/v0/traces", + query_params={"user_id": str(seeded["user_id"])}, + ) + detail_status, detail_payload = invoke_request( + "GET", + f"/v0/traces/{seeded['second_trace']['id']}", + query_params={"user_id": str(seeded["user_id"])}, + ) + events_status, events_payload = invoke_request( + "GET", + f"/v0/traces/{seeded['second_trace']['id']}/events", + query_params={"user_id": str(seeded["user_id"])}, + ) + + expected_trace_order = sorted( + [seeded["first_trace"], seeded["second_trace"]], + key=lambda trace: (trace["created_at"], trace["id"]), + reverse=True, + ) + + assert list_status == 200 + assert list_payload == { + "items": [ + serialize_trace_summary( + expected_trace_order[0], + trace_event_count=2 if expected_trace_order[0]["id"] == seeded["second_trace"]["id"] else 1, + ), + serialize_trace_summary( + expected_trace_order[1], + trace_event_count=2 if expected_trace_order[1]["id"] == seeded["second_trace"]["id"] else 1, + ), + ], + "summary": { + "total_count": 2, + "order": ["created_at_desc", "id_desc"], + }, + } + + assert detail_status == 200 + assert detail_payload == { + "trace": serialize_trace_detail(seeded["second_trace"], trace_event_count=2) + } + + assert events_status == 200 + assert events_payload == { + "items": [ + serialize_trace_event(seeded["events"]["second_trace_first"]), + serialize_trace_event(seeded["events"]["second_trace_second"]), + ], + "summary": { + "trace_id": str(seeded["second_trace"]["id"]), + "total_count": 2, + "order": ["sequence_no_asc", "id_asc"], + }, + } + + +def test_trace_review_endpoints_enforce_user_isolation_and_not_found( + migrated_database_urls, + monkeypatch, +) -> None: + owner = seed_user_with_traces(migrated_database_urls["app"], email="owner@example.com") + intruder_id = create_user(migrated_database_urls["app"], email="intruder@example.com") + monkeypatch.setattr( + main_module, + "get_settings", + lambda: Settings(database_url=migrated_database_urls["app"]), + ) + + list_status, list_payload = invoke_request( + "GET", + "/v0/traces", + query_params={"user_id": str(intruder_id)}, + ) + detail_status, detail_payload = invoke_request( + "GET", + f"/v0/traces/{owner['second_trace']['id']}", + query_params={"user_id": str(intruder_id)}, + ) + events_status, events_payload = invoke_request( + "GET", + f"/v0/traces/{owner['second_trace']['id']}/events", + query_params={"user_id": str(intruder_id)}, + ) + + assert list_status == 200 + assert list_payload == { + "items": [], + "summary": { + "total_count": 0, + "order": ["created_at_desc", "id_desc"], + }, + } + assert detail_status == 404 + assert detail_payload == { + "detail": f"trace {owner['second_trace']['id']} was not found", + } + assert events_status == 404 + assert events_payload == { + "detail": f"trace {owner['second_trace']['id']} was not found", + } diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_20260310_0001_foundation_continuity.py b/tests/unit/test_20260310_0001_foundation_continuity.py new file mode 100644 index 0000000..713bc44 --- /dev/null +++ b/tests/unit/test_20260310_0001_foundation_continuity.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import importlib + + +MODULE_NAME = "apps.api.alembic.versions.20260310_0001_foundation_continuity" + + +def load_migration_module(): + return importlib.import_module(MODULE_NAME) + + +def test_upgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.upgrade() + + assert executed == [ + *module._UPGRADE_BOOTSTRAP_STATEMENTS, + module._UPGRADE_SCHEMA_STATEMENT, + module._UPGRADE_TRIGGER_STATEMENT, + *module._UPGRADE_GRANT_STATEMENTS, + "ALTER TABLE users ENABLE ROW LEVEL SECURITY", + "ALTER TABLE users FORCE ROW LEVEL SECURITY", + "ALTER TABLE threads ENABLE ROW LEVEL SECURITY", + "ALTER TABLE threads FORCE ROW LEVEL SECURITY", + "ALTER TABLE sessions ENABLE ROW LEVEL SECURITY", + "ALTER TABLE sessions FORCE ROW LEVEL SECURITY", + "ALTER TABLE events ENABLE ROW LEVEL SECURITY", + "ALTER TABLE events FORCE ROW LEVEL SECURITY", + module._UPGRADE_POLICY_STATEMENT, + ] + + +def test_downgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.downgrade() + + assert executed == list(module._DOWNGRADE_STATEMENTS) + + +def test_base_downgrade_does_not_drop_global_extensions() -> None: + module = load_migration_module() + + assert "DROP EXTENSION IF EXISTS vector" not in module._DOWNGRADE_STATEMENTS + assert "DROP EXTENSION IF EXISTS pgcrypto" not in module._DOWNGRADE_STATEMENTS + + +def test_base_schema_does_not_create_redundant_events_sequence_index() -> None: + module = load_migration_module() + + assert "CREATE INDEX events_thread_sequence_idx" not in module._UPGRADE_SCHEMA_STATEMENT + + +def test_base_schema_keeps_thread_created_index_for_deterministic_review_queries() -> None: + module = load_migration_module() + + assert "CREATE INDEX threads_user_created_idx" in module._UPGRADE_SCHEMA_STATEMENT diff --git a/tests/unit/test_20260311_0002_tighten_runtime_privileges.py b/tests/unit/test_20260311_0002_tighten_runtime_privileges.py new file mode 100644 index 0000000..af0925d --- /dev/null +++ b/tests/unit/test_20260311_0002_tighten_runtime_privileges.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import importlib + + +MODULE_NAME = "apps.api.alembic.versions.20260311_0002_tighten_runtime_privileges" + + +def load_migration_module(): + return importlib.import_module(MODULE_NAME) + + +def test_upgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.upgrade() + + assert executed == list(module._UPGRADE_STATEMENTS) + + +def test_downgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.downgrade() + + assert executed == list(module._DOWNGRADE_STATEMENTS) + + +def test_downgrade_reasserts_revision_0001_privilege_floor() -> None: + module = load_migration_module() + + assert module._DOWNGRADE_STATEMENTS == ( + "REVOKE UPDATE ON users FROM alicebot_app", + "REVOKE UPDATE ON threads FROM alicebot_app", + "REVOKE UPDATE ON sessions FROM alicebot_app", + ) diff --git a/tests/unit/test_20260311_0003_trace_backbone.py b/tests/unit/test_20260311_0003_trace_backbone.py new file mode 100644 index 0000000..5780912 --- /dev/null +++ b/tests/unit/test_20260311_0003_trace_backbone.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import importlib + + +MODULE_NAME = "apps.api.alembic.versions.20260311_0003_trace_backbone" + + +def load_migration_module(): + return importlib.import_module(MODULE_NAME) + + +def test_upgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.upgrade() + + assert executed == [ + *module._UPGRADE_BOOTSTRAP_STATEMENTS, + module._UPGRADE_SCHEMA_STATEMENT, + module._UPGRADE_TRIGGER_STATEMENT, + *module._UPGRADE_GRANT_STATEMENTS, + "ALTER TABLE traces ENABLE ROW LEVEL SECURITY", + "ALTER TABLE traces FORCE ROW LEVEL SECURITY", + "ALTER TABLE trace_events ENABLE ROW LEVEL SECURITY", + "ALTER TABLE trace_events FORCE ROW LEVEL SECURITY", + module._UPGRADE_POLICY_STATEMENT, + ] + + +def test_downgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.downgrade() + + assert executed == list(module._DOWNGRADE_STATEMENTS) + + +def test_trace_tables_keep_runtime_role_at_select_insert_only() -> None: + module = load_migration_module() + + assert module._UPGRADE_GRANT_STATEMENTS == ( + "GRANT SELECT, INSERT ON traces TO alicebot_app", + "GRANT SELECT, INSERT ON trace_events TO alicebot_app", + ) diff --git a/tests/unit/test_20260311_0004_memory_admission.py b/tests/unit/test_20260311_0004_memory_admission.py new file mode 100644 index 0000000..fe561e1 --- /dev/null +++ b/tests/unit/test_20260311_0004_memory_admission.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import importlib + + +MODULE_NAME = "apps.api.alembic.versions.20260311_0004_memory_admission" + + +def load_migration_module(): + return importlib.import_module(MODULE_NAME) + + +def test_upgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.upgrade() + + assert executed == [ + *module._UPGRADE_BOOTSTRAP_STATEMENTS, + module._UPGRADE_SCHEMA_STATEMENT, + module._UPGRADE_TRIGGER_STATEMENT, + *module._UPGRADE_GRANT_STATEMENTS, + "ALTER TABLE memories ENABLE ROW LEVEL SECURITY", + "ALTER TABLE memories FORCE ROW LEVEL SECURITY", + "ALTER TABLE memory_revisions ENABLE ROW LEVEL SECURITY", + "ALTER TABLE memory_revisions FORCE ROW LEVEL SECURITY", + module._UPGRADE_POLICY_STATEMENT, + ] + + +def test_downgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.downgrade() + + assert executed == list(module._DOWNGRADE_STATEMENTS) + + +def test_memory_table_privileges_stay_narrow() -> None: + module = load_migration_module() + + assert module._UPGRADE_GRANT_STATEMENTS == ( + "GRANT SELECT, INSERT, UPDATE ON memories TO alicebot_app", + "GRANT SELECT, INSERT ON memory_revisions TO alicebot_app", + ) diff --git a/tests/unit/test_20260312_0005_memory_review_labels.py b/tests/unit/test_20260312_0005_memory_review_labels.py new file mode 100644 index 0000000..2476797 --- /dev/null +++ b/tests/unit/test_20260312_0005_memory_review_labels.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import importlib + + +MODULE_NAME = "apps.api.alembic.versions.20260312_0005_memory_review_labels" + + +def load_migration_module(): + return importlib.import_module(MODULE_NAME) + + +def test_upgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.upgrade() + + assert executed == [ + *module._UPGRADE_BOOTSTRAP_STATEMENTS, + module._UPGRADE_SCHEMA_STATEMENT, + module._UPGRADE_TRIGGER_STATEMENT, + *module._UPGRADE_GRANT_STATEMENTS, + "ALTER TABLE memory_review_labels ENABLE ROW LEVEL SECURITY", + "ALTER TABLE memory_review_labels FORCE ROW LEVEL SECURITY", + module._UPGRADE_POLICY_STATEMENT, + ] + + +def test_downgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.downgrade() + + assert executed == list(module._DOWNGRADE_STATEMENTS) + + +def test_memory_review_label_table_privileges_stay_append_only() -> None: + module = load_migration_module() + + assert module._UPGRADE_GRANT_STATEMENTS == ( + "GRANT SELECT, INSERT ON memory_review_labels TO alicebot_app", + ) diff --git a/tests/unit/test_20260312_0006_entities_backbone.py b/tests/unit/test_20260312_0006_entities_backbone.py new file mode 100644 index 0000000..d099878 --- /dev/null +++ b/tests/unit/test_20260312_0006_entities_backbone.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import importlib + + +MODULE_NAME = "apps.api.alembic.versions.20260312_0006_entities_backbone" + + +def load_migration_module(): + return importlib.import_module(MODULE_NAME) + + +def test_upgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.upgrade() + + assert executed == [ + module._UPGRADE_SCHEMA_STATEMENT, + *module._UPGRADE_GRANT_STATEMENTS, + "ALTER TABLE entities ENABLE ROW LEVEL SECURITY", + "ALTER TABLE entities FORCE ROW LEVEL SECURITY", + module._UPGRADE_POLICY_STATEMENT, + ] + + +def test_downgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.downgrade() + + assert executed == list(module._DOWNGRADE_STATEMENTS) + + +def test_entities_table_privileges_stay_insert_select_only() -> None: + module = load_migration_module() + + assert module._UPGRADE_GRANT_STATEMENTS == ( + "GRANT SELECT, INSERT ON entities TO alicebot_app", + ) diff --git a/tests/unit/test_20260312_0007_entity_edges.py b/tests/unit/test_20260312_0007_entity_edges.py new file mode 100644 index 0000000..255b9fb --- /dev/null +++ b/tests/unit/test_20260312_0007_entity_edges.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import importlib + + +MODULE_NAME = "apps.api.alembic.versions.20260312_0007_entity_edges" + + +def load_migration_module(): + return importlib.import_module(MODULE_NAME) + + +def test_upgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.upgrade() + + assert executed == [ + module._UPGRADE_SCHEMA_STATEMENT, + *module._UPGRADE_GRANT_STATEMENTS, + "ALTER TABLE entity_edges ENABLE ROW LEVEL SECURITY", + "ALTER TABLE entity_edges FORCE ROW LEVEL SECURITY", + module._UPGRADE_POLICY_STATEMENT, + ] + + +def test_downgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.downgrade() + + assert executed == list(module._DOWNGRADE_STATEMENTS) + + +def test_entity_edges_table_privileges_stay_insert_select_only() -> None: + module = load_migration_module() + + assert module._UPGRADE_GRANT_STATEMENTS == ( + "GRANT SELECT, INSERT ON entity_edges TO alicebot_app", + ) diff --git a/tests/unit/test_20260312_0008_embedding_substrate.py b/tests/unit/test_20260312_0008_embedding_substrate.py new file mode 100644 index 0000000..240286f --- /dev/null +++ b/tests/unit/test_20260312_0008_embedding_substrate.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import importlib + + +MODULE_NAME = "apps.api.alembic.versions.20260312_0008_embedding_substrate" + + +def load_migration_module(): + return importlib.import_module(MODULE_NAME) + + +def test_upgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.upgrade() + + assert executed == [ + module._UPGRADE_SCHEMA_STATEMENT, + *module._UPGRADE_GRANT_STATEMENTS, + "ALTER TABLE embedding_configs ENABLE ROW LEVEL SECURITY", + "ALTER TABLE embedding_configs FORCE ROW LEVEL SECURITY", + "ALTER TABLE memory_embeddings ENABLE ROW LEVEL SECURITY", + "ALTER TABLE memory_embeddings FORCE ROW LEVEL SECURITY", + module._UPGRADE_POLICY_STATEMENT, + ] + + +def test_downgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.downgrade() + + assert executed == list(module._DOWNGRADE_STATEMENTS) + + +def test_embedding_tables_privileges_stay_narrow() -> None: + module = load_migration_module() + + assert module._UPGRADE_GRANT_STATEMENTS == ( + "GRANT SELECT, INSERT ON embedding_configs TO alicebot_app", + "GRANT SELECT, INSERT, UPDATE ON memory_embeddings TO alicebot_app", + ) diff --git a/tests/unit/test_20260312_0009_policy_and_consent_core.py b/tests/unit/test_20260312_0009_policy_and_consent_core.py new file mode 100644 index 0000000..b926485 --- /dev/null +++ b/tests/unit/test_20260312_0009_policy_and_consent_core.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import importlib + + +MODULE_NAME = "apps.api.alembic.versions.20260312_0009_policy_and_consent_core" + + +def load_migration_module(): + return importlib.import_module(MODULE_NAME) + + +def test_upgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.upgrade() + + assert executed == [ + module._UPGRADE_SCHEMA_STATEMENT, + *module._UPGRADE_GRANT_STATEMENTS, + "ALTER TABLE consents ENABLE ROW LEVEL SECURITY", + "ALTER TABLE consents FORCE ROW LEVEL SECURITY", + "ALTER TABLE policies ENABLE ROW LEVEL SECURITY", + "ALTER TABLE policies FORCE ROW LEVEL SECURITY", + module._UPGRADE_POLICY_STATEMENT, + ] + + +def test_downgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.downgrade() + + assert executed == list(module._DOWNGRADE_STATEMENTS) + + +def test_policy_and_consent_privileges_stay_narrow() -> None: + module = load_migration_module() + + assert module._UPGRADE_GRANT_STATEMENTS == ( + "GRANT SELECT, INSERT, UPDATE ON consents TO alicebot_app", + "GRANT SELECT, INSERT ON policies TO alicebot_app", + ) diff --git a/tests/unit/test_20260312_0010_tools_registry_and_allowlist.py b/tests/unit/test_20260312_0010_tools_registry_and_allowlist.py new file mode 100644 index 0000000..b7c4215 --- /dev/null +++ b/tests/unit/test_20260312_0010_tools_registry_and_allowlist.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import importlib + + +MODULE_NAME = "apps.api.alembic.versions.20260312_0010_tools_registry_and_allowlist" + + +def load_migration_module(): + return importlib.import_module(MODULE_NAME) + + +def test_upgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.upgrade() + + assert executed == [ + module._UPGRADE_SCHEMA_STATEMENT, + *module._UPGRADE_GRANT_STATEMENTS, + "ALTER TABLE tools ENABLE ROW LEVEL SECURITY", + "ALTER TABLE tools FORCE ROW LEVEL SECURITY", + module._UPGRADE_POLICY_STATEMENT, + ] + + +def test_downgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.downgrade() + + assert executed == list(module._DOWNGRADE_STATEMENTS) + + +def test_tools_privileges_stay_narrow() -> None: + module = load_migration_module() + + assert module._UPGRADE_GRANT_STATEMENTS == ( + "GRANT SELECT, INSERT ON tools TO alicebot_app", + ) diff --git a/tests/unit/test_20260312_0011_approval_request_records.py b/tests/unit/test_20260312_0011_approval_request_records.py new file mode 100644 index 0000000..00c051b --- /dev/null +++ b/tests/unit/test_20260312_0011_approval_request_records.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import importlib + + +MODULE_NAME = "apps.api.alembic.versions.20260312_0011_approval_request_records" + + +def load_migration_module(): + return importlib.import_module(MODULE_NAME) + + +def test_upgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.upgrade() + + assert executed == [ + module._UPGRADE_SCHEMA_STATEMENT, + *module._UPGRADE_GRANT_STATEMENTS, + "ALTER TABLE approvals ENABLE ROW LEVEL SECURITY", + "ALTER TABLE approvals FORCE ROW LEVEL SECURITY", + module._UPGRADE_POLICY_STATEMENT, + ] + + +def test_downgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.downgrade() + + assert executed == list(module._DOWNGRADE_STATEMENTS) + + +def test_approvals_privileges_stay_narrow() -> None: + module = load_migration_module() + + assert module._UPGRADE_GRANT_STATEMENTS == ( + "GRANT SELECT, INSERT ON approvals TO alicebot_app", + ) diff --git a/tests/unit/test_20260312_0012_approval_resolution.py b/tests/unit/test_20260312_0012_approval_resolution.py new file mode 100644 index 0000000..7e37cd5 --- /dev/null +++ b/tests/unit/test_20260312_0012_approval_resolution.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import importlib + + +MODULE_NAME = "apps.api.alembic.versions.20260312_0012_approval_resolution" + + +def load_migration_module(): + return importlib.import_module(MODULE_NAME) + + +def test_upgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.upgrade() + + assert executed == [ + module._UPGRADE_SCHEMA_STATEMENT, + *module._UPGRADE_GRANT_STATEMENTS, + ] + + +def test_downgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.downgrade() + + assert executed == list(module._DOWNGRADE_STATEMENTS) + + +def test_approvals_resolution_privileges_stay_narrow() -> None: + module = load_migration_module() + + assert module._UPGRADE_GRANT_STATEMENTS == ( + "GRANT UPDATE ON approvals TO alicebot_app", + ) diff --git a/tests/unit/test_20260313_0013_tool_executions.py b/tests/unit/test_20260313_0013_tool_executions.py new file mode 100644 index 0000000..84e4f67 --- /dev/null +++ b/tests/unit/test_20260313_0013_tool_executions.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import importlib + + +MODULE_NAME = "apps.api.alembic.versions.20260313_0013_tool_executions" + + +def load_migration_module(): + return importlib.import_module(MODULE_NAME) + + +def test_upgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.upgrade() + + assert executed == [ + module._UPGRADE_SCHEMA_STATEMENT, + *module._UPGRADE_GRANT_STATEMENTS, + "ALTER TABLE tool_executions ENABLE ROW LEVEL SECURITY", + "ALTER TABLE tool_executions FORCE ROW LEVEL SECURITY", + module._UPGRADE_POLICY_STATEMENT, + ] + + +def test_downgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.downgrade() + + assert executed == list(module._DOWNGRADE_STATEMENTS) + + +def test_tool_executions_privileges_stay_narrow() -> None: + module = load_migration_module() + + assert module._UPGRADE_GRANT_STATEMENTS == ( + "GRANT SELECT, INSERT ON tool_executions TO alicebot_app", + ) diff --git a/tests/unit/test_20260313_0014_execution_budgets.py b/tests/unit/test_20260313_0014_execution_budgets.py new file mode 100644 index 0000000..a1cadf3 --- /dev/null +++ b/tests/unit/test_20260313_0014_execution_budgets.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import importlib + + +MODULE_NAME = "apps.api.alembic.versions.20260313_0014_execution_budgets" + + +def load_migration_module(): + return importlib.import_module(MODULE_NAME) + + +def test_upgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.upgrade() + + assert executed == [ + module._UPGRADE_SCHEMA_STATEMENT, + *module._UPGRADE_GRANT_STATEMENTS, + "ALTER TABLE execution_budgets ENABLE ROW LEVEL SECURITY", + "ALTER TABLE execution_budgets FORCE ROW LEVEL SECURITY", + module._UPGRADE_POLICY_STATEMENT, + ] + + +def test_downgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.downgrade() + + assert executed == list(module._DOWNGRADE_STATEMENTS) + + +def test_execution_budgets_privileges_stay_narrow() -> None: + module = load_migration_module() + + assert module._UPGRADE_GRANT_STATEMENTS == ( + "GRANT SELECT, INSERT ON execution_budgets TO alicebot_app", + ) diff --git a/tests/unit/test_20260313_0015_execution_budget_lifecycle.py b/tests/unit/test_20260313_0015_execution_budget_lifecycle.py new file mode 100644 index 0000000..f1a7468 --- /dev/null +++ b/tests/unit/test_20260313_0015_execution_budget_lifecycle.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import importlib + + +MODULE_NAME = "apps.api.alembic.versions.20260313_0015_execution_budget_lifecycle" + + +def load_migration_module(): + return importlib.import_module(MODULE_NAME) + + +def test_upgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.upgrade() + + assert executed == list(module._UPGRADE_STATEMENTS) + + +def test_downgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.downgrade() + + assert executed == list(module._DOWNGRADE_STATEMENTS) + + +def test_execution_budget_lifecycle_privileges_stay_narrow() -> None: + module = load_migration_module() + + assert module._UPGRADE_STATEMENTS[-1] == "GRANT SELECT, INSERT, UPDATE ON execution_budgets TO alicebot_app" diff --git a/tests/unit/test_20260313_0016_execution_budget_rolling_window.py b/tests/unit/test_20260313_0016_execution_budget_rolling_window.py new file mode 100644 index 0000000..631b0bb --- /dev/null +++ b/tests/unit/test_20260313_0016_execution_budget_rolling_window.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import importlib + + +MODULE_NAME = "apps.api.alembic.versions.20260313_0016_execution_budget_rolling_window" + + +def load_migration_module(): + return importlib.import_module(MODULE_NAME) + + +def test_upgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.upgrade() + + assert executed == list(module._UPGRADE_STATEMENTS) + + +def test_downgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.downgrade() + + assert executed == list(module._DOWNGRADE_STATEMENTS) diff --git a/tests/unit/test_20260313_0018_task_steps.py b/tests/unit/test_20260313_0018_task_steps.py new file mode 100644 index 0000000..c3ab793 --- /dev/null +++ b/tests/unit/test_20260313_0018_task_steps.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import importlib + + +MODULE_NAME = "apps.api.alembic.versions.20260313_0018_task_steps" + + +def load_migration_module(): + return importlib.import_module(MODULE_NAME) + + +def test_upgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.upgrade() + + assert executed == [ + module._UPGRADE_SCHEMA_STATEMENT, + *module._UPGRADE_GRANT_STATEMENTS, + "ALTER TABLE task_steps ENABLE ROW LEVEL SECURITY", + "ALTER TABLE task_steps FORCE ROW LEVEL SECURITY", + module._UPGRADE_POLICY_STATEMENT, + ] + + +def test_downgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.downgrade() + + assert executed == list(module._DOWNGRADE_STATEMENTS) + + +def test_task_step_privileges_allow_only_expected_runtime_writes() -> None: + module = load_migration_module() + + assert module._UPGRADE_GRANT_STATEMENTS == ( + "GRANT SELECT, INSERT, UPDATE ON task_steps TO alicebot_app", + ) diff --git a/tests/unit/test_20260313_0019_task_step_lineage.py b/tests/unit/test_20260313_0019_task_step_lineage.py new file mode 100644 index 0000000..68fdcca --- /dev/null +++ b/tests/unit/test_20260313_0019_task_step_lineage.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import importlib + + +MODULE_NAME = "apps.api.alembic.versions.20260313_0019_task_step_lineage" + + +def load_migration_module(): + return importlib.import_module(MODULE_NAME) + + +def test_upgrade_executes_expected_statement(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.upgrade() + + assert executed == [module._UPGRADE_SCHEMA_STATEMENT] + + +def test_downgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.downgrade() + + assert executed == list(module._DOWNGRADE_STATEMENTS) diff --git a/tests/unit/test_20260313_0020_approval_task_step_linkage.py b/tests/unit/test_20260313_0020_approval_task_step_linkage.py new file mode 100644 index 0000000..5f7816a --- /dev/null +++ b/tests/unit/test_20260313_0020_approval_task_step_linkage.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import importlib + + +MODULE_NAME = "apps.api.alembic.versions.20260313_0020_approval_task_step_linkage" + + +def load_migration_module(): + return importlib.import_module(MODULE_NAME) + + +def test_upgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.upgrade() + + assert executed == [module._UPGRADE_SCHEMA_STATEMENT] + + +def test_downgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.downgrade() + + assert executed == list(module._DOWNGRADE_STATEMENTS) diff --git a/tests/unit/test_20260313_0021_tool_execution_task_step_linkage.py b/tests/unit/test_20260313_0021_tool_execution_task_step_linkage.py new file mode 100644 index 0000000..31f5330 --- /dev/null +++ b/tests/unit/test_20260313_0021_tool_execution_task_step_linkage.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import importlib + + +MODULE_NAME = "apps.api.alembic.versions.20260313_0021_tool_execution_task_step_linkage" + + +def load_migration_module(): + return importlib.import_module(MODULE_NAME) + + +def test_upgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.upgrade() + + assert executed == list(module._UPGRADE_STATEMENTS) + + +def test_downgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.downgrade() + + assert executed == list(module._DOWNGRADE_STATEMENTS) diff --git a/tests/unit/test_20260313_0022_task_workspaces.py b/tests/unit/test_20260313_0022_task_workspaces.py new file mode 100644 index 0000000..6e352b9 --- /dev/null +++ b/tests/unit/test_20260313_0022_task_workspaces.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import importlib + + +MODULE_NAME = "apps.api.alembic.versions.20260313_0022_task_workspaces" + + +def load_migration_module(): + return importlib.import_module(MODULE_NAME) + + +def test_upgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.upgrade() + + assert executed == [ + module._UPGRADE_SCHEMA_STATEMENT, + *module._UPGRADE_GRANT_STATEMENTS, + "ALTER TABLE task_workspaces ENABLE ROW LEVEL SECURITY", + "ALTER TABLE task_workspaces FORCE ROW LEVEL SECURITY", + module._UPGRADE_POLICY_STATEMENT, + ] + + +def test_downgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.downgrade() + + assert executed == list(module._DOWNGRADE_STATEMENTS) + + +def test_task_workspace_privileges_allow_only_expected_runtime_writes() -> None: + module = load_migration_module() + + assert module._UPGRADE_GRANT_STATEMENTS == ( + "GRANT SELECT, INSERT ON task_workspaces TO alicebot_app", + ) diff --git a/tests/unit/test_20260313_0023_task_artifacts.py b/tests/unit/test_20260313_0023_task_artifacts.py new file mode 100644 index 0000000..f6fd17d --- /dev/null +++ b/tests/unit/test_20260313_0023_task_artifacts.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import importlib + + +MODULE_NAME = "apps.api.alembic.versions.20260313_0023_task_artifacts" + + +def load_migration_module(): + return importlib.import_module(MODULE_NAME) + + +def test_upgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.upgrade() + + assert executed == [ + module._UPGRADE_SCHEMA_STATEMENT, + *module._UPGRADE_GRANT_STATEMENTS, + "ALTER TABLE task_artifacts ENABLE ROW LEVEL SECURITY", + "ALTER TABLE task_artifacts FORCE ROW LEVEL SECURITY", + module._UPGRADE_POLICY_STATEMENT, + ] + + +def test_downgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.downgrade() + + assert executed == list(module._DOWNGRADE_STATEMENTS) + + +def test_task_artifact_privileges_allow_only_expected_runtime_writes() -> None: + module = load_migration_module() + + assert module._UPGRADE_GRANT_STATEMENTS == ( + "GRANT SELECT, INSERT ON task_artifacts TO alicebot_app", + ) diff --git a/tests/unit/test_20260314_0024_task_artifact_chunks.py b/tests/unit/test_20260314_0024_task_artifact_chunks.py new file mode 100644 index 0000000..5e9e6da --- /dev/null +++ b/tests/unit/test_20260314_0024_task_artifact_chunks.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import importlib + + +MODULE_NAME = "apps.api.alembic.versions.20260314_0024_task_artifact_chunks" + + +def load_migration_module(): + return importlib.import_module(MODULE_NAME) + + +def test_upgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.upgrade() + + assert executed == [ + *module._UPGRADE_TASK_ARTIFACTS_STATEMENTS, + module._UPGRADE_SCHEMA_STATEMENT, + *module._UPGRADE_GRANT_STATEMENTS, + "ALTER TABLE task_artifact_chunks ENABLE ROW LEVEL SECURITY", + "ALTER TABLE task_artifact_chunks FORCE ROW LEVEL SECURITY", + module._UPGRADE_POLICY_STATEMENT, + ] + + +def test_downgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.downgrade() + + assert executed == list(module._DOWNGRADE_STATEMENTS) + + +def test_task_artifact_chunk_privileges_allow_only_expected_runtime_writes() -> None: + module = load_migration_module() + + assert module._UPGRADE_GRANT_STATEMENTS == ( + "GRANT UPDATE ON task_artifacts TO alicebot_app", + "GRANT SELECT, INSERT ON task_artifact_chunks TO alicebot_app", + ) diff --git a/tests/unit/test_20260314_0025_task_artifact_chunk_embeddings.py b/tests/unit/test_20260314_0025_task_artifact_chunk_embeddings.py new file mode 100644 index 0000000..0844b8e --- /dev/null +++ b/tests/unit/test_20260314_0025_task_artifact_chunk_embeddings.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import importlib + + +MODULE_NAME = "apps.api.alembic.versions.20260314_0025_task_artifact_chunk_embeddings" + + +def load_migration_module(): + return importlib.import_module(MODULE_NAME) + + +def test_upgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.upgrade() + + assert executed == [ + module._UPGRADE_SCHEMA_STATEMENT, + *module._UPGRADE_GRANT_STATEMENTS, + "ALTER TABLE task_artifact_chunk_embeddings ENABLE ROW LEVEL SECURITY", + "ALTER TABLE task_artifact_chunk_embeddings FORCE ROW LEVEL SECURITY", + module._UPGRADE_POLICY_STATEMENT, + ] + + +def test_downgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.downgrade() + + assert executed == list(module._DOWNGRADE_STATEMENTS) + + +def test_task_artifact_chunk_embedding_privileges_stay_narrow() -> None: + module = load_migration_module() + + assert module._UPGRADE_GRANT_STATEMENTS == ( + "GRANT SELECT, INSERT, UPDATE ON task_artifact_chunk_embeddings TO alicebot_app", + ) diff --git a/tests/unit/test_20260316_0026_gmail_accounts.py b/tests/unit/test_20260316_0026_gmail_accounts.py new file mode 100644 index 0000000..cc0153a --- /dev/null +++ b/tests/unit/test_20260316_0026_gmail_accounts.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import importlib + + +MODULE_NAME = "apps.api.alembic.versions.20260316_0026_gmail_accounts" + + +def load_migration_module(): + return importlib.import_module(MODULE_NAME) + + +def test_upgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.upgrade() + + assert executed == [ + module._UPGRADE_SCHEMA_STATEMENT, + *module._UPGRADE_GRANT_STATEMENTS, + "ALTER TABLE gmail_accounts ENABLE ROW LEVEL SECURITY", + "ALTER TABLE gmail_accounts FORCE ROW LEVEL SECURITY", + module._UPGRADE_POLICY_STATEMENT, + ] + + +def test_downgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.downgrade() + + assert executed == list(module._DOWNGRADE_STATEMENTS) + + +def test_gmail_account_privileges_allow_only_expected_runtime_writes() -> None: + module = load_migration_module() + + assert module._UPGRADE_GRANT_STATEMENTS == ( + "GRANT SELECT, INSERT ON gmail_accounts TO alicebot_app", + ) diff --git a/tests/unit/test_20260316_0027_gmail_account_credentials.py b/tests/unit/test_20260316_0027_gmail_account_credentials.py new file mode 100644 index 0000000..ef80e31 --- /dev/null +++ b/tests/unit/test_20260316_0027_gmail_account_credentials.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import importlib + + +MODULE_NAME = "apps.api.alembic.versions.20260316_0027_gmail_account_credentials" + + +def load_migration_module(): + return importlib.import_module(MODULE_NAME) + + +def test_upgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.upgrade() + + assert executed == [ + module._UPGRADE_SCHEMA_STATEMENT, + module._UPGRADE_BACKFILL_STATEMENT, + *module._UPGRADE_DROP_PLAINTEXT_STATEMENTS, + *module._UPGRADE_GRANT_STATEMENTS, + "ALTER TABLE gmail_account_credentials ENABLE ROW LEVEL SECURITY", + "ALTER TABLE gmail_account_credentials FORCE ROW LEVEL SECURITY", + module._UPGRADE_POLICY_STATEMENT, + ] + + +def test_downgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.downgrade() + + assert executed == [ + *module._DOWNGRADE_ADD_PLAINTEXT_STATEMENTS, + module._DOWNGRADE_BACKFILL_STATEMENT, + *module._DOWNGRADE_RESTORE_CONSTRAINT_STATEMENTS, + ] + + +def test_gmail_account_credential_privileges_allow_only_expected_runtime_writes() -> None: + module = load_migration_module() + + assert module._UPGRADE_GRANT_STATEMENTS == ( + "GRANT SELECT, INSERT ON gmail_account_credentials TO alicebot_app", + ) diff --git a/tests/unit/test_20260316_0028_gmail_refresh_token_lifecycle.py b/tests/unit/test_20260316_0028_gmail_refresh_token_lifecycle.py new file mode 100644 index 0000000..0c180c2 --- /dev/null +++ b/tests/unit/test_20260316_0028_gmail_refresh_token_lifecycle.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import importlib + + +MODULE_NAME = "apps.api.alembic.versions.20260316_0028_gmail_refresh_token_lifecycle" + + +def load_migration_module(): + return importlib.import_module(MODULE_NAME) + + +def test_upgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.upgrade() + + assert executed == list(module._UPGRADE_STATEMENTS) + + +def test_downgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.downgrade() + + assert executed == list(module._DOWNGRADE_STATEMENTS) + + +def test_gmail_account_credential_privileges_allow_runtime_updates() -> None: + module = load_migration_module() + + assert module._UPGRADE_STATEMENTS[-1] == ( + "GRANT UPDATE ON gmail_account_credentials TO alicebot_app" + ) diff --git a/tests/unit/test_20260316_0029_gmail_external_secret_manager.py b/tests/unit/test_20260316_0029_gmail_external_secret_manager.py new file mode 100644 index 0000000..087c4cb --- /dev/null +++ b/tests/unit/test_20260316_0029_gmail_external_secret_manager.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import importlib + + +MODULE_NAME = "apps.api.alembic.versions.20260316_0029_gmail_external_secret_manager" + + +def load_migration_module(): + return importlib.import_module(MODULE_NAME) + + +def test_upgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.upgrade() + + assert executed == list(module._UPGRADE_STATEMENTS) + + +def test_downgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.downgrade() + + assert executed == list(module._DOWNGRADE_STATEMENTS) + + +def test_upgrade_marks_external_secret_manager_and_legacy_transition_kinds_explicitly() -> None: + module = load_migration_module() + + assert module.GMAIL_SECRET_MANAGER_KIND_FILE_V1 == "file_v1" + assert module.GMAIL_SECRET_MANAGER_KIND_LEGACY_DB_V0 == "legacy_db_v0" diff --git a/tests/unit/test_20260319_0030_calendar_accounts_and_credentials.py b/tests/unit/test_20260319_0030_calendar_accounts_and_credentials.py new file mode 100644 index 0000000..92db601 --- /dev/null +++ b/tests/unit/test_20260319_0030_calendar_accounts_and_credentials.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import importlib + + +MODULE_NAME = "apps.api.alembic.versions.20260319_0030_calendar_accounts_and_credentials" + + +def load_migration_module(): + return importlib.import_module(MODULE_NAME) + + +def test_upgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.upgrade() + + assert executed == [ + module._UPGRADE_SCHEMA_STATEMENT, + *module._UPGRADE_GRANT_STATEMENTS, + "ALTER TABLE calendar_accounts ENABLE ROW LEVEL SECURITY", + "ALTER TABLE calendar_accounts FORCE ROW LEVEL SECURITY", + "ALTER TABLE calendar_account_credentials ENABLE ROW LEVEL SECURITY", + "ALTER TABLE calendar_account_credentials FORCE ROW LEVEL SECURITY", + *module._UPGRADE_POLICY_STATEMENTS, + ] + + +def test_downgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.downgrade() + + assert executed == list(module._DOWNGRADE_STATEMENTS) + + +def test_calendar_account_privileges_allow_only_expected_runtime_writes() -> None: + module = load_migration_module() + + assert module._UPGRADE_GRANT_STATEMENTS == ( + "GRANT SELECT, INSERT ON calendar_accounts TO alicebot_app", + "GRANT SELECT, INSERT ON calendar_account_credentials TO alicebot_app", + ) diff --git a/tests/unit/test_20260323_0030_typed_memory_backbone.py b/tests/unit/test_20260323_0030_typed_memory_backbone.py new file mode 100644 index 0000000..4120701 --- /dev/null +++ b/tests/unit/test_20260323_0030_typed_memory_backbone.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import importlib + + +MODULE_NAME = "apps.api.alembic.versions.20260323_0030_typed_memory_backbone" + + +def load_migration_module(): + return importlib.import_module(MODULE_NAME) + + +def test_upgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.upgrade() + + assert executed == list(module._UPGRADE_STATEMENTS) + + +def test_downgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.downgrade() + + assert executed == list(module._DOWNGRADE_STATEMENTS) + + +def test_memory_type_and_confirmation_status_domains_match_sprint_contract() -> None: + module = load_migration_module() + + assert module.MEMORY_TYPES == ( + "preference", + "identity_fact", + "relationship_fact", + "project_fact", + "decision", + "commitment", + "routine", + "constraint", + "working_style", + ) + assert module.MEMORY_CONFIRMATION_STATUSES == ( + "unconfirmed", + "confirmed", + "contested", + ) diff --git a/tests/unit/test_20260323_0031_open_loop_backbone.py b/tests/unit/test_20260323_0031_open_loop_backbone.py new file mode 100644 index 0000000..5b3630a --- /dev/null +++ b/tests/unit/test_20260323_0031_open_loop_backbone.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import importlib + + +MODULE_NAME = "apps.api.alembic.versions.20260323_0031_open_loop_backbone" + + +def load_migration_module(): + return importlib.import_module(MODULE_NAME) + + +def test_upgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.upgrade() + + assert executed == [ + module._UPGRADE_SCHEMA_STATEMENT, + *module._UPGRADE_GRANT_STATEMENTS, + "ALTER TABLE open_loops ENABLE ROW LEVEL SECURITY", + "ALTER TABLE open_loops FORCE ROW LEVEL SECURITY", + module._UPGRADE_POLICY_STATEMENT, + ] + + +def test_downgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.downgrade() + + assert executed == list(module._DOWNGRADE_STATEMENTS) + + +def test_open_loop_status_domain_matches_sprint_contract() -> None: + module = load_migration_module() + + assert module.OPEN_LOOP_STATUSES == ("open", "resolved", "dismissed") diff --git a/tests/unit/test_20260324_0032_thread_agent_profiles.py b/tests/unit/test_20260324_0032_thread_agent_profiles.py new file mode 100644 index 0000000..38a7039 --- /dev/null +++ b/tests/unit/test_20260324_0032_thread_agent_profiles.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import importlib + + +MODULE_NAME = "apps.api.alembic.versions.20260324_0032_thread_agent_profiles" + + +def load_migration_module(): + return importlib.import_module(MODULE_NAME) + + +def test_upgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.upgrade() + + assert executed == list(module._UPGRADE_STATEMENTS) + + +def test_downgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.downgrade() + + assert executed == list(module._DOWNGRADE_STATEMENTS) + + +def test_agent_profile_domain_and_default_match_sprint_contract() -> None: + module = load_migration_module() + + assert module.AGENT_PROFILE_IDS == ("assistant_default", "coach_default") + assert module.DEFAULT_AGENT_PROFILE_ID == "assistant_default" diff --git a/tests/unit/test_20260324_0033_agent_profile_registry.py b/tests/unit/test_20260324_0033_agent_profile_registry.py new file mode 100644 index 0000000..ac922b3 --- /dev/null +++ b/tests/unit/test_20260324_0033_agent_profile_registry.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import importlib + + +MODULE_NAME = "apps.api.alembic.versions.20260324_0033_agent_profile_registry" + + +def load_migration_module(): + return importlib.import_module(MODULE_NAME) + + +def test_upgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.upgrade() + + assert executed == list(module._UPGRADE_STATEMENTS) + + +def test_downgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.downgrade() + + assert executed == list(module._DOWNGRADE_STATEMENTS) + + +def test_seeded_registry_rows_and_thread_binding_contract() -> None: + module = load_migration_module() + + assert module.AGENT_PROFILE_SEED_ROWS == ( + ( + "assistant_default", + "Assistant Default", + "General-purpose assistant profile for baseline conversations.", + ), + ( + "coach_default", + "Coach Default", + "Coaching-oriented profile focused on guidance and accountability.", + ), + ) + assert module.AGENT_PROFILE_IDS == ("assistant_default", "coach_default") + assert "threads_agent_profile_id_check" in module._UPGRADE_STATEMENTS[3] + assert "threads_agent_profile_id_fkey" in module._UPGRADE_STATEMENTS[4] diff --git a/tests/unit/test_20260324_0034_memory_agent_profile_scope.py b/tests/unit/test_20260324_0034_memory_agent_profile_scope.py new file mode 100644 index 0000000..7f8adc5 --- /dev/null +++ b/tests/unit/test_20260324_0034_memory_agent_profile_scope.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import importlib + + +MODULE_NAME = "apps.api.alembic.versions.20260324_0034_memory_agent_profile_scope" + + +def load_migration_module(): + return importlib.import_module(MODULE_NAME) + + +def test_upgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.upgrade() + + assert executed == list(module._UPGRADE_STATEMENTS) + + +def test_downgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.downgrade() + + assert executed == list(module._DOWNGRADE_STATEMENTS) + + +def test_memory_profile_scope_upgrade_contract() -> None: + module = load_migration_module() + + assert module.DEFAULT_AGENT_PROFILE_ID == "assistant_default" + assert "ADD COLUMN agent_profile_id text NOT NULL DEFAULT 'assistant_default'" in module._UPGRADE_STATEMENTS[0] + assert "FOREIGN KEY (agent_profile_id)" in module._UPGRADE_STATEMENTS[1] + assert "REFERENCES agent_profiles(id)" in module._UPGRADE_STATEMENTS[1] + assert "DROP CONSTRAINT IF EXISTS memories_user_id_memory_key_key" in module._UPGRADE_STATEMENTS[2] + assert "memories_user_profile_memory_key_key" in module._UPGRADE_STATEMENTS[3] + assert "UNIQUE (user_id, agent_profile_id, memory_key)" in module._UPGRADE_STATEMENTS[3] + assert "memories_user_profile_updated_created_id_idx" in module._UPGRADE_STATEMENTS[4] + assert "(user_id, agent_profile_id, updated_at, created_at, id)" in module._UPGRADE_STATEMENTS[4] + + +def test_memory_profile_scope_downgrade_contract_handles_cross_profile_duplicates() -> None: + module = load_migration_module() + + assert "DROP CONSTRAINT IF EXISTS memories_user_profile_memory_key_key" in module._DOWNGRADE_STATEMENTS[1] + assert "ROW_NUMBER() OVER" in module._DOWNGRADE_STATEMENTS[2] + assert "PARTITION BY user_id, memory_key" in module._DOWNGRADE_STATEMENTS[2] + assert "memory_key = ranked_memories.memory_key" in module._DOWNGRADE_STATEMENTS[2] + assert "#profile:" in module._DOWNGRADE_STATEMENTS[2] + assert "ADD CONSTRAINT memories_user_id_memory_key_key" in module._DOWNGRADE_STATEMENTS[3] diff --git a/tests/unit/test_20260325_0035_policy_agent_profile_scope.py b/tests/unit/test_20260325_0035_policy_agent_profile_scope.py new file mode 100644 index 0000000..274b98b --- /dev/null +++ b/tests/unit/test_20260325_0035_policy_agent_profile_scope.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import importlib + + +MODULE_NAME = "apps.api.alembic.versions.20260325_0035_policy_agent_profile_scope" + + +def load_migration_module(): + return importlib.import_module(MODULE_NAME) + + +def test_upgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.upgrade() + + assert executed == list(module._UPGRADE_STATEMENTS) + + +def test_downgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.downgrade() + + assert executed == list(module._DOWNGRADE_STATEMENTS) + + +def test_policy_profile_scope_upgrade_contract() -> None: + module = load_migration_module() + + assert "ADD COLUMN agent_profile_id text NULL" in module._UPGRADE_STATEMENTS[0] + assert "policies_agent_profile_id_fkey" in module._UPGRADE_STATEMENTS[1] + assert "FOREIGN KEY (agent_profile_id)" in module._UPGRADE_STATEMENTS[1] + assert "REFERENCES agent_profiles(id)" in module._UPGRADE_STATEMENTS[1] + assert "DROP INDEX IF EXISTS policies_user_active_priority_created_idx" in module._UPGRADE_STATEMENTS[2] + assert "policies_user_active_profile_priority_created_idx" in module._UPGRADE_STATEMENTS[3] + assert "(user_id, active, agent_profile_id, priority, created_at, id)" in module._UPGRADE_STATEMENTS[3] + + +def test_policy_profile_scope_downgrade_contract() -> None: + module = load_migration_module() + + assert "DROP INDEX IF EXISTS policies_user_active_profile_priority_created_idx" in module._DOWNGRADE_STATEMENTS[0] + assert "DROP CONSTRAINT IF EXISTS policies_agent_profile_id_fkey" in module._DOWNGRADE_STATEMENTS[1] + assert "DROP COLUMN IF EXISTS agent_profile_id" in module._DOWNGRADE_STATEMENTS[2] + assert "CREATE INDEX policies_user_active_priority_created_idx" in module._DOWNGRADE_STATEMENTS[3] + assert "(user_id, active, priority, created_at, id)" in module._DOWNGRADE_STATEMENTS[3] diff --git a/tests/unit/test_20260325_0036_agent_profile_model_runtime.py b/tests/unit/test_20260325_0036_agent_profile_model_runtime.py new file mode 100644 index 0000000..64cf597 --- /dev/null +++ b/tests/unit/test_20260325_0036_agent_profile_model_runtime.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import importlib + + +MODULE_NAME = "apps.api.alembic.versions.20260325_0036_agent_profile_model_runtime" + + +def load_migration_module(): + return importlib.import_module(MODULE_NAME) + + +def test_upgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.upgrade() + + assert executed == list(module._UPGRADE_STATEMENTS) + + +def test_downgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.downgrade() + + assert executed == list(module._DOWNGRADE_STATEMENTS) + + +def test_profile_runtime_upgrade_contract() -> None: + module = load_migration_module() + + assert module.AGENT_PROFILE_RUNTIME_SEED_ROWS == ( + ("assistant_default", "openai_responses", "gpt-5-mini"), + ("coach_default", "openai_responses", "gpt-5"), + ) + assert "ADD COLUMN model_provider text NULL" in module._UPGRADE_STATEMENTS[0] + assert "ADD COLUMN model_name text NULL" in module._UPGRADE_STATEMENTS[1] + assert "agent_profiles_model_provider_check" in module._UPGRADE_STATEMENTS[2] + assert "model_provider = 'openai_responses'" in module._UPGRADE_STATEMENTS[2] + assert "agent_profiles_model_runtime_pairing_check" in module._UPGRADE_STATEMENTS[3] + assert "(model_provider IS NULL AND model_name IS NULL)" in module._UPGRADE_STATEMENTS[3] + assert "(model_provider IS NOT NULL AND char_length(model_name) > 0)" in module._UPGRADE_STATEMENTS[3] + assert "UPDATE agent_profiles" in module._UPGRADE_STATEMENTS[4] + assert "assistant_default" in module._UPGRADE_STATEMENTS[4] + assert "coach_default" in module._UPGRADE_STATEMENTS[4] + + +def test_profile_runtime_downgrade_contract() -> None: + module = load_migration_module() + + assert "DROP CONSTRAINT IF EXISTS agent_profiles_model_runtime_pairing_check" in module._DOWNGRADE_STATEMENTS[0] + assert "DROP CONSTRAINT IF EXISTS agent_profiles_model_provider_check" in module._DOWNGRADE_STATEMENTS[1] + assert "DROP COLUMN IF EXISTS model_name" in module._DOWNGRADE_STATEMENTS[2] + assert "DROP COLUMN IF EXISTS model_provider" in module._DOWNGRADE_STATEMENTS[3] diff --git a/tests/unit/test_20260325_0037_execution_budget_agent_profile_scope.py b/tests/unit/test_20260325_0037_execution_budget_agent_profile_scope.py new file mode 100644 index 0000000..f09d7ee --- /dev/null +++ b/tests/unit/test_20260325_0037_execution_budget_agent_profile_scope.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import importlib + + +MODULE_NAME = "apps.api.alembic.versions.20260325_0037_execution_budget_agent_profile_scope" + + +def load_migration_module(): + return importlib.import_module(MODULE_NAME) + + +def test_upgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.upgrade() + + assert executed == list(module._UPGRADE_STATEMENTS) + + +def test_downgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.downgrade() + + assert executed == list(module._DOWNGRADE_STATEMENTS) + + +def test_execution_budget_profile_scope_upgrade_contract() -> None: + module = load_migration_module() + + assert "ADD COLUMN agent_profile_id text NULL" in module._UPGRADE_STATEMENTS[0] + assert "execution_budgets_agent_profile_id_fkey" in module._UPGRADE_STATEMENTS[1] + assert "FOREIGN KEY (agent_profile_id)" in module._UPGRADE_STATEMENTS[1] + assert "REFERENCES agent_profiles(id)" in module._UPGRADE_STATEMENTS[1] + assert "DROP INDEX IF EXISTS execution_budgets_user_match_idx" in module._UPGRADE_STATEMENTS[2] + assert "DROP INDEX IF EXISTS execution_budgets_one_active_scope_idx" in module._UPGRADE_STATEMENTS[3] + assert "execution_budgets_user_profile_match_idx" in module._UPGRADE_STATEMENTS[4] + assert "(user_id, agent_profile_id, tool_key, domain_hint, created_at, id)" in module._UPGRADE_STATEMENTS[4] + assert "execution_budgets_one_active_scope_idx" in module._UPGRADE_STATEMENTS[5] + assert "COALESCE(agent_profile_id, '')" in module._UPGRADE_STATEMENTS[5] + assert "COALESCE(tool_key, '')" in module._UPGRADE_STATEMENTS[5] + assert "COALESCE(domain_hint, '')" in module._UPGRADE_STATEMENTS[5] + assert "WHERE status = 'active'" in module._UPGRADE_STATEMENTS[5] + + +def test_execution_budget_profile_scope_downgrade_contract() -> None: + module = load_migration_module() + + assert "DROP INDEX IF EXISTS execution_budgets_one_active_scope_idx" in module._DOWNGRADE_STATEMENTS[0] + assert "DROP INDEX IF EXISTS execution_budgets_user_profile_match_idx" in module._DOWNGRADE_STATEMENTS[1] + assert "DROP CONSTRAINT IF EXISTS execution_budgets_agent_profile_id_fkey" in module._DOWNGRADE_STATEMENTS[2] + assert "DROP COLUMN IF EXISTS agent_profile_id" in module._DOWNGRADE_STATEMENTS[3] + assert "CREATE INDEX execution_budgets_user_match_idx" in module._DOWNGRADE_STATEMENTS[4] + assert "(user_id, tool_key, domain_hint, created_at, id)" in module._DOWNGRADE_STATEMENTS[4] + assert "CREATE UNIQUE INDEX execution_budgets_one_active_scope_idx" in module._DOWNGRADE_STATEMENTS[5] + assert "COALESCE(tool_key, '')" in module._DOWNGRADE_STATEMENTS[5] + assert "COALESCE(domain_hint, '')" in module._DOWNGRADE_STATEMENTS[5] + assert "WHERE status = 'active'" in module._DOWNGRADE_STATEMENTS[5] diff --git a/tests/unit/test_20260327_0038_task_runs.py b/tests/unit/test_20260327_0038_task_runs.py new file mode 100644 index 0000000..e86702d --- /dev/null +++ b/tests/unit/test_20260327_0038_task_runs.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import importlib + + +MODULE_NAME = "apps.api.alembic.versions.20260327_0038_task_runs" + + +def load_migration_module(): + return importlib.import_module(MODULE_NAME) + + +def test_upgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.upgrade() + + assert executed == [ + module._UPGRADE_SCHEMA_STATEMENT, + *module._UPGRADE_GRANT_STATEMENTS, + "ALTER TABLE task_runs ENABLE ROW LEVEL SECURITY", + "ALTER TABLE task_runs FORCE ROW LEVEL SECURITY", + module._UPGRADE_POLICY_STATEMENT, + ] + + +def test_downgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.downgrade() + + assert executed == list(module._DOWNGRADE_STATEMENTS) + + +def test_task_runs_privileges_stay_narrow() -> None: + module = load_migration_module() + + assert module._UPGRADE_GRANT_STATEMENTS == ( + "GRANT SELECT, INSERT, UPDATE ON task_runs TO alicebot_app", + ) diff --git a/tests/unit/test_20260327_0039_task_run_execution_linkage.py b/tests/unit/test_20260327_0039_task_run_execution_linkage.py new file mode 100644 index 0000000..fbdaf80 --- /dev/null +++ b/tests/unit/test_20260327_0039_task_run_execution_linkage.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import importlib + + +MODULE_NAME = "apps.api.alembic.versions.20260327_0039_task_run_execution_linkage" + + +def load_migration_module(): + return importlib.import_module(MODULE_NAME) + + +def test_upgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.upgrade() + + assert executed == list(module._UPGRADE_STATEMENTS) + + +def test_downgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.downgrade() + + assert executed == list(module._DOWNGRADE_STATEMENTS) + + +def test_upgrade_enforces_idempotent_side_effect_guardrails() -> None: + module = load_migration_module() + + assert any( + "tool_executions_task_run_idempotency_idx" in statement + for statement in module._UPGRADE_STATEMENTS + ) + assert any( + "waiting_approval" in statement + for statement in module._UPGRADE_STATEMENTS + ) diff --git a/tests/unit/test_20260327_0040_task_run_retry_failure_controls.py b/tests/unit/test_20260327_0040_task_run_retry_failure_controls.py new file mode 100644 index 0000000..0a0326e --- /dev/null +++ b/tests/unit/test_20260327_0040_task_run_retry_failure_controls.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import importlib + + +MODULE_NAME = "apps.api.alembic.versions.20260327_0040_task_run_retry_failure_controls" + + +def load_migration_module(): + return importlib.import_module(MODULE_NAME) + + +def test_upgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.upgrade() + + assert executed == list(module._UPGRADE_STATEMENTS) + + +def test_downgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.downgrade() + + assert executed == list(module._DOWNGRADE_STATEMENTS) + + +def test_upgrade_enforces_retry_and_failure_controls() -> None: + module = load_migration_module() + + combined = "\n".join(module._UPGRADE_STATEMENTS) + assert "retry_count" in combined + assert "retry_cap" in combined + assert "retry_posture" in combined + assert "failure_class" in combined + assert "last_transitioned_at" in combined + assert "waiting_user" in combined + assert "done" in combined + assert "failed" in combined + assert "status = 'paused' AND stop_reason = 'budget_exhausted' THEN 'terminal'" in combined diff --git a/tests/unit/test_20260329_0041_phase5_continuity_backbone.py b/tests/unit/test_20260329_0041_phase5_continuity_backbone.py new file mode 100644 index 0000000..719297d --- /dev/null +++ b/tests/unit/test_20260329_0041_phase5_continuity_backbone.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import importlib + + +MODULE_NAME = "apps.api.alembic.versions.20260329_0041_phase5_continuity_backbone" + + +def load_migration_module(): + return importlib.import_module(MODULE_NAME) + + +def test_upgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.upgrade() + + assert executed == [ + *module._UPGRADE_BOOTSTRAP_STATEMENTS, + module._UPGRADE_SCHEMA_STATEMENT, + module._UPGRADE_TRIGGER_STATEMENT, + *module._UPGRADE_GRANT_STATEMENTS, + "ALTER TABLE continuity_capture_events ENABLE ROW LEVEL SECURITY", + "ALTER TABLE continuity_capture_events FORCE ROW LEVEL SECURITY", + "ALTER TABLE continuity_objects ENABLE ROW LEVEL SECURITY", + "ALTER TABLE continuity_objects FORCE ROW LEVEL SECURITY", + module._UPGRADE_POLICY_STATEMENT, + ] + + +def test_downgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.downgrade() + + assert executed == list(module._DOWNGRADE_STATEMENTS) + + +def test_domains_match_sprint_contract() -> None: + module = load_migration_module() + + assert module.CONTINUITY_OBJECT_TYPES == ( + "Note", + "MemoryFact", + "Decision", + "Commitment", + "WaitingFor", + "Blocker", + "NextAction", + ) + assert module.CAPTURE_EXPLICIT_SIGNALS == ( + "remember_this", + "task", + "decision", + "commitment", + "waiting_for", + "blocker", + "next_action", + "note", + ) + assert module.CAPTURE_ADMISSION_POSTURES == ("DERIVED", "TRIAGE") diff --git a/tests/unit/test_20260330_0042_phase5_continuity_corrections.py b/tests/unit/test_20260330_0042_phase5_continuity_corrections.py new file mode 100644 index 0000000..29cc93f --- /dev/null +++ b/tests/unit/test_20260330_0042_phase5_continuity_corrections.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import importlib + + +MODULE_NAME = "apps.api.alembic.versions.20260330_0042_phase5_continuity_corrections" + + +def load_migration_module(): + return importlib.import_module(MODULE_NAME) + + +def test_upgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.upgrade() + + assert executed == [ + *module._UPGRADE_BOOTSTRAP_STATEMENTS, + *module._UPGRADE_SCHEMA_STATEMENTS, + *module._UPGRADE_TRIGGER_STATEMENTS, + *module._UPGRADE_GRANT_STATEMENTS, + "ALTER TABLE continuity_correction_events ENABLE ROW LEVEL SECURITY", + "ALTER TABLE continuity_correction_events FORCE ROW LEVEL SECURITY", + *module._UPGRADE_POLICY_STATEMENTS, + ] + + +def test_downgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.downgrade() + + assert executed == list(module._DOWNGRADE_STATEMENTS) + + +def test_domains_match_sprint_contract() -> None: + module = load_migration_module() + + assert module.CONTINUITY_CORRECTION_ACTIONS == ( + "confirm", + "edit", + "delete", + "supersede", + "mark_stale", + ) + assert module.CONTINUITY_OBJECT_STATUSES == ( + "active", + "completed", + "cancelled", + "superseded", + "stale", + "deleted", + ) diff --git a/tests/unit/test_20260408_0043_phase10_identity_workspace_bootstrap.py b/tests/unit/test_20260408_0043_phase10_identity_workspace_bootstrap.py new file mode 100644 index 0000000..5de573a --- /dev/null +++ b/tests/unit/test_20260408_0043_phase10_identity_workspace_bootstrap.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import importlib + + +MODULE_NAME = "apps.api.alembic.versions.20260408_0043_phase10_identity_workspace_bootstrap" + + +def load_migration_module(): + return importlib.import_module(MODULE_NAME) + + +def test_upgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.upgrade() + + assert executed == [ + *module._UPGRADE_STATEMENTS, + *module._UPGRADE_GRANT_STATEMENTS, + ] + + +def test_downgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.downgrade() + + assert executed == list(module._DOWNGRADE_STATEMENTS) + + +def test_migration_mentions_phase10_control_plane_tables() -> None: + module = load_migration_module() + + joined_upgrade_sql = "\n".join(module._UPGRADE_STATEMENTS) + for table_name in ( + "user_accounts", + "auth_sessions", + "magic_link_challenges", + "devices", + "device_link_challenges", + "workspaces", + "workspace_members", + "user_preferences", + "beta_cohorts", + "feature_flags", + ): + assert table_name in joined_upgrade_sql + + +def test_migration_stores_challenge_tokens_hashed_at_rest() -> None: + module = load_migration_module() + joined_upgrade_sql = "\n".join(module._UPGRADE_STATEMENTS) + + assert "challenge_token_hash text NOT NULL UNIQUE" in joined_upgrade_sql + assert "challenge_token text NOT NULL UNIQUE" not in joined_upgrade_sql diff --git a/tests/unit/test_20260408_0044_phase10_telegram_transport.py b/tests/unit/test_20260408_0044_phase10_telegram_transport.py new file mode 100644 index 0000000..4511006 --- /dev/null +++ b/tests/unit/test_20260408_0044_phase10_telegram_transport.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import importlib + + +MODULE_NAME = "apps.api.alembic.versions.20260408_0044_phase10_telegram_transport" + + +def load_migration_module(): + return importlib.import_module(MODULE_NAME) + + +def test_upgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.upgrade() + + assert executed == [ + *module._UPGRADE_STATEMENTS, + *module._UPGRADE_GRANT_STATEMENTS, + ] + + +def test_downgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.downgrade() + + assert executed == list(module._DOWNGRADE_STATEMENTS) + + +def test_migration_mentions_phase10_s2_channel_tables() -> None: + module = load_migration_module() + + joined_upgrade_sql = "\n".join(module._UPGRADE_STATEMENTS) + for table_name in ( + "channel_identities", + "channel_link_challenges", + "channel_messages", + "channel_threads", + "channel_delivery_receipts", + "chat_intents", + ): + assert table_name in joined_upgrade_sql + + +def test_migration_hashes_challenge_tokens_and_enforces_webhook_idempotency() -> None: + module = load_migration_module() + joined_upgrade_sql = "\n".join(module._UPGRADE_STATEMENTS) + + assert "challenge_token_hash text NOT NULL UNIQUE" in joined_upgrade_sql + assert "challenge_token text NOT NULL UNIQUE" not in joined_upgrade_sql + assert "UNIQUE (channel_type, direction, idempotency_key)" in joined_upgrade_sql + assert "ON channel_identities (channel_type, external_chat_id)" in joined_upgrade_sql diff --git a/tests/unit/test_20260408_0045_phase10_chat_continuity_approvals.py b/tests/unit/test_20260408_0045_phase10_chat_continuity_approvals.py new file mode 100644 index 0000000..6f6da97 --- /dev/null +++ b/tests/unit/test_20260408_0045_phase10_chat_continuity_approvals.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import importlib + + +MODULE_NAME = "apps.api.alembic.versions.20260408_0045_phase10_chat_continuity_approvals" + + +def load_migration_module(): + return importlib.import_module(MODULE_NAME) + + +def test_upgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.upgrade() + + assert executed == [ + *module._UPGRADE_STATEMENTS, + *module._UPGRADE_GRANT_STATEMENTS, + ] + + +def test_downgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.downgrade() + + assert executed == list(module._DOWNGRADE_STATEMENTS) + + +def test_migration_adds_chat_intent_result_fields_and_new_tables() -> None: + module = load_migration_module() + joined_upgrade_sql = "\n".join(module._UPGRADE_STATEMENTS) + + assert "ADD COLUMN intent_payload jsonb" in joined_upgrade_sql + assert "ADD COLUMN result_payload jsonb" in joined_upgrade_sql + assert "ADD COLUMN handled_at timestamptz" in joined_upgrade_sql + assert "CREATE TABLE approval_challenges" in joined_upgrade_sql + assert "CREATE TABLE open_loop_reviews" in joined_upgrade_sql + + +def test_migration_extends_intent_routing_checks_for_phase10_s3() -> None: + module = load_migration_module() + joined_upgrade_sql = "\n".join(module._UPGRADE_STATEMENTS) + + for marker in ( + "'capture'", + "'recall'", + "'resume'", + "'correction'", + "'open_loops'", + "'open_loop_review'", + "'approvals'", + "'approval_approve'", + "'approval_reject'", + "'unknown'", + ): + assert marker in joined_upgrade_sql + + assert "('pending', 'recorded', 'handled', 'failed')" in joined_upgrade_sql diff --git a/tests/unit/test_20260408_0046_phase10_daily_brief_notifications.py b/tests/unit/test_20260408_0046_phase10_daily_brief_notifications.py new file mode 100644 index 0000000..fa8f8ad --- /dev/null +++ b/tests/unit/test_20260408_0046_phase10_daily_brief_notifications.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import importlib + + +MODULE_NAME = "apps.api.alembic.versions.20260408_0046_phase10_daily_brief_notifications" + + +def load_migration_module(): + return importlib.import_module(MODULE_NAME) + + +def test_upgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.upgrade() + + assert executed == [ + *module._UPGRADE_STATEMENTS, + *module._UPGRADE_GRANT_STATEMENTS, + ] + + +def test_downgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.downgrade() + + assert executed == list(module._DOWNGRADE_STATEMENTS) + + +def test_migration_adds_phase10_s4_tables_and_scheduler_receipt_fields() -> None: + module = load_migration_module() + joined_upgrade_sql = "\n".join(module._UPGRADE_STATEMENTS) + + assert "CREATE TABLE notification_subscriptions" in joined_upgrade_sql + assert "CREATE TABLE continuity_briefs" in joined_upgrade_sql + assert "CREATE TABLE daily_brief_jobs" in joined_upgrade_sql + assert "ADD COLUMN scheduled_job_id uuid" in joined_upgrade_sql + assert "ADD COLUMN scheduler_job_kind text" in joined_upgrade_sql + assert "ADD COLUMN scheduled_for timestamptz" in joined_upgrade_sql + assert "ADD COLUMN schedule_slot text" in joined_upgrade_sql + assert "ADD COLUMN notification_policy jsonb" in joined_upgrade_sql + assert "UNIQUE (workspace_id, channel_type, idempotency_key)" in joined_upgrade_sql + + +def test_migration_extends_delivery_receipt_status_for_policy_suppression() -> None: + module = load_migration_module() + joined_upgrade_sql = "\n".join(module._UPGRADE_STATEMENTS) + + assert "status IN ('delivered', 'failed', 'simulated', 'suppressed')" in joined_upgrade_sql + assert "scheduler_job_kind IN ('daily_brief', 'open_loop_prompt')" in joined_upgrade_sql diff --git a/tests/unit/test_20260409_0047_phase10_beta_hardening_launch.py b/tests/unit/test_20260409_0047_phase10_beta_hardening_launch.py new file mode 100644 index 0000000..4a2247a --- /dev/null +++ b/tests/unit/test_20260409_0047_phase10_beta_hardening_launch.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import importlib + + +MODULE_NAME = "apps.api.alembic.versions.20260409_0047_phase10_beta_hardening_launch" + + +def load_migration_module(): + return importlib.import_module(MODULE_NAME) + + +def test_upgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.upgrade() + + assert executed == [ + *module._UPGRADE_STATEMENTS, + *module._UPGRADE_GRANT_STATEMENTS, + ] + + +def test_downgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.downgrade() + + assert executed == list(module._DOWNGRADE_STATEMENTS) + + +def test_migration_adds_chat_telemetry_and_evidence_fields() -> None: + module = load_migration_module() + joined_upgrade_sql = "\n".join(module._UPGRADE_STATEMENTS) + + assert "CREATE TABLE chat_telemetry" in joined_upgrade_sql + assert "ADD COLUMN support_status text" in joined_upgrade_sql + assert "ADD COLUMN rollout_evidence jsonb" in joined_upgrade_sql + assert "ADD COLUMN rate_limit_evidence jsonb" in joined_upgrade_sql + assert "ADD COLUMN incident_evidence jsonb" in joined_upgrade_sql + assert "ALTER TABLE channel_delivery_receipts" in joined_upgrade_sql + assert "ALTER TABLE daily_brief_jobs" in joined_upgrade_sql + + +def test_migration_seeds_phase10_s5_rollout_flags() -> None: + module = load_migration_module() + joined_upgrade_sql = "\n".join(module._UPGRADE_STATEMENTS) + + assert "hosted_admin_read" in joined_upgrade_sql + assert "hosted_admin_operator" in joined_upgrade_sql + assert "hosted_chat_handle_enabled" in joined_upgrade_sql + assert "hosted_scheduler_delivery_enabled" in joined_upgrade_sql + assert "hosted_abuse_controls_enabled" in joined_upgrade_sql + assert "hosted_rate_limits_enabled" in joined_upgrade_sql + assert "'p10-ops'" in joined_upgrade_sql diff --git a/tests/unit/test_20260410_0048_memory_trust_classes.py b/tests/unit/test_20260410_0048_memory_trust_classes.py new file mode 100644 index 0000000..070ee6f --- /dev/null +++ b/tests/unit/test_20260410_0048_memory_trust_classes.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import importlib + + +MODULE_NAME = "apps.api.alembic.versions.20260410_0048_memory_trust_classes" + + +def load_migration_module(): + return importlib.import_module(MODULE_NAME) + + +def test_upgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.upgrade() + + assert executed == list(module._UPGRADE_STATEMENTS) + + +def test_downgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.downgrade() + + assert executed == list(module._DOWNGRADE_STATEMENTS) + + +def test_trust_class_and_promotion_domains_match_contract() -> None: + module = load_migration_module() + + assert module.MEMORY_TRUST_CLASSES == ( + "deterministic", + "llm_single_source", + "llm_corroborated", + "human_curated", + ) + assert module.MEMORY_PROMOTION_ELIGIBILITIES == ( + "promotable", + "not_promotable", + ) diff --git a/tests/unit/test_20260410_0049_continuity_object_lifecycle_flags.py b/tests/unit/test_20260410_0049_continuity_object_lifecycle_flags.py new file mode 100644 index 0000000..36ad518 --- /dev/null +++ b/tests/unit/test_20260410_0049_continuity_object_lifecycle_flags.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import importlib + + +MODULE_NAME = "apps.api.alembic.versions.20260410_0049_continuity_object_lifecycle_flags" + + +def load_migration_module(): + return importlib.import_module(MODULE_NAME) + + +def test_upgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.upgrade() + + assert executed == list(module._UPGRADE_STATEMENTS) + + +def test_downgrade_executes_expected_statements_in_order(monkeypatch) -> None: + module = load_migration_module() + executed: list[str] = [] + + monkeypatch.setattr(module.op, "execute", executed.append) + + module.downgrade() + + assert executed == list(module._DOWNGRADE_STATEMENTS) diff --git a/tests/unit/test_approval_store.py b/tests/unit/test_approval_store.py new file mode 100644 index 0000000..46a4a51 --- /dev/null +++ b/tests/unit/test_approval_store.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +from typing import Any +from uuid import uuid4 + +from psycopg.types.json import Jsonb + +from alicebot_api.store import ContinuityStore + + +class RecordingCursor: + def __init__(self, fetchone_results: list[dict[str, Any]], fetchall_result: list[dict[str, Any]] | None = None) -> None: + self.executed: list[tuple[str, tuple[object, ...] | None]] = [] + self.fetchone_results = list(fetchone_results) + self.fetchall_result = fetchall_result or [] + + def __enter__(self) -> "RecordingCursor": + return self + + def __exit__(self, exc_type, exc, tb) -> None: + return None + + def execute(self, query: str, params: tuple[object, ...] | None = None) -> None: + self.executed.append((query, params)) + + def fetchone(self) -> dict[str, Any] | None: + if not self.fetchone_results: + return None + return self.fetchone_results.pop(0) + + def fetchall(self) -> list[dict[str, Any]]: + return self.fetchall_result + + +class RecordingConnection: + def __init__(self, cursor: RecordingCursor) -> None: + self.cursor_instance = cursor + + def cursor(self) -> RecordingCursor: + return self.cursor_instance + + +def test_approval_store_methods_use_expected_queries_and_jsonb_parameters() -> None: + approval_id = uuid4() + thread_id = uuid4() + tool_id = uuid4() + task_run_id = None + task_step_id = uuid4() + routing_trace_id = uuid4() + resolved_by_user_id = uuid4() + cursor = RecordingCursor( + fetchone_results=[ + { + "id": approval_id, + "thread_id": thread_id, + "tool_id": tool_id, + "task_run_id": task_run_id, + "task_step_id": task_step_id, + "status": "pending", + "request": {"thread_id": str(thread_id), "tool_id": str(tool_id)}, + "tool": {"id": str(tool_id), "tool_key": "shell.exec"}, + "routing": {"decision": "approval_required", "trace": {"trace_id": str(routing_trace_id)}}, + "routing_trace_id": routing_trace_id, + "resolved_at": None, + "resolved_by_user_id": None, + }, + { + "id": approval_id, + "thread_id": thread_id, + "tool_id": tool_id, + "task_run_id": task_run_id, + "task_step_id": task_step_id, + "status": "pending", + "request": {"thread_id": str(thread_id), "tool_id": str(tool_id)}, + "tool": {"id": str(tool_id), "tool_key": "shell.exec"}, + "routing": {"decision": "approval_required", "trace": {"trace_id": str(routing_trace_id)}}, + "routing_trace_id": routing_trace_id, + "resolved_at": None, + "resolved_by_user_id": None, + }, + { + "id": approval_id, + "thread_id": thread_id, + "tool_id": tool_id, + "task_run_id": task_run_id, + "task_step_id": task_step_id, + "status": "approved", + "request": {"thread_id": str(thread_id), "tool_id": str(tool_id)}, + "tool": {"id": str(tool_id), "tool_key": "shell.exec"}, + "routing": {"decision": "approval_required", "trace": {"trace_id": str(routing_trace_id)}}, + "routing_trace_id": routing_trace_id, + "resolved_at": "2026-03-12T10:00:00+00:00", + "resolved_by_user_id": resolved_by_user_id, + }, + ], + fetchall_result=[ + { + "id": approval_id, + "thread_id": thread_id, + "tool_id": tool_id, + "task_run_id": task_run_id, + "task_step_id": task_step_id, + "status": "pending", + "request": {"thread_id": str(thread_id), "tool_id": str(tool_id)}, + "tool": {"id": str(tool_id), "tool_key": "shell.exec"}, + "routing": {"decision": "approval_required", "trace": {"trace_id": str(routing_trace_id)}}, + "routing_trace_id": routing_trace_id, + "resolved_at": None, + "resolved_by_user_id": None, + } + ], + ) + store = ContinuityStore(RecordingConnection(cursor)) + + created = store.create_approval( + thread_id=thread_id, + tool_id=tool_id, + task_step_id=task_step_id, + status="pending", + request={"thread_id": str(thread_id), "tool_id": str(tool_id)}, + tool={"id": str(tool_id), "tool_key": "shell.exec"}, + routing={"decision": "approval_required", "trace": {"trace_id": str(routing_trace_id)}}, + routing_trace_id=routing_trace_id, + ) + fetched = store.get_approval_optional(approval_id) + listed = store.list_approvals() + resolved = store.resolve_approval_optional(approval_id=approval_id, status="approved") + + assert created["id"] == approval_id + assert created["resolved_at"] is None + assert fetched is not None + assert listed[0]["id"] == approval_id + assert resolved is not None + assert resolved["status"] == "approved" + + create_query, create_params = cursor.executed[0] + assert "INSERT INTO approvals" in create_query + assert create_params is not None + assert create_params[:5] == (thread_id, tool_id, task_run_id, task_step_id, "pending") + assert isinstance(create_params[5], Jsonb) + assert create_params[5].obj == {"thread_id": str(thread_id), "tool_id": str(tool_id)} + assert isinstance(create_params[6], Jsonb) + assert create_params[6].obj == {"id": str(tool_id), "tool_key": "shell.exec"} + assert isinstance(create_params[7], Jsonb) + assert create_params[7].obj == { + "decision": "approval_required", + "trace": {"trace_id": str(routing_trace_id)}, + } + assert create_params[8] == routing_trace_id + assert "resolved_at" in cursor.executed[1][0] + assert "ORDER BY created_at ASC, id ASC" in cursor.executed[2][0] + + resolve_query, resolve_params = cursor.executed[3] + assert "UPDATE approvals" in resolve_query + assert "WHERE id = %s" in resolve_query + assert "AND status = 'pending'" in resolve_query + assert resolve_params == ("approved", approval_id) diff --git a/tests/unit/test_approvals.py b/tests/unit/test_approvals.py new file mode 100644 index 0000000..9b3c63a --- /dev/null +++ b/tests/unit/test_approvals.py @@ -0,0 +1,1427 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from uuid import UUID, uuid4 + +from alicebot_api.approvals import ( + ApprovalNotFoundError, + ApprovalResolutionConflictError, + approve_approval_record, + get_approval_record, + list_approval_records, + reject_approval_record, + submit_approval_request, +) +from alicebot_api.contracts import ApprovalApproveInput, ApprovalRejectInput, ApprovalRequestCreateInput +from alicebot_api.tasks import TaskStepApprovalLinkageError + + +class ApprovalStoreStub: + def __init__(self) -> None: + self.base_time = datetime(2026, 3, 12, 9, 0, tzinfo=UTC) + self.user_id = uuid4() + self.thread_id = uuid4() + self.locked_task_ids: list[UUID] = [] + self.consents: dict[str, dict[str, object]] = {} + self.policies: list[dict[str, object]] = [] + self.tools: list[dict[str, object]] = [] + self.approvals: list[dict[str, object]] = [] + self.tasks: list[dict[str, object]] = [] + self.task_runs: list[dict[str, object]] = [] + self.task_steps: list[dict[str, object]] = [] + self.traces: list[dict[str, object]] = [] + self.trace_events: list[dict[str, object]] = [] + + def create_consent(self, *, consent_key: str, status: str, metadata: dict[str, object]) -> dict[str, object]: + consent = { + "id": uuid4(), + "user_id": self.user_id, + "consent_key": consent_key, + "status": status, + "metadata": metadata, + "created_at": self.base_time + timedelta(minutes=len(self.consents)), + "updated_at": self.base_time + timedelta(minutes=len(self.consents)), + } + self.consents[consent_key] = consent + return consent + + def list_consents(self) -> list[dict[str, object]]: + return sorted( + self.consents.values(), + key=lambda consent: (consent["consent_key"], consent["created_at"], consent["id"]), + ) + + def create_policy( + self, + *, + name: str, + action: str, + scope: str, + effect: str, + priority: int, + active: bool, + conditions: dict[str, object], + required_consents: list[str], + ) -> dict[str, object]: + policy = { + "id": uuid4(), + "user_id": self.user_id, + "name": name, + "action": action, + "scope": scope, + "effect": effect, + "priority": priority, + "active": active, + "conditions": conditions, + "required_consents": required_consents, + "created_at": self.base_time + timedelta(minutes=len(self.policies)), + "updated_at": self.base_time + timedelta(minutes=len(self.policies)), + } + self.policies.append(policy) + return policy + + def list_active_policies(self) -> list[dict[str, object]]: + return sorted( + [policy for policy in self.policies if policy["active"] is True], + key=lambda policy: (policy["priority"], policy["created_at"], policy["id"]), + ) + + def create_tool( + self, + *, + tool_key: str, + name: str, + description: str, + version: str, + metadata_version: str, + active: bool, + tags: list[str], + action_hints: list[str], + scope_hints: list[str], + domain_hints: list[str], + risk_hints: list[str], + metadata: dict[str, object], + ) -> dict[str, object]: + tool = { + "id": uuid4(), + "user_id": self.user_id, + "tool_key": tool_key, + "name": name, + "description": description, + "version": version, + "metadata_version": metadata_version, + "active": active, + "tags": tags, + "action_hints": action_hints, + "scope_hints": scope_hints, + "domain_hints": domain_hints, + "risk_hints": risk_hints, + "metadata": metadata, + "created_at": self.base_time + timedelta(minutes=len(self.tools)), + } + self.tools.append(tool) + return tool + + def get_tool_optional(self, tool_id: UUID) -> dict[str, object] | None: + return next((tool for tool in self.tools if tool["id"] == tool_id), None) + + def list_active_tools(self) -> list[dict[str, object]]: + return [tool for tool in self.tools if tool["active"] is True] + + def get_thread_optional(self, thread_id: UUID) -> dict[str, object] | None: + if thread_id != self.thread_id: + return None + return { + "id": self.thread_id, + "user_id": self.user_id, + "title": "Approval thread", + "created_at": self.base_time, + "updated_at": self.base_time, + } + + def create_trace( + self, + *, + user_id: UUID, + thread_id: UUID, + kind: str, + compiler_version: str, + status: str, + limits: dict[str, object], + ) -> dict[str, object]: + trace = { + "id": uuid4(), + "user_id": user_id, + "thread_id": thread_id, + "kind": kind, + "compiler_version": compiler_version, + "status": status, + "limits": limits, + "created_at": self.base_time + timedelta(minutes=len(self.traces)), + } + self.traces.append(trace) + return trace + + def append_trace_event( + self, + *, + trace_id: UUID, + sequence_no: int, + kind: str, + payload: dict[str, object], + ) -> dict[str, object]: + event = { + "id": uuid4(), + "trace_id": trace_id, + "sequence_no": sequence_no, + "kind": kind, + "payload": payload, + "created_at": self.base_time + timedelta(minutes=len(self.trace_events)), + } + self.trace_events.append(event) + return event + + def create_approval( + self, + *, + thread_id: UUID, + tool_id: UUID, + task_step_id: UUID | None, + status: str, + request: dict[str, object], + tool: dict[str, object], + routing: dict[str, object], + routing_trace_id: UUID, + ) -> dict[str, object]: + approval = { + "id": uuid4(), + "user_id": self.user_id, + "thread_id": thread_id, + "tool_id": tool_id, + "task_step_id": task_step_id, + "status": status, + "request": request, + "tool": tool, + "routing": routing, + "routing_trace_id": routing_trace_id, + "created_at": self.base_time + timedelta(minutes=len(self.approvals)), + "resolved_at": None, + "resolved_by_user_id": None, + } + self.approvals.append(approval) + return approval + + def get_approval_optional(self, approval_id: UUID) -> dict[str, object] | None: + return next((approval for approval in self.approvals if approval["id"] == approval_id), None) + + def list_approvals(self) -> list[dict[str, object]]: + return sorted( + self.approvals, + key=lambda approval: (approval["created_at"], approval["id"]), + ) + + def get_task_optional(self, task_id: UUID) -> dict[str, object] | None: + return next((task for task in self.tasks if task["id"] == task_id), None) + + def create_task( + self, + *, + thread_id: UUID, + tool_id: UUID, + status: str, + request: dict[str, object], + tool: dict[str, object], + latest_approval_id: UUID | None, + latest_execution_id: UUID | None, + ) -> dict[str, object]: + task = { + "id": uuid4(), + "user_id": self.user_id, + "thread_id": thread_id, + "tool_id": tool_id, + "status": status, + "request": request, + "tool": tool, + "latest_approval_id": latest_approval_id, + "latest_execution_id": latest_execution_id, + "created_at": self.base_time + timedelta(minutes=len(self.tasks)), + "updated_at": self.base_time + timedelta(minutes=len(self.tasks)), + } + self.tasks.append(task) + return task + + def get_task_optional(self, task_id: UUID) -> dict[str, object] | None: + return next((task for task in self.tasks if task["id"] == task_id), None) + + def create_task_run( + self, + *, + task_id: UUID, + status: str, + checkpoint: dict[str, object], + tick_count: int, + step_count: int, + max_ticks: int, + retry_count: int = 0, + retry_cap: int = 1, + retry_posture: str = "none", + failure_class: str | None = None, + stop_reason: str | None = None, + ) -> dict[str, object]: + row = { + "id": uuid4(), + "user_id": self.user_id, + "task_id": task_id, + "status": status, + "checkpoint": checkpoint, + "tick_count": tick_count, + "step_count": step_count, + "max_ticks": max_ticks, + "retry_count": retry_count, + "retry_cap": retry_cap, + "retry_posture": retry_posture, + "failure_class": failure_class, + "stop_reason": stop_reason, + "last_transitioned_at": self.base_time + timedelta(minutes=len(self.task_runs)), + "created_at": self.base_time + timedelta(minutes=len(self.task_runs)), + "updated_at": self.base_time + timedelta(minutes=len(self.task_runs)), + } + self.task_runs.append(row) + return row + + def get_task_run_optional(self, task_run_id: UUID) -> dict[str, object] | None: + return next((row for row in self.task_runs if row["id"] == task_run_id), None) + + def update_task_run_optional( + self, + *, + task_run_id: UUID, + status: str, + checkpoint: dict[str, object], + tick_count: int, + step_count: int, + retry_count: int, + retry_cap: int, + retry_posture: str, + failure_class: str | None, + stop_reason: str | None, + ) -> dict[str, object] | None: + row = self.get_task_run_optional(task_run_id) + if row is None: + return None + row["status"] = status + row["checkpoint"] = checkpoint + row["tick_count"] = tick_count + row["step_count"] = step_count + row["retry_count"] = retry_count + row["retry_cap"] = retry_cap + row["retry_posture"] = retry_posture + row["failure_class"] = failure_class + row["stop_reason"] = stop_reason + row["last_transitioned_at"] = self.base_time + timedelta(hours=1, minutes=len(self.trace_events)) + row["updated_at"] = self.base_time + timedelta(hours=1, minutes=len(self.trace_events)) + return row + + def get_task_by_approval_optional(self, approval_id: UUID) -> dict[str, object] | None: + return next((task for task in self.tasks if task["latest_approval_id"] == approval_id), None) + + def lock_task_steps(self, task_id: UUID) -> None: + self.locked_task_ids.append(task_id) + + def list_tasks(self) -> list[dict[str, object]]: + return sorted( + self.tasks, + key=lambda task: (task["created_at"], task["id"]), + ) + + def create_task_step( + self, + *, + task_id: UUID, + sequence_no: int, + parent_step_id: UUID | None = None, + source_approval_id: UUID | None = None, + source_execution_id: UUID | None = None, + kind: str, + status: str, + request: dict[str, object], + outcome: dict[str, object], + trace_id: UUID, + trace_kind: str, + ) -> dict[str, object]: + task_step = { + "id": uuid4(), + "user_id": self.user_id, + "task_id": task_id, + "sequence_no": sequence_no, + "parent_step_id": parent_step_id, + "source_approval_id": source_approval_id, + "source_execution_id": source_execution_id, + "kind": kind, + "status": status, + "request": request, + "outcome": outcome, + "trace_id": trace_id, + "trace_kind": trace_kind, + "created_at": self.base_time + timedelta(minutes=len(self.task_steps)), + "updated_at": self.base_time + timedelta(minutes=len(self.task_steps)), + } + self.task_steps.append(task_step) + return task_step + + def get_task_step_for_task_sequence_optional( + self, + *, + task_id: UUID, + sequence_no: int, + ) -> dict[str, object] | None: + return next( + ( + task_step + for task_step in self.task_steps + if task_step["task_id"] == task_id and task_step["sequence_no"] == sequence_no + ), + None, + ) + + def get_task_step_optional(self, task_step_id: UUID) -> dict[str, object] | None: + return next((task_step for task_step in self.task_steps if task_step["id"] == task_step_id), None) + + def list_task_steps_for_task(self, task_id: UUID) -> list[dict[str, object]]: + return sorted( + [task_step for task_step in self.task_steps if task_step["task_id"] == task_id], + key=lambda task_step: (task_step["sequence_no"], task_step["created_at"], task_step["id"]), + ) + + def update_task_step_for_task_sequence_optional( + self, + *, + task_id: UUID, + sequence_no: int, + status: str, + outcome: dict[str, object], + trace_id: UUID, + trace_kind: str, + ) -> dict[str, object] | None: + task_step = self.get_task_step_for_task_sequence_optional(task_id=task_id, sequence_no=sequence_no) + if task_step is None: + return None + + task_step["status"] = status + task_step["outcome"] = outcome + task_step["trace_id"] = trace_id + task_step["trace_kind"] = trace_kind + task_step["updated_at"] = self.base_time + timedelta(hours=1, minutes=len(self.trace_events)) + return task_step + + def update_task_step_optional( + self, + *, + task_step_id: UUID, + status: str, + outcome: dict[str, object], + trace_id: UUID, + trace_kind: str, + ) -> dict[str, object] | None: + task_step = self.get_task_step_optional(task_step_id) + if task_step is None: + return None + + task_step["status"] = status + task_step["outcome"] = outcome + task_step["trace_id"] = trace_id + task_step["trace_kind"] = trace_kind + task_step["updated_at"] = self.base_time + timedelta(hours=1, minutes=len(self.trace_events)) + return task_step + + def update_task_status_by_approval_optional( + self, + *, + approval_id: UUID, + status: str, + ) -> dict[str, object] | None: + task = self.get_task_by_approval_optional(approval_id) + if task is None: + return None + task["status"] = status + task["updated_at"] = self.base_time + timedelta(hours=1, minutes=len(self.trace_events)) + return task + + def update_task_status_optional( + self, + *, + task_id: UUID, + status: str, + latest_approval_id: UUID | None, + latest_execution_id: UUID | None, + ) -> dict[str, object] | None: + task = self.get_task_optional(task_id) + if task is None: + return None + task["status"] = status + task["latest_approval_id"] = latest_approval_id + task["latest_execution_id"] = latest_execution_id + task["updated_at"] = self.base_time + timedelta(hours=1, minutes=len(self.trace_events)) + return task + + def resolve_approval_optional(self, *, approval_id: UUID, status: str) -> dict[str, object] | None: + approval = self.get_approval_optional(approval_id) + if approval is None or approval["status"] != "pending": + return None + + approval["status"] = status + approval["resolved_at"] = self.base_time + timedelta(hours=1, minutes=len(self.trace_events)) + approval["resolved_by_user_id"] = self.user_id + return approval + + def update_approval_task_step_optional( + self, + *, + approval_id: UUID, + task_step_id: UUID, + ) -> dict[str, object] | None: + approval = self.get_approval_optional(approval_id) + if approval is None: + return None + approval["task_step_id"] = task_step_id + return approval + + +def test_submit_approval_request_persists_record_for_approval_required_route() -> None: + store = ApprovalStoreStub() + tool = store.create_tool( + tool_key="shell.exec", + name="Shell Exec", + description="Run shell commands.", + version="1.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["shell"], + action_hints=["tool.run"], + scope_hints=["workspace"], + domain_hints=[], + risk_hints=[], + metadata={"transport": "local"}, + ) + policy = store.create_policy( + name="Require shell approval", + action="tool.run", + scope="workspace", + effect="require_approval", + priority=10, + active=True, + conditions={"tool_key": "shell.exec"}, + required_consents=[], + ) + + payload = submit_approval_request( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=ApprovalRequestCreateInput( + thread_id=store.thread_id, + tool_id=tool["id"], + action="tool.run", + scope="workspace", + attributes={"command": "ls"}, + ), + ) + + assert payload["decision"] == "approval_required" + assert payload["task"]["status"] == "pending_approval" + assert payload["task"]["latest_approval_id"] == payload["approval"]["id"] + assert payload["task"]["latest_execution_id"] is None + assert payload["approval"] is not None + assert payload["approval"]["status"] == "pending" + assert payload["approval"]["resolution"] is None + assert payload["approval"]["thread_id"] == str(store.thread_id) + assert payload["approval"]["task_step_id"] == str(store.task_steps[0]["id"]) + assert payload["approval"]["request"] == payload["request"] + assert payload["approval"]["tool"] == payload["tool"] + assert payload["approval"]["routing"] == { + "decision": "approval_required", + "reasons": payload["reasons"], + "trace": payload["routing_trace"], + } + assert payload["routing_trace"]["trace_event_count"] == 3 + assert payload["trace"]["trace_event_count"] == 8 + assert len(store.approvals) == 1 + assert len(store.tasks) == 1 + assert len(store.task_steps) == 1 + assert store.traces[0]["kind"] == "tool.route" + assert store.traces[1]["kind"] == "approval.request" + assert store.traces[1]["compiler_version"] == "approval_request_v0" + assert store.traces[1]["limits"] == { + "order": ["created_at_asc", "id_asc"], + "persisted": True, + } + assert [event["kind"] for event in store.trace_events[-8:]] == [ + "approval.request.request", + "approval.request.routing", + "approval.request.persisted", + "approval.request.summary", + "task.lifecycle.state", + "task.lifecycle.summary", + "task.step.lifecycle.state", + "task.step.lifecycle.summary", + ] + assert store.trace_events[-7]["payload"]["routing_trace_id"] == payload["routing_trace"]["trace_id"] + assert store.trace_events[-6]["payload"] == { + "approval_id": payload["approval"]["id"], + "task_step_id": payload["approval"]["task_step_id"], + "decision": "approval_required", + "persisted": True, + } + assert store.trace_events[-4]["payload"] == { + "task_id": payload["task"]["id"], + "source": "approval_request", + "previous_status": None, + "current_status": "pending_approval", + "latest_approval_id": payload["approval"]["id"], + "latest_execution_id": None, + } + assert store.trace_events[-2]["payload"] == { + "task_id": payload["task"]["id"], + "task_step_id": str(store.task_steps[0]["id"]), + "source": "approval_request", + "sequence_no": 1, + "kind": "governed_request", + "previous_status": None, + "current_status": "created", + "trace": { + "trace_id": payload["trace"]["trace_id"], + "trace_kind": "approval.request", + }, + } + assert payload["reasons"][-1] == { + "code": "policy_effect_require_approval", + "source": "policy", + "message": "Policy effect resolved the decision to 'require_approval'.", + "tool_id": str(tool["id"]), + "policy_id": str(policy["id"]), + "consent_key": None, + } + + +def test_submit_approval_request_does_not_persist_for_ready_or_denied_routes() -> None: + ready_store = ApprovalStoreStub() + ready_store.create_consent( + consent_key="web_access", + status="granted", + metadata={"source": "settings"}, + ) + ready_tool = ready_store.create_tool( + tool_key="browser.open", + name="Browser Open", + description="Open documentation pages.", + version="1.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["browser"], + action_hints=["tool.run"], + scope_hints=["workspace"], + domain_hints=["docs"], + risk_hints=[], + metadata={"transport": "proxy"}, + ) + ready_store.create_policy( + name="Allow docs browser", + action="tool.run", + scope="workspace", + effect="allow", + priority=10, + active=True, + conditions={"tool_key": "browser.open", "domain_hint": "docs"}, + required_consents=["web_access"], + ) + + ready_payload = submit_approval_request( + ready_store, # type: ignore[arg-type] + user_id=ready_store.user_id, + request=ApprovalRequestCreateInput( + thread_id=ready_store.thread_id, + tool_id=ready_tool["id"], + action="tool.run", + scope="workspace", + domain_hint="docs", + attributes={}, + ), + ) + + denied_store = ApprovalStoreStub() + denied_tool = denied_store.create_tool( + tool_key="calendar.read", + name="Calendar Read", + description="Read calendars.", + version="1.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["calendar"], + action_hints=["calendar.read"], + scope_hints=["calendar"], + domain_hints=[], + risk_hints=[], + metadata={}, + ) + denied_payload = submit_approval_request( + denied_store, # type: ignore[arg-type] + user_id=denied_store.user_id, + request=ApprovalRequestCreateInput( + thread_id=denied_store.thread_id, + tool_id=denied_tool["id"], + action="tool.run", + scope="workspace", + attributes={}, + ), + ) + + assert ready_payload["decision"] == "ready" + assert ready_payload["task"]["status"] == "approved" + assert ready_payload["task"]["latest_approval_id"] is None + assert ready_payload["approval"] is None + assert ready_store.approvals == [] + assert len(ready_store.task_steps) == 1 + assert [event["kind"] for event in ready_store.trace_events[-8:]] == [ + "approval.request.request", + "approval.request.routing", + "approval.request.skipped", + "approval.request.summary", + "task.lifecycle.state", + "task.lifecycle.summary", + "task.step.lifecycle.state", + "task.step.lifecycle.summary", + ] + + assert denied_payload["decision"] == "denied" + assert denied_payload["task"]["status"] == "denied" + assert denied_payload["task"]["latest_approval_id"] is None + assert denied_payload["approval"] is None + assert denied_store.approvals == [] + assert [reason["code"] for reason in denied_payload["reasons"]] == [ + "tool_action_unsupported", + "tool_scope_unsupported", + ] + + +def test_approve_approval_record_resolves_pending_and_records_trace() -> None: + store = ApprovalStoreStub() + approval = store.create_approval( + thread_id=store.thread_id, + tool_id=uuid4(), + task_step_id=None, + status="pending", + request={"thread_id": str(store.thread_id), "tool_id": "tool-1"}, + tool={"id": "tool-1", "tool_key": "shell.exec"}, + routing={"decision": "approval_required", "reasons": [], "trace": {"trace_id": "trace-1", "trace_event_count": 3}}, + routing_trace_id=uuid4(), + ) + created_task = store.create_task( + thread_id=store.thread_id, + tool_id=approval["tool_id"], + status="pending_approval", + request=approval["request"], + tool=approval["tool"], + latest_approval_id=approval["id"], + latest_execution_id=None, + ) + created_step = store.create_task_step( + task_id=created_task["id"], + sequence_no=1, + kind="governed_request", + status="created", + request=approval["request"], + outcome={ + "routing_decision": "approval_required", + "approval_id": str(approval["id"]), + "approval_status": "pending", + "execution_id": None, + "execution_status": None, + "blocked_reason": None, + }, + trace_id=uuid4(), + trace_kind="approval.request", + ) + approval["task_step_id"] = created_step["id"] + + payload = approve_approval_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=ApprovalApproveInput(approval_id=approval["id"]), + ) + + assert payload["approval"]["id"] == str(approval["id"]) + assert payload["approval"]["task_step_id"] == str(created_step["id"]) + assert payload["approval"]["status"] == "approved" + assert payload["approval"]["resolution"] == { + "resolved_at": "2026-03-12T10:00:00+00:00", + "resolved_by_user_id": str(store.user_id), + } + assert payload["trace"]["trace_event_count"] == 7 + assert store.traces[0]["kind"] == "approval.resolve" + assert store.traces[0]["compiler_version"] == "approval_resolution_v0" + assert store.traces[0]["limits"] == { + "order": ["created_at_asc", "id_asc"], + "requested_action": "approve", + "outcome": "resolved", + } + assert [event["kind"] for event in store.trace_events] == [ + "approval.resolution.request", + "approval.resolution.state", + "approval.resolution.summary", + "task.lifecycle.state", + "task.lifecycle.summary", + "task.step.lifecycle.state", + "task.step.lifecycle.summary", + ] + assert store.trace_events[1]["payload"] == { + "approval_id": str(approval["id"]), + "task_step_id": str(approval["task_step_id"]), + "requested_action": "approve", + "previous_status": "pending", + "outcome": "resolved", + "current_status": "approved", + "resolved_at": "2026-03-12T10:00:00+00:00", + "resolved_by_user_id": str(store.user_id), + } + assert store.trace_events[3]["payload"] == { + "task_id": str(store.tasks[0]["id"]), + "source": "approval_resolution", + "previous_status": "pending_approval", + "current_status": "approved", + "latest_approval_id": str(approval["id"]), + "latest_execution_id": None, + } + assert store.trace_events[5]["payload"] == { + "task_id": str(store.tasks[0]["id"]), + "task_step_id": str(store.task_steps[0]["id"]), + "source": "approval_resolution", + "sequence_no": 1, + "kind": "governed_request", + "previous_status": "created", + "current_status": "approved", + "trace": { + "trace_id": str(store.traces[0]["id"]), + "trace_kind": "approval.resolve", + }, + } + + +def test_reject_approval_record_resolves_pending_and_records_trace() -> None: + store = ApprovalStoreStub() + approval = store.create_approval( + thread_id=store.thread_id, + tool_id=uuid4(), + task_step_id=None, + status="pending", + request={"thread_id": str(store.thread_id), "tool_id": "tool-2"}, + tool={"id": "tool-2", "tool_key": "browser.open"}, + routing={"decision": "approval_required", "reasons": [], "trace": {"trace_id": "trace-2", "trace_event_count": 3}}, + routing_trace_id=uuid4(), + ) + created_task = store.create_task( + thread_id=store.thread_id, + tool_id=approval["tool_id"], + status="pending_approval", + request=approval["request"], + tool=approval["tool"], + latest_approval_id=approval["id"], + latest_execution_id=None, + ) + created_step = store.create_task_step( + task_id=created_task["id"], + sequence_no=1, + kind="governed_request", + status="created", + request=approval["request"], + outcome={ + "routing_decision": "approval_required", + "approval_id": str(approval["id"]), + "approval_status": "pending", + "execution_id": None, + "execution_status": None, + "blocked_reason": None, + }, + trace_id=uuid4(), + trace_kind="approval.request", + ) + approval["task_step_id"] = created_step["id"] + + payload = reject_approval_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=ApprovalRejectInput(approval_id=approval["id"]), + ) + + assert payload["approval"]["status"] == "rejected" + assert payload["approval"]["task_step_id"] == str(created_step["id"]) + assert payload["approval"]["resolution"] == { + "resolved_at": "2026-03-12T10:00:00+00:00", + "resolved_by_user_id": str(store.user_id), + } + assert store.trace_events[1]["payload"]["requested_action"] == "reject" + assert store.trace_events[1]["payload"]["current_status"] == "rejected" + + +def test_approval_resolution_resumes_waiting_approval_run_only() -> None: + store = ApprovalStoreStub() + approval = store.create_approval( + thread_id=store.thread_id, + tool_id=uuid4(), + task_step_id=None, + status="pending", + request={"thread_id": str(store.thread_id), "tool_id": "tool-run"}, + tool={"id": "tool-run", "tool_key": "shell.exec"}, + routing={"decision": "approval_required", "reasons": [], "trace": {"trace_id": "trace-run", "trace_event_count": 3}}, + routing_trace_id=uuid4(), + ) + created_task = store.create_task( + thread_id=store.thread_id, + tool_id=approval["tool_id"], + status="pending_approval", + request=approval["request"], + tool=approval["tool"], + latest_approval_id=approval["id"], + latest_execution_id=None, + ) + created_step = store.create_task_step( + task_id=created_task["id"], + sequence_no=1, + kind="governed_request", + status="created", + request=approval["request"], + outcome={ + "routing_decision": "approval_required", + "approval_id": str(approval["id"]), + "approval_status": "pending", + "execution_id": None, + "execution_status": None, + "blocked_reason": None, + }, + trace_id=uuid4(), + trace_kind="approval.request", + ) + approval["task_step_id"] = created_step["id"] + run = store.create_task_run( + task_id=created_task["id"], + status="waiting_approval", + checkpoint={ + "cursor": 0, + "target_steps": 1, + "wait_for_signal": True, + "waiting_approval_id": str(approval["id"]), + }, + tick_count=1, + step_count=0, + max_ticks=3, + stop_reason="waiting_approval", + ) + approval["task_run_id"] = run["id"] + + payload = approve_approval_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=ApprovalApproveInput(approval_id=approval["id"]), + ) + + assert payload["approval"]["status"] == "approved" + assert payload["trace"]["trace_event_count"] == 8 + assert store.task_runs[0]["status"] == "queued" + assert store.task_runs[0]["stop_reason"] is None + assert store.task_runs[0]["checkpoint"]["wait_for_signal"] is False + assert store.task_runs[0]["checkpoint"]["waiting_approval_id"] is None + assert store.task_runs[0]["checkpoint"]["resolved_approval_id"] == str(approval["id"]) + assert store.task_runs[0]["checkpoint"]["approval_resolution_status"] == "approved" + assert [event["kind"] for event in store.trace_events] == [ + "approval.resolution.request", + "approval.resolution.state", + "approval.resolution.summary", + "approval.resolution.run", + "task.lifecycle.state", + "task.lifecycle.summary", + "task.step.lifecycle.state", + "task.step.lifecycle.summary", + ] + + +def test_approval_resolution_does_not_reopen_cancelled_linked_run() -> None: + store = ApprovalStoreStub() + approval = store.create_approval( + thread_id=store.thread_id, + tool_id=uuid4(), + task_step_id=None, + status="pending", + request={"thread_id": str(store.thread_id), "tool_id": "tool-cancelled-run"}, + tool={"id": "tool-cancelled-run", "tool_key": "shell.exec"}, + routing={ + "decision": "approval_required", + "reasons": [], + "trace": {"trace_id": "trace-cancelled-run", "trace_event_count": 3}, + }, + routing_trace_id=uuid4(), + ) + created_task = store.create_task( + thread_id=store.thread_id, + tool_id=approval["tool_id"], + status="pending_approval", + request=approval["request"], + tool=approval["tool"], + latest_approval_id=approval["id"], + latest_execution_id=None, + ) + created_step = store.create_task_step( + task_id=created_task["id"], + sequence_no=1, + kind="governed_request", + status="created", + request=approval["request"], + outcome={ + "routing_decision": "approval_required", + "approval_id": str(approval["id"]), + "approval_status": "pending", + "execution_id": None, + "execution_status": None, + "blocked_reason": None, + }, + trace_id=uuid4(), + trace_kind="approval.request", + ) + approval["task_step_id"] = created_step["id"] + run = store.create_task_run( + task_id=created_task["id"], + status="cancelled", + checkpoint={ + "cursor": 0, + "target_steps": 1, + "wait_for_signal": False, + }, + tick_count=1, + step_count=0, + max_ticks=3, + stop_reason="cancelled", + ) + approval["task_run_id"] = run["id"] + + payload = approve_approval_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=ApprovalApproveInput(approval_id=approval["id"]), + ) + + assert payload["approval"]["status"] == "approved" + assert payload["trace"]["trace_event_count"] == 7 + assert store.task_runs[0]["status"] == "cancelled" + assert store.task_runs[0]["stop_reason"] == "cancelled" + assert store.task_runs[0]["checkpoint"] == { + "cursor": 0, + "target_steps": 1, + "wait_for_signal": False, + } + assert "approval.resolution.run" not in [event["kind"] for event in store.trace_events] + + +def test_approval_resolution_locks_task_steps_before_task_and_step_mutation() -> None: + class LockingApprovalStoreStub(ApprovalStoreStub): + def list_task_steps_for_task(self, task_id: UUID) -> list[dict[str, object]]: + if task_id not in self.locked_task_ids: + raise AssertionError("task-step boundary was checked before the task-step lock was taken") + return super().list_task_steps_for_task(task_id) + + def update_task_status_by_approval_optional( + self, + *, + approval_id: UUID, + status: str, + ) -> dict[str, object] | None: + task = self.get_task_by_approval_optional(approval_id) + if task is None: + return None + if task["id"] not in self.locked_task_ids: + raise AssertionError("task status changed before the task-step lock was taken") + return super().update_task_status_by_approval_optional( + approval_id=approval_id, + status=status, + ) + + store = LockingApprovalStoreStub() + approval = store.create_approval( + thread_id=store.thread_id, + tool_id=uuid4(), + task_step_id=None, + status="pending", + request={"thread_id": str(store.thread_id), "tool_id": "tool-lock"}, + tool={"id": "tool-lock", "tool_key": "shell.exec"}, + routing={"decision": "approval_required", "reasons": [], "trace": {"trace_id": "trace-lock", "trace_event_count": 3}}, + routing_trace_id=uuid4(), + ) + task = store.create_task( + thread_id=store.thread_id, + tool_id=approval["tool_id"], + status="pending_approval", + request=approval["request"], + tool=approval["tool"], + latest_approval_id=approval["id"], + latest_execution_id=None, + ) + created_step = store.create_task_step( + task_id=task["id"], + sequence_no=1, + kind="governed_request", + status="created", + request=approval["request"], + outcome={ + "routing_decision": "approval_required", + "approval_id": str(approval["id"]), + "approval_status": "pending", + "execution_id": None, + "execution_status": None, + "blocked_reason": None, + }, + trace_id=uuid4(), + trace_kind="approval.request", + ) + approval["task_step_id"] = created_step["id"] + + payload = approve_approval_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=ApprovalApproveInput(approval_id=approval["id"]), + ) + + assert payload["approval"]["status"] == "approved" + assert task["id"] in store.locked_task_ids + + +def test_resolution_rejects_duplicate_and_conflicting_updates_deterministically() -> None: + duplicate_store = ApprovalStoreStub() + duplicate_approval = duplicate_store.create_approval( + thread_id=duplicate_store.thread_id, + tool_id=uuid4(), + task_step_id=None, + status="pending", + request={"thread_id": str(duplicate_store.thread_id), "tool_id": "tool-3"}, + tool={"id": "tool-3", "tool_key": "shell.exec"}, + routing={"decision": "approval_required", "reasons": [], "trace": {"trace_id": "trace-3", "trace_event_count": 3}}, + routing_trace_id=uuid4(), + ) + duplicate_task = duplicate_store.create_task( + thread_id=duplicate_store.thread_id, + tool_id=duplicate_approval["tool_id"], + status="pending_approval", + request=duplicate_approval["request"], + tool=duplicate_approval["tool"], + latest_approval_id=duplicate_approval["id"], + latest_execution_id=None, + ) + duplicate_step = duplicate_store.create_task_step( + task_id=duplicate_task["id"], + sequence_no=1, + kind="governed_request", + status="created", + request=duplicate_approval["request"], + outcome={ + "routing_decision": "approval_required", + "approval_id": str(duplicate_approval["id"]), + "approval_status": "pending", + "execution_id": None, + "execution_status": None, + "blocked_reason": None, + }, + trace_id=uuid4(), + trace_kind="approval.request", + ) + duplicate_approval["task_step_id"] = duplicate_step["id"] + approve_approval_record( + duplicate_store, # type: ignore[arg-type] + user_id=duplicate_store.user_id, + request=ApprovalApproveInput(approval_id=duplicate_approval["id"]), + ) + + try: + approve_approval_record( + duplicate_store, # type: ignore[arg-type] + user_id=duplicate_store.user_id, + request=ApprovalApproveInput(approval_id=duplicate_approval["id"]), + ) + except ApprovalResolutionConflictError as exc: + assert str(exc) == f"approval {duplicate_approval['id']} was already approved" + else: + raise AssertionError("expected ApprovalResolutionConflictError for duplicate approval") + + assert duplicate_store.trace_events[-6]["payload"]["outcome"] == "duplicate_rejected" + + conflict_store = ApprovalStoreStub() + conflict_approval = conflict_store.create_approval( + thread_id=conflict_store.thread_id, + tool_id=uuid4(), + task_step_id=None, + status="pending", + request={"thread_id": str(conflict_store.thread_id), "tool_id": "tool-4"}, + tool={"id": "tool-4", "tool_key": "shell.exec"}, + routing={"decision": "approval_required", "reasons": [], "trace": {"trace_id": "trace-4", "trace_event_count": 3}}, + routing_trace_id=uuid4(), + ) + conflict_task = conflict_store.create_task( + thread_id=conflict_store.thread_id, + tool_id=conflict_approval["tool_id"], + status="pending_approval", + request=conflict_approval["request"], + tool=conflict_approval["tool"], + latest_approval_id=conflict_approval["id"], + latest_execution_id=None, + ) + conflict_step = conflict_store.create_task_step( + task_id=conflict_task["id"], + sequence_no=1, + kind="governed_request", + status="created", + request=conflict_approval["request"], + outcome={ + "routing_decision": "approval_required", + "approval_id": str(conflict_approval["id"]), + "approval_status": "pending", + "execution_id": None, + "execution_status": None, + "blocked_reason": None, + }, + trace_id=uuid4(), + trace_kind="approval.request", + ) + conflict_approval["task_step_id"] = conflict_step["id"] + approve_approval_record( + conflict_store, # type: ignore[arg-type] + user_id=conflict_store.user_id, + request=ApprovalApproveInput(approval_id=conflict_approval["id"]), + ) + + try: + reject_approval_record( + conflict_store, # type: ignore[arg-type] + user_id=conflict_store.user_id, + request=ApprovalRejectInput(approval_id=conflict_approval["id"]), + ) + except ApprovalResolutionConflictError as exc: + assert str(exc) == ( + f"approval {conflict_approval['id']} was already approved and cannot be rejected" + ) + else: + raise AssertionError("expected ApprovalResolutionConflictError for conflicting rejection") + + assert conflict_store.trace_events[-6]["payload"]["outcome"] == "conflict_rejected" + + +def test_approval_resolution_rejects_inconsistent_linkage_without_mutating_task_state() -> None: + store = ApprovalStoreStub() + approval = store.create_approval( + thread_id=store.thread_id, + tool_id=uuid4(), + task_step_id=None, + status="approved", + request={"thread_id": str(store.thread_id), "tool_id": "tool-boundary"}, + tool={"id": "tool-boundary", "tool_key": "shell.exec"}, + routing={"decision": "approval_required", "reasons": [], "trace": {"trace_id": "trace-boundary", "trace_event_count": 3}}, + routing_trace_id=uuid4(), + ) + task = store.create_task( + thread_id=store.thread_id, + tool_id=approval["tool_id"], + status="pending_approval", + request=approval["request"], + tool=approval["tool"], + latest_approval_id=approval["id"], + latest_execution_id=None, + ) + first_step = store.create_task_step( + task_id=task["id"], + sequence_no=1, + kind="governed_request", + status="executed", + request=approval["request"], + outcome={ + "routing_decision": "approval_required", + "approval_id": str(approval["id"]), + "approval_status": "approved", + "execution_id": str(uuid4()), + "execution_status": "completed", + "blocked_reason": None, + }, + trace_id=uuid4(), + trace_kind="tool.proxy.execute", + ) + later_step = store.create_task_step( + task_id=task["id"], + sequence_no=2, + parent_step_id=first_step["id"], + source_approval_id=approval["id"], + source_execution_id=uuid4(), + kind="governed_request", + status="created", + request=approval["request"], + outcome={ + "routing_decision": "approval_required", + "approval_id": None, + "approval_status": None, + "execution_id": None, + "execution_status": None, + "blocked_reason": None, + }, + trace_id=uuid4(), + trace_kind="task.step.continuation", + ) + approval["task_step_id"] = later_step["id"] + + original_first_trace_id = first_step["trace_id"] + original_first_outcome = dict(first_step["outcome"]) + original_later_trace_id = later_step["trace_id"] + + try: + approve_approval_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=ApprovalApproveInput(approval_id=approval["id"]), + ) + except TaskStepApprovalLinkageError as exc: + assert str(exc) == ( + f"approval {approval['id']} is inconsistent with linked task step {later_step['id']}" + ) + else: + raise AssertionError("expected TaskStepApprovalLinkageError") + + assert task["status"] == "pending_approval" + assert task["latest_execution_id"] is None + assert first_step["status"] == "executed" + assert first_step["trace_id"] == original_first_trace_id + assert first_step["outcome"] == original_first_outcome + assert later_step["status"] == "created" + assert later_step["trace_id"] == original_later_trace_id + assert store.traces == [] + assert store.trace_events == [] + + +def test_list_and_get_approval_records_use_deterministic_order_after_resolution() -> None: + store = ApprovalStoreStub() + first = store.create_approval( + thread_id=store.thread_id, + tool_id=uuid4(), + task_step_id=None, + status="pending", + request={"thread_id": str(store.thread_id), "tool_id": "tool-1"}, + tool={"id": "tool-1", "tool_key": "shell.exec"}, + routing={"decision": "approval_required", "reasons": [], "trace": {"trace_id": "trace-1", "trace_event_count": 3}}, + routing_trace_id=uuid4(), + ) + first_task = store.create_task( + thread_id=store.thread_id, + tool_id=first["tool_id"], + status="pending_approval", + request=first["request"], + tool=first["tool"], + latest_approval_id=first["id"], + latest_execution_id=None, + ) + first_step = store.create_task_step( + task_id=first_task["id"], + sequence_no=1, + kind="governed_request", + status="created", + request=first["request"], + outcome={ + "routing_decision": "approval_required", + "approval_id": str(first["id"]), + "approval_status": "pending", + "execution_id": None, + "execution_status": None, + "blocked_reason": None, + }, + trace_id=uuid4(), + trace_kind="approval.request", + ) + first["task_step_id"] = first_step["id"] + second = store.create_approval( + thread_id=store.thread_id, + tool_id=uuid4(), + task_step_id=None, + status="pending", + request={"thread_id": str(store.thread_id), "tool_id": "tool-2"}, + tool={"id": "tool-2", "tool_key": "browser.open"}, + routing={"decision": "approval_required", "reasons": [], "trace": {"trace_id": "trace-2", "trace_event_count": 3}}, + routing_trace_id=uuid4(), + ) + second_task = store.create_task( + thread_id=store.thread_id, + tool_id=second["tool_id"], + status="pending_approval", + request=second["request"], + tool=second["tool"], + latest_approval_id=second["id"], + latest_execution_id=None, + ) + second_step = store.create_task_step( + task_id=second_task["id"], + sequence_no=1, + kind="governed_request", + status="created", + request=second["request"], + outcome={ + "routing_decision": "approval_required", + "approval_id": str(second["id"]), + "approval_status": "pending", + "execution_id": None, + "execution_status": None, + "blocked_reason": None, + }, + trace_id=uuid4(), + trace_kind="approval.request", + ) + second["task_step_id"] = second_step["id"] + + approve_approval_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=ApprovalApproveInput(approval_id=first["id"]), + ) + reject_approval_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=ApprovalRejectInput(approval_id=second["id"]), + ) + + listed = list_approval_records( + store, # type: ignore[arg-type] + user_id=store.user_id, + ) + detail = get_approval_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + approval_id=UUID(str(second["id"])), + ) + + assert [item["id"] for item in listed["items"]] == [str(first["id"]), str(second["id"])] + assert [item["task_step_id"] for item in listed["items"]] == [str(first_step["id"]), str(second_step["id"])] + assert [item["status"] for item in listed["items"]] == ["approved", "rejected"] + assert listed["items"][0]["resolution"] is not None + assert listed["items"][1]["resolution"] is not None + assert listed["summary"] == { + "total_count": 2, + "order": ["created_at_asc", "id_asc"], + } + assert detail["approval"]["id"] == str(second["id"]) + assert detail["approval"]["task_step_id"] == str(second_step["id"]) + assert detail["approval"]["status"] == "rejected" + assert detail["approval"]["resolution"] == { + "resolved_at": "2026-03-12T10:07:00+00:00", + "resolved_by_user_id": str(store.user_id), + } + + +def test_get_approval_record_raises_not_found_when_missing() -> None: + store = ApprovalStoreStub() + + try: + get_approval_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + approval_id=uuid4(), + ) + except ApprovalNotFoundError as exc: + assert "approval" in str(exc) + else: + raise AssertionError("expected ApprovalNotFoundError") diff --git a/tests/unit/test_approvals_main.py b/tests/unit/test_approvals_main.py new file mode 100644 index 0000000..833f78d --- /dev/null +++ b/tests/unit/test_approvals_main.py @@ -0,0 +1,376 @@ +from __future__ import annotations + +import json +from contextlib import contextmanager +from uuid import uuid4 + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.approvals import ApprovalNotFoundError, ApprovalResolutionConflictError +from alicebot_api.tasks import TaskStepApprovalLinkageError +from alicebot_api.tools import ToolRoutingValidationError + + +def test_create_approval_request_endpoint_translates_request_and_returns_trace_payload(monkeypatch) -> None: + user_id = uuid4() + thread_id = uuid4() + tool_id = uuid4() + settings = Settings(database_url="postgresql://app") + captured: dict[str, object] = {} + + @contextmanager + def fake_user_connection(database_url: str, current_user_id): + captured["database_url"] = database_url + captured["current_user_id"] = current_user_id + yield object() + + def fake_submit_approval_request(store, *, user_id, request): + captured["store_type"] = type(store).__name__ + captured["user_id"] = user_id + captured["request"] = request + return { + "request": { + "thread_id": str(thread_id), + "tool_id": str(tool_id), + "action": "tool.run", + "scope": "workspace", + "domain_hint": None, + "risk_hint": None, + "attributes": {"command": "ls"}, + }, + "decision": "approval_required", + "tool": {"id": str(tool_id), "tool_key": "shell.exec"}, + "reasons": [], + "approval": { + "id": "approval-123", + "thread_id": str(thread_id), + "task_step_id": "task-step-123", + "status": "pending", + "resolution": None, + "request": { + "thread_id": str(thread_id), + "tool_id": str(tool_id), + "action": "tool.run", + "scope": "workspace", + "domain_hint": None, + "risk_hint": None, + "attributes": {"command": "ls"}, + }, + "tool": {"id": str(tool_id), "tool_key": "shell.exec"}, + "routing": { + "decision": "approval_required", + "reasons": [], + "trace": {"trace_id": "routing-trace-123", "trace_event_count": 3}, + }, + "created_at": "2026-03-12T09:00:00+00:00", + }, + "routing_trace": {"trace_id": "routing-trace-123", "trace_event_count": 3}, + "trace": {"trace_id": "approval-trace-123", "trace_event_count": 4}, + } + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "submit_approval_request", fake_submit_approval_request) + + response = main_module.create_approval_request( + main_module.CreateApprovalRequest( + user_id=user_id, + thread_id=thread_id, + tool_id=tool_id, + action="tool.run", + scope="workspace", + attributes={"command": "ls"}, + ) + ) + + assert response.status_code == 200 + assert json.loads(response.body)["trace"] == { + "trace_id": "approval-trace-123", + "trace_event_count": 4, + } + assert captured["database_url"] == "postgresql://app" + assert captured["current_user_id"] == user_id + assert captured["store_type"] == "ContinuityStore" + assert captured["user_id"] == user_id + assert captured["request"].thread_id == thread_id + assert captured["request"].tool_id == tool_id + assert captured["request"].attributes == {"command": "ls"} + + +def test_create_approval_request_endpoint_maps_validation_errors_to_400(monkeypatch) -> None: + user_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + def fake_submit_approval_request(*_args, **_kwargs): + raise ToolRoutingValidationError("tool_id must reference an existing active tool owned by the user") + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "submit_approval_request", fake_submit_approval_request) + + response = main_module.create_approval_request( + main_module.CreateApprovalRequest( + user_id=user_id, + thread_id=uuid4(), + tool_id=uuid4(), + action="tool.run", + scope="workspace", + attributes={}, + ) + ) + + assert response.status_code == 400 + assert json.loads(response.body) == { + "detail": "tool_id must reference an existing active tool owned by the user" + } + + +def test_list_approvals_endpoint_returns_payload(monkeypatch) -> None: + user_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr( + main_module, + "list_approval_records", + lambda *_args, **_kwargs: { + "items": [], + "summary": {"total_count": 0, "order": ["created_at_asc", "id_asc"]}, + }, + ) + + response = main_module.list_approvals(user_id) + + assert response.status_code == 200 + assert json.loads(response.body) == { + "items": [], + "summary": {"total_count": 0, "order": ["created_at_asc", "id_asc"]}, + } + + +def test_get_approval_endpoint_maps_not_found_to_404(monkeypatch) -> None: + user_id = uuid4() + approval_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + def fake_get_approval_record(*_args, **_kwargs): + raise ApprovalNotFoundError(f"approval {approval_id} was not found") + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "get_approval_record", fake_get_approval_record) + + response = main_module.get_approval(approval_id, user_id) + + assert response.status_code == 404 + assert json.loads(response.body) == {"detail": f"approval {approval_id} was not found"} + + +def test_approve_approval_endpoint_returns_payload(monkeypatch) -> None: + user_id = uuid4() + approval_id = uuid4() + settings = Settings(database_url="postgresql://app") + captured: dict[str, object] = {} + + @contextmanager + def fake_user_connection(database_url: str, current_user_id): + captured["database_url"] = database_url + captured["current_user_id"] = current_user_id + yield object() + + def fake_approve_approval_record(store, *, user_id, request): + captured["store_type"] = type(store).__name__ + captured["user_id"] = user_id + captured["request"] = request + return { + "approval": { + "id": str(approval_id), + "thread_id": "thread-123", + "task_step_id": "task-step-123", + "status": "approved", + "resolution": { + "resolved_at": "2026-03-12T10:00:00+00:00", + "resolved_by_user_id": str(user_id), + }, + "request": {"thread_id": "thread-123", "tool_id": "tool-123"}, + "tool": {"id": "tool-123", "tool_key": "shell.exec"}, + "routing": { + "decision": "approval_required", + "reasons": [], + "trace": {"trace_id": "routing-trace-123", "trace_event_count": 3}, + }, + "created_at": "2026-03-12T09:00:00+00:00", + }, + "trace": {"trace_id": "approval-resolution-trace-123", "trace_event_count": 3}, + } + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "approve_approval_record", fake_approve_approval_record) + + response = main_module.approve_approval( + approval_id, + main_module.ResolveApprovalRequest(user_id=user_id), + ) + + assert response.status_code == 200 + assert json.loads(response.body)["trace"] == { + "trace_id": "approval-resolution-trace-123", + "trace_event_count": 3, + } + assert captured["database_url"] == "postgresql://app" + assert captured["current_user_id"] == user_id + assert captured["store_type"] == "ContinuityStore" + assert captured["user_id"] == user_id + assert captured["request"].approval_id == approval_id + + +def test_approve_approval_endpoint_maps_conflicts_to_409(monkeypatch) -> None: + user_id = uuid4() + approval_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + def fake_approve_approval_record(*_args, **_kwargs): + raise ApprovalResolutionConflictError(f"approval {approval_id} was already approved") + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "approve_approval_record", fake_approve_approval_record) + + response = main_module.approve_approval( + approval_id, + main_module.ResolveApprovalRequest(user_id=user_id), + ) + + assert response.status_code == 409 + assert json.loads(response.body) == {"detail": f"approval {approval_id} was already approved"} + + +def test_approve_approval_endpoint_maps_linkage_errors_to_409(monkeypatch) -> None: + user_id = uuid4() + approval_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + def fake_approve_approval_record(*_args, **_kwargs): + raise TaskStepApprovalLinkageError( + f"approval {approval_id} is inconsistent with linked task step task-step-123" + ) + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "approve_approval_record", fake_approve_approval_record) + + response = main_module.approve_approval( + approval_id, + main_module.ResolveApprovalRequest(user_id=user_id), + ) + + assert response.status_code == 409 + assert json.loads(response.body) == { + "detail": f"approval {approval_id} is inconsistent with linked task step task-step-123" + } + + +def test_reject_approval_endpoint_returns_payload(monkeypatch) -> None: + user_id = uuid4() + approval_id = uuid4() + settings = Settings(database_url="postgresql://app") + captured: dict[str, object] = {} + + @contextmanager + def fake_user_connection(database_url: str, current_user_id): + captured["database_url"] = database_url + captured["current_user_id"] = current_user_id + yield object() + + def fake_reject_approval_record(store, *, user_id, request): + captured["store_type"] = type(store).__name__ + captured["user_id"] = user_id + captured["request"] = request + return { + "approval": { + "id": str(approval_id), + "thread_id": "thread-123", + "task_step_id": "task-step-456", + "status": "rejected", + "resolution": { + "resolved_at": "2026-03-12T10:01:00+00:00", + "resolved_by_user_id": str(user_id), + }, + "request": {"thread_id": "thread-123", "tool_id": "tool-123"}, + "tool": {"id": "tool-123", "tool_key": "shell.exec"}, + "routing": { + "decision": "approval_required", + "reasons": [], + "trace": {"trace_id": "routing-trace-123", "trace_event_count": 3}, + }, + "created_at": "2026-03-12T09:00:00+00:00", + }, + "trace": {"trace_id": "approval-resolution-trace-456", "trace_event_count": 3}, + } + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "reject_approval_record", fake_reject_approval_record) + + response = main_module.reject_approval( + approval_id, + main_module.ResolveApprovalRequest(user_id=user_id), + ) + + assert response.status_code == 200 + assert json.loads(response.body)["trace"] == { + "trace_id": "approval-resolution-trace-456", + "trace_event_count": 3, + } + assert captured["database_url"] == "postgresql://app" + assert captured["current_user_id"] == user_id + assert captured["store_type"] == "ContinuityStore" + assert captured["user_id"] == user_id + assert captured["request"].approval_id == approval_id + + +def test_reject_approval_endpoint_maps_not_found_to_404(monkeypatch) -> None: + user_id = uuid4() + approval_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + def fake_reject_approval_record(*_args, **_kwargs): + raise ApprovalNotFoundError(f"approval {approval_id} was not found") + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "reject_approval_record", fake_reject_approval_record) + + response = main_module.reject_approval( + approval_id, + main_module.ResolveApprovalRequest(user_id=user_id), + ) + + assert response.status_code == 404 + assert json.loads(response.body) == {"detail": f"approval {approval_id} was not found"} diff --git a/tests/unit/test_artifacts.py b/tests/unit/test_artifacts.py new file mode 100644 index 0000000..df3de49 --- /dev/null +++ b/tests/unit/test_artifacts.py @@ -0,0 +1,1991 @@ +from __future__ import annotations + +import io +import zlib +from datetime import UTC, datetime, timedelta +from pathlib import Path +from uuid import UUID, uuid4 +from xml.sax.saxutils import escape +import zipfile + +import pytest + +from alicebot_api.artifacts import ( + TASK_ARTIFACT_CHUNKING_RULE, + TASK_ARTIFACT_CHUNK_RETRIEVAL_MATCHING_RULE, + TaskArtifactAlreadyExistsError, + TaskArtifactChunkRetrievalValidationError, + TaskArtifactNotFoundError, + TaskArtifactValidationError, + build_workspace_relative_artifact_path, + chunk_normalized_artifact_text, + ensure_artifact_path_is_rooted, + extract_unique_lexical_terms, + get_task_artifact_record, + ingest_task_artifact_record, + list_task_artifact_chunk_records, + list_task_artifact_records, + match_artifact_chunk_text, + normalize_artifact_text, + register_task_artifact_record, + retrieve_artifact_scoped_artifact_chunk_records, + retrieve_task_scoped_artifact_chunk_records, + serialize_task_artifact_row, +) +from alicebot_api.contracts import ( + ArtifactScopedArtifactChunkRetrievalInput, + TaskArtifactIngestInput, + TaskArtifactRegisterInput, + TaskScopedArtifactChunkRetrievalInput, +) +from alicebot_api.tasks import TaskNotFoundError +from alicebot_api.workspaces import TaskWorkspaceNotFoundError + + +def _escape_pdf_literal_string(value: str) -> str: + return value.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)") + + +def _build_pdf_bytes( + pages: list[list[str]], + *, + compress_streams: bool = True, + textless: bool = False, +) -> bytes: + objects: dict[int, bytes] = { + 1: b"<< /Type /Catalog /Pages 2 0 R >>", + 3: b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + } + page_refs: list[str] = [] + next_object_id = 4 + for page_lines in pages: + page_object_id = next_object_id + content_object_id = next_object_id + 1 + next_object_id += 2 + page_refs.append(f"{page_object_id} 0 R") + + if textless: + content_stream = b"q 10 10 100 100 re S Q\n" + else: + commands = [b"BT", b"/F1 12 Tf", b"72 720 Td"] + for index, line in enumerate(page_lines): + if index > 0: + commands.append(b"T*") + commands.append(f"({_escape_pdf_literal_string(line)}) Tj".encode("latin-1")) + commands.append(b"ET") + content_stream = b"\n".join(commands) + b"\n" + + if compress_streams: + encoded_stream = zlib.compress(content_stream) + content_body = ( + f"<< /Length {len(encoded_stream)} /Filter /FlateDecode >>\n".encode("ascii") + + b"stream\n" + + encoded_stream + + b"\nendstream" + ) + else: + content_body = ( + f"<< /Length {len(content_stream)} >>\n".encode("ascii") + + b"stream\n" + + content_stream + + b"endstream" + ) + + objects[page_object_id] = ( + f"<< /Type /Page /Parent 2 0 R /Resources << /Font << /F1 3 0 R >> >> " + f"/MediaBox [0 0 612 792] /Contents {content_object_id} 0 R >>" + ).encode("ascii") + objects[content_object_id] = content_body + + objects[2] = ( + f"<< /Type /Pages /Count {len(page_refs)} /Kids [{' '.join(page_refs)}] >>" + ).encode("ascii") + + document = bytearray(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n") + max_object_id = max(objects) + offsets = [0] * (max_object_id + 1) + for object_id in range(1, max_object_id + 1): + offsets[object_id] = len(document) + document.extend(f"{object_id} 0 obj\n".encode("ascii")) + document.extend(objects[object_id]) + document.extend(b"\nendobj\n") + + xref_offset = len(document) + document.extend(f"xref\n0 {max_object_id + 1}\n".encode("ascii")) + document.extend(b"0000000000 65535 f \n") + for object_id in range(1, max_object_id + 1): + document.extend(f"{offsets[object_id]:010d} 00000 n \n".encode("ascii")) + document.extend( + ( + f"trailer\n<< /Size {max_object_id + 1} /Root 1 0 R >>\n" + f"startxref\n{xref_offset}\n%%EOF\n" + ).encode("ascii") + ) + return bytes(document) + + +def _build_docx_bytes( + paragraphs: list[str], + *, + include_document_xml: bool = True, + malformed_document_xml: bool = False, +) -> bytes: + document_xml = ( + b"<w:document" + if malformed_document_xml + else ( + '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' + '<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">' + "<w:body>" + + "".join( + ( + "<w:p><w:r><w:t xml:space=\"preserve\">" + f"{escape(paragraph)}" + "</w:t></w:r></w:p>" + ) + for paragraph in paragraphs + ) + + ( + "<w:sectPr>" + "<w:pgSz w:w=\"12240\" w:h=\"15840\"/>" + "<w:pgMar w:top=\"1440\" w:right=\"1440\" w:bottom=\"1440\" w:left=\"1440\" " + "w:header=\"708\" w:footer=\"708\" w:gutter=\"0\"/>" + "</w:sectPr>" + "</w:body>" + "</w:document>" + ) + ) + ) + + archive_buffer = io.BytesIO() + with zipfile.ZipFile(archive_buffer, "w", compression=zipfile.ZIP_STORED) as archive: + entries = { + "[Content_Types].xml": ( + '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' + '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' + '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' + '<Default Extension="xml" ContentType="application/xml"/>' + '<Override PartName="/word/document.xml" ' + 'ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>' + "</Types>" + ).encode("utf-8"), + "_rels/.rels": ( + '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' + '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' + '<Relationship Id="rId1" ' + 'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" ' + 'Target="word/document.xml"/>' + "</Relationships>" + ).encode("utf-8"), + } + if include_document_xml: + entries["word/document.xml"] = document_xml + + for name, payload in entries.items(): + info = zipfile.ZipInfo(filename=name) + info.date_time = (2026, 3, 13, 10, 0, 0) + info.compress_type = zipfile.ZIP_STORED + archive.writestr(info, payload) + + return archive_buffer.getvalue() + + +def _build_rfc822_email_bytes( + *, + headers: list[tuple[str, str]] | None = None, + plain_body: str | None = None, + plain_parts: list[str] | None = None, + html_body: str | None = None, + attachment_text: str | None = None, + nested_message_bytes: bytes | None = None, + malformed_multipart: bool = False, +) -> bytes: + header_lines = [ + f"{name}: {value}" + for name, value in ( + headers + if headers is not None + else [ + ("From", "Alice <alice@example.com>"), + ("To", "Bob <bob@example.com>"), + ("Subject", "Sprint Update"), + ] + ) + ] + if malformed_multipart: + return ( + "\r\n".join( + [ + *header_lines, + "MIME-Version: 1.0", + "Content-Type: multipart/mixed", + "", + "--broken-boundary", + 'Content-Type: text/plain; charset="utf-8"', + "", + "broken", + "--broken-boundary--", + "", + ] + ).encode("utf-8") + ) + + if ( + plain_parts is None + and html_body is None + and attachment_text is None + and nested_message_bytes is None + ): + return ( + "\r\n".join( + [ + *header_lines, + 'Content-Type: text/plain; charset="utf-8"', + "Content-Transfer-Encoding: 8bit", + "", + plain_body or "", + ] + ).encode("utf-8") + ) + + boundary = "alicebot-boundary-001" + lines = [ + *header_lines, + "MIME-Version: 1.0", + f'Content-Type: multipart/mixed; boundary="{boundary}"', + "", + ] + for part_text in plain_parts or []: + lines.extend( + [ + f"--{boundary}", + 'Content-Type: text/plain; charset="utf-8"', + "Content-Transfer-Encoding: 8bit", + "", + part_text, + ] + ) + if html_body is not None: + lines.extend( + [ + f"--{boundary}", + 'Content-Type: text/html; charset="utf-8"', + "Content-Transfer-Encoding: 8bit", + "", + html_body, + ] + ) + if attachment_text is not None: + lines.extend( + [ + f"--{boundary}", + 'Content-Type: text/plain; charset="utf-8"', + 'Content-Disposition: attachment; filename="note.txt"', + "Content-Transfer-Encoding: 8bit", + "", + attachment_text, + ] + ) + if nested_message_bytes is not None: + lines.extend( + [ + f"--{boundary}", + "Content-Type: message/rfc822", + "Content-Transfer-Encoding: 8bit", + "", + nested_message_bytes.decode("utf-8"), + ] + ) + lines.extend([f"--{boundary}--", ""]) + return "\r\n".join(lines).encode("utf-8") + + +class ArtifactStoreStub: + def __init__(self) -> None: + self.base_time = datetime(2026, 3, 13, 10, 0, tzinfo=UTC) + self.tasks: list[dict[str, object]] = [] + self.workspaces: list[dict[str, object]] = [] + self.artifacts: list[dict[str, object]] = [] + self.artifact_chunks: list[dict[str, object]] = [] + self.locked_workspace_ids: list[UUID] = [] + self.locked_artifact_ids: list[UUID] = [] + + def create_task(self, *, task_id: UUID, user_id: UUID) -> dict[str, object]: + task = { + "id": task_id, + "user_id": user_id, + "thread_id": uuid4(), + "tool_id": uuid4(), + "status": "approved", + "request": {}, + "tool": {}, + "latest_approval_id": None, + "latest_execution_id": None, + "created_at": self.base_time, + "updated_at": self.base_time, + } + self.tasks.append(task) + return task + + def get_task_optional(self, task_id: UUID) -> dict[str, object] | None: + return next((task for task in self.tasks if task["id"] == task_id), None) + + def create_task_workspace(self, *, task_workspace_id: UUID, task_id: UUID, user_id: UUID, local_path: str) -> dict[str, object]: + workspace = { + "id": task_workspace_id, + "user_id": user_id, + "task_id": task_id, + "status": "active", + "local_path": local_path, + "created_at": self.base_time, + "updated_at": self.base_time, + } + self.workspaces.append(workspace) + return workspace + + def get_task_workspace_optional(self, task_workspace_id: UUID) -> dict[str, object] | None: + return next((workspace for workspace in self.workspaces if workspace["id"] == task_workspace_id), None) + + def lock_task_artifacts(self, task_workspace_id: UUID) -> None: + self.locked_workspace_ids.append(task_workspace_id) + + def get_task_artifact_by_workspace_relative_path_optional( + self, + *, + task_workspace_id: UUID, + relative_path: str, + ) -> dict[str, object] | None: + return next( + ( + artifact + for artifact in self.artifacts + if artifact["task_workspace_id"] == task_workspace_id + and artifact["relative_path"] == relative_path + ), + None, + ) + + def create_task_artifact( + self, + *, + task_id: UUID, + task_workspace_id: UUID, + status: str, + ingestion_status: str, + relative_path: str, + media_type_hint: str | None, + ) -> dict[str, object]: + artifact = { + "id": uuid4(), + "user_id": self.workspaces[0]["user_id"], + "task_id": task_id, + "task_workspace_id": task_workspace_id, + "status": status, + "ingestion_status": ingestion_status, + "relative_path": relative_path, + "media_type_hint": media_type_hint, + "created_at": self.base_time + timedelta(minutes=len(self.artifacts)), + "updated_at": self.base_time + timedelta(minutes=len(self.artifacts)), + } + self.artifacts.append(artifact) + return artifact + + def list_task_artifacts(self) -> list[dict[str, object]]: + return sorted(self.artifacts, key=lambda artifact: (artifact["created_at"], artifact["id"])) + + def list_task_artifacts_for_task(self, task_id: UUID) -> list[dict[str, object]]: + return sorted( + (artifact for artifact in self.artifacts if artifact["task_id"] == task_id), + key=lambda artifact: (artifact["created_at"], artifact["id"]), + ) + + def get_task_artifact_optional(self, task_artifact_id: UUID) -> dict[str, object] | None: + return next((artifact for artifact in self.artifacts if artifact["id"] == task_artifact_id), None) + + def lock_task_artifact_ingestion(self, task_artifact_id: UUID) -> None: + self.locked_artifact_ids.append(task_artifact_id) + + def create_task_artifact_chunk( + self, + *, + task_artifact_id: UUID, + sequence_no: int, + char_start: int, + char_end_exclusive: int, + text: str, + ) -> dict[str, object]: + chunk = { + "id": uuid4(), + "user_id": self.workspaces[0]["user_id"], + "task_artifact_id": task_artifact_id, + "sequence_no": sequence_no, + "char_start": char_start, + "char_end_exclusive": char_end_exclusive, + "text": text, + "created_at": self.base_time + timedelta(seconds=len(self.artifact_chunks)), + "updated_at": self.base_time + timedelta(seconds=len(self.artifact_chunks)), + } + self.artifact_chunks.append(chunk) + return chunk + + def list_task_artifact_chunks(self, task_artifact_id: UUID) -> list[dict[str, object]]: + return sorted( + ( + chunk + for chunk in self.artifact_chunks + if chunk["task_artifact_id"] == task_artifact_id + ), + key=lambda chunk: (chunk["sequence_no"], chunk["id"]), + ) + + def update_task_artifact_ingestion_status( + self, + *, + task_artifact_id: UUID, + ingestion_status: str, + ) -> dict[str, object]: + artifact = self.get_task_artifact_optional(task_artifact_id) + assert artifact is not None + artifact["ingestion_status"] = ingestion_status + artifact["updated_at"] = self.base_time + timedelta(minutes=30) + return artifact + + +def test_ensure_artifact_path_is_rooted_rejects_escape() -> None: + with pytest.raises(TaskArtifactValidationError, match="escapes workspace root"): + ensure_artifact_path_is_rooted( + workspace_path=Path("/tmp/alicebot/task-workspaces/user/task"), + artifact_path=Path("/tmp/alicebot/task-workspaces/user/task/../escape.txt"), + ) + + +def test_build_workspace_relative_artifact_path_returns_posix_path() -> None: + relative_path = build_workspace_relative_artifact_path( + workspace_path=Path("/tmp/alicebot/task-workspaces/user/task"), + artifact_path=Path("/tmp/alicebot/task-workspaces/user/task/docs/spec.txt"), + ) + + assert relative_path == "docs/spec.txt" + + +def test_register_task_artifact_record_persists_relative_path_and_returns_record(tmp_path) -> None: + store = ArtifactStoreStub() + user_id = uuid4() + task_id = uuid4() + task_workspace_id = uuid4() + workspace_path = tmp_path / "workspaces" / str(user_id) / str(task_id) + workspace_path.mkdir(parents=True) + artifact_path = workspace_path / "docs" / "spec.txt" + artifact_path.parent.mkdir(parents=True) + artifact_path.write_text("spec") + store.create_task_workspace( + task_workspace_id=task_workspace_id, + task_id=task_id, + user_id=user_id, + local_path=str(workspace_path), + ) + + response = register_task_artifact_record( + store, + user_id=user_id, + request=TaskArtifactRegisterInput( + task_workspace_id=task_workspace_id, + local_path=str(artifact_path), + media_type_hint="text/plain", + ), + ) + + assert response == { + "artifact": { + "id": response["artifact"]["id"], + "task_id": str(task_id), + "task_workspace_id": str(task_workspace_id), + "status": "registered", + "ingestion_status": "pending", + "relative_path": "docs/spec.txt", + "media_type_hint": "text/plain", + "created_at": "2026-03-13T10:00:00+00:00", + "updated_at": "2026-03-13T10:00:00+00:00", + } + } + assert store.locked_workspace_ids == [task_workspace_id] + + +def test_register_task_artifact_record_rejects_duplicate_relative_path(tmp_path) -> None: + store = ArtifactStoreStub() + user_id = uuid4() + task_id = uuid4() + task_workspace_id = uuid4() + workspace_path = tmp_path / "workspaces" / str(user_id) / str(task_id) + workspace_path.mkdir(parents=True) + artifact_path = workspace_path / "docs" / "spec.txt" + artifact_path.parent.mkdir(parents=True) + artifact_path.write_text("spec") + store.create_task_workspace( + task_workspace_id=task_workspace_id, + task_id=task_id, + user_id=user_id, + local_path=str(workspace_path), + ) + + register_task_artifact_record( + store, + user_id=user_id, + request=TaskArtifactRegisterInput( + task_workspace_id=task_workspace_id, + local_path=str(artifact_path), + media_type_hint="text/plain", + ), + ) + + with pytest.raises( + TaskArtifactAlreadyExistsError, + match=f"artifact docs/spec.txt is already registered for task workspace {task_workspace_id}", + ): + register_task_artifact_record( + store, + user_id=user_id, + request=TaskArtifactRegisterInput( + task_workspace_id=task_workspace_id, + local_path=str(artifact_path), + media_type_hint="text/plain", + ), + ) + + +def test_register_task_artifact_record_requires_visible_workspace(tmp_path) -> None: + artifact_path = tmp_path / "spec.txt" + artifact_path.write_text("spec") + + with pytest.raises(TaskWorkspaceNotFoundError, match="was not found"): + register_task_artifact_record( + ArtifactStoreStub(), + user_id=uuid4(), + request=TaskArtifactRegisterInput( + task_workspace_id=uuid4(), + local_path=str(artifact_path), + media_type_hint=None, + ), + ) + + +def test_register_task_artifact_record_rejects_paths_outside_workspace(tmp_path) -> None: + store = ArtifactStoreStub() + user_id = uuid4() + task_id = uuid4() + task_workspace_id = uuid4() + workspace_path = tmp_path / "workspaces" / str(user_id) / str(task_id) + workspace_path.mkdir(parents=True) + outside_path = tmp_path / "escape.txt" + outside_path.write_text("escape") + store.create_task_workspace( + task_workspace_id=task_workspace_id, + task_id=task_id, + user_id=user_id, + local_path=str(workspace_path), + ) + + with pytest.raises(TaskArtifactValidationError, match="escapes workspace root"): + register_task_artifact_record( + store, + user_id=user_id, + request=TaskArtifactRegisterInput( + task_workspace_id=task_workspace_id, + local_path=str(outside_path), + media_type_hint=None, + ), + ) + + +def test_normalize_and_chunk_artifact_text_are_deterministic() -> None: + normalized = normalize_artifact_text("ab\r\ncd\ref") + + assert normalized == "ab\ncd\nef" + assert chunk_normalized_artifact_text(normalized, chunk_size=4) == [ + (0, 4, "ab\nc"), + (4, 8, "d\nef"), + ] + + +def test_ingest_task_artifact_record_persists_deterministic_chunks(tmp_path) -> None: + store = ArtifactStoreStub() + user_id = uuid4() + task_id = uuid4() + task_workspace_id = uuid4() + workspace_path = tmp_path / "workspaces" / str(user_id) / str(task_id) + workspace_path.mkdir(parents=True) + artifact_path = workspace_path / "docs" / "spec.txt" + artifact_path.parent.mkdir(parents=True) + artifact_path.write_text(("A" * 998) + "\r\n" + ("B" * 5) + "\rC") + store.create_task_workspace( + task_workspace_id=task_workspace_id, + task_id=task_id, + user_id=user_id, + local_path=str(workspace_path), + ) + artifact = store.create_task_artifact( + task_id=task_id, + task_workspace_id=task_workspace_id, + status="registered", + ingestion_status="pending", + relative_path="docs/spec.txt", + media_type_hint="text/plain", + ) + + response = ingest_task_artifact_record( + store, + user_id=user_id, + request=TaskArtifactIngestInput(task_artifact_id=artifact["id"]), + ) + + assert response == { + "artifact": { + "id": str(artifact["id"]), + "task_id": str(task_id), + "task_workspace_id": str(task_workspace_id), + "status": "registered", + "ingestion_status": "ingested", + "relative_path": "docs/spec.txt", + "media_type_hint": "text/plain", + "created_at": "2026-03-13T10:00:00+00:00", + "updated_at": "2026-03-13T10:30:00+00:00", + }, + "summary": { + "total_count": 2, + "total_characters": 1006, + "media_type": "text/plain", + "chunking_rule": TASK_ARTIFACT_CHUNKING_RULE, + "order": ["sequence_no_asc", "id_asc"], + }, + } + assert store.locked_artifact_ids == [artifact["id"]] + assert store.list_task_artifact_chunks(artifact["id"]) == [ + { + "id": store.artifact_chunks[0]["id"], + "user_id": user_id, + "task_artifact_id": artifact["id"], + "sequence_no": 1, + "char_start": 0, + "char_end_exclusive": 1000, + "text": ("A" * 998) + "\n" + "B", + "created_at": datetime(2026, 3, 13, 10, 0, tzinfo=UTC), + "updated_at": datetime(2026, 3, 13, 10, 0, tzinfo=UTC), + }, + { + "id": store.artifact_chunks[1]["id"], + "user_id": user_id, + "task_artifact_id": artifact["id"], + "sequence_no": 2, + "char_start": 1000, + "char_end_exclusive": 1006, + "text": "BBBB\nC", + "created_at": datetime(2026, 3, 13, 10, 0, 1, tzinfo=UTC), + "updated_at": datetime(2026, 3, 13, 10, 0, 1, tzinfo=UTC), + }, + ] + + +def test_ingest_task_artifact_record_supports_markdown(tmp_path) -> None: + store = ArtifactStoreStub() + user_id = uuid4() + task_id = uuid4() + task_workspace_id = uuid4() + workspace_path = tmp_path / "workspaces" / str(user_id) / str(task_id) + workspace_path.mkdir(parents=True) + artifact_path = workspace_path / "notes" / "plan.md" + artifact_path.parent.mkdir(parents=True) + artifact_path.write_text("# Plan\r\n\r\n- Ship ingestion\n- Keep scope narrow\r") + store.create_task_workspace( + task_workspace_id=task_workspace_id, + task_id=task_id, + user_id=user_id, + local_path=str(workspace_path), + ) + artifact = store.create_task_artifact( + task_id=task_id, + task_workspace_id=task_workspace_id, + status="registered", + ingestion_status="pending", + relative_path="notes/plan.md", + media_type_hint="text/markdown", + ) + + response = ingest_task_artifact_record( + store, + user_id=user_id, + request=TaskArtifactIngestInput(task_artifact_id=artifact["id"]), + ) + + assert response["artifact"]["ingestion_status"] == "ingested" + assert response["summary"] == { + "total_count": 1, + "total_characters": 45, + "media_type": "text/markdown", + "chunking_rule": TASK_ARTIFACT_CHUNKING_RULE, + "order": ["sequence_no_asc", "id_asc"], + } + assert store.list_task_artifact_chunks(artifact["id"]) == [ + { + "id": store.artifact_chunks[0]["id"], + "user_id": user_id, + "task_artifact_id": artifact["id"], + "sequence_no": 1, + "char_start": 0, + "char_end_exclusive": 45, + "text": "# Plan\n\n- Ship ingestion\n- Keep scope narrow\n", + "created_at": datetime(2026, 3, 13, 10, 0, tzinfo=UTC), + "updated_at": datetime(2026, 3, 13, 10, 0, tzinfo=UTC), + } + ] + + +def test_ingest_task_artifact_record_persists_deterministic_pdf_chunks(tmp_path) -> None: + store = ArtifactStoreStub() + user_id = uuid4() + task_id = uuid4() + task_workspace_id = uuid4() + workspace_path = tmp_path / "workspaces" / str(user_id) / str(task_id) + workspace_path.mkdir(parents=True) + artifact_path = workspace_path / "docs" / "spec.pdf" + artifact_path.parent.mkdir(parents=True) + artifact_path.write_bytes(_build_pdf_bytes([["A" * 998, "B" * 5, "C"]])) + store.create_task_workspace( + task_workspace_id=task_workspace_id, + task_id=task_id, + user_id=user_id, + local_path=str(workspace_path), + ) + artifact = store.create_task_artifact( + task_id=task_id, + task_workspace_id=task_workspace_id, + status="registered", + ingestion_status="pending", + relative_path="docs/spec.pdf", + media_type_hint="application/pdf", + ) + + response = ingest_task_artifact_record( + store, + user_id=user_id, + request=TaskArtifactIngestInput(task_artifact_id=artifact["id"]), + ) + + assert response == { + "artifact": { + "id": str(artifact["id"]), + "task_id": str(task_id), + "task_workspace_id": str(task_workspace_id), + "status": "registered", + "ingestion_status": "ingested", + "relative_path": "docs/spec.pdf", + "media_type_hint": "application/pdf", + "created_at": "2026-03-13T10:00:00+00:00", + "updated_at": "2026-03-13T10:30:00+00:00", + }, + "summary": { + "total_count": 2, + "total_characters": 1006, + "media_type": "application/pdf", + "chunking_rule": TASK_ARTIFACT_CHUNKING_RULE, + "order": ["sequence_no_asc", "id_asc"], + }, + } + assert store.locked_artifact_ids == [artifact["id"]] + assert store.list_task_artifact_chunks(artifact["id"]) == [ + { + "id": store.artifact_chunks[0]["id"], + "user_id": user_id, + "task_artifact_id": artifact["id"], + "sequence_no": 1, + "char_start": 0, + "char_end_exclusive": 1000, + "text": ("A" * 998) + "\n" + "B", + "created_at": datetime(2026, 3, 13, 10, 0, tzinfo=UTC), + "updated_at": datetime(2026, 3, 13, 10, 0, tzinfo=UTC), + }, + { + "id": store.artifact_chunks[1]["id"], + "user_id": user_id, + "task_artifact_id": artifact["id"], + "sequence_no": 2, + "char_start": 1000, + "char_end_exclusive": 1006, + "text": "BBBB\nC", + "created_at": datetime(2026, 3, 13, 10, 0, 1, tzinfo=UTC), + "updated_at": datetime(2026, 3, 13, 10, 0, 1, tzinfo=UTC), + }, + ] + + +def test_ingest_task_artifact_record_persists_deterministic_docx_chunks(tmp_path) -> None: + store = ArtifactStoreStub() + user_id = uuid4() + task_id = uuid4() + task_workspace_id = uuid4() + workspace_path = tmp_path / "workspaces" / str(user_id) / str(task_id) + workspace_path.mkdir(parents=True) + artifact_path = workspace_path / "docs" / "spec.docx" + artifact_path.parent.mkdir(parents=True) + artifact_path.write_bytes(_build_docx_bytes(["A" * 998, "B" * 5, "C"])) + store.create_task_workspace( + task_workspace_id=task_workspace_id, + task_id=task_id, + user_id=user_id, + local_path=str(workspace_path), + ) + artifact = store.create_task_artifact( + task_id=task_id, + task_workspace_id=task_workspace_id, + status="registered", + ingestion_status="pending", + relative_path="docs/spec.docx", + media_type_hint="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ) + + response = ingest_task_artifact_record( + store, + user_id=user_id, + request=TaskArtifactIngestInput(task_artifact_id=artifact["id"]), + ) + + assert response == { + "artifact": { + "id": str(artifact["id"]), + "task_id": str(task_id), + "task_workspace_id": str(task_workspace_id), + "status": "registered", + "ingestion_status": "ingested", + "relative_path": "docs/spec.docx", + "media_type_hint": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "created_at": "2026-03-13T10:00:00+00:00", + "updated_at": "2026-03-13T10:30:00+00:00", + }, + "summary": { + "total_count": 2, + "total_characters": 1006, + "media_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "chunking_rule": TASK_ARTIFACT_CHUNKING_RULE, + "order": ["sequence_no_asc", "id_asc"], + }, + } + assert store.locked_artifact_ids == [artifact["id"]] + assert store.list_task_artifact_chunks(artifact["id"]) == [ + { + "id": store.artifact_chunks[0]["id"], + "user_id": user_id, + "task_artifact_id": artifact["id"], + "sequence_no": 1, + "char_start": 0, + "char_end_exclusive": 1000, + "text": ("A" * 998) + "\n" + "B", + "created_at": datetime(2026, 3, 13, 10, 0, tzinfo=UTC), + "updated_at": datetime(2026, 3, 13, 10, 0, tzinfo=UTC), + }, + { + "id": store.artifact_chunks[1]["id"], + "user_id": user_id, + "task_artifact_id": artifact["id"], + "sequence_no": 2, + "char_start": 1000, + "char_end_exclusive": 1006, + "text": "BBBB\nC", + "created_at": datetime(2026, 3, 13, 10, 0, 1, tzinfo=UTC), + "updated_at": datetime(2026, 3, 13, 10, 0, 1, tzinfo=UTC), + }, + ] + + +def test_ingest_task_artifact_record_persists_deterministic_rfc822_chunks(tmp_path) -> None: + store = ArtifactStoreStub() + user_id = uuid4() + task_id = uuid4() + task_workspace_id = uuid4() + workspace_path = tmp_path / "workspaces" / str(user_id) / str(task_id) + workspace_path.mkdir(parents=True) + artifact_path = workspace_path / "mail" / "update.eml" + artifact_path.parent.mkdir(parents=True) + artifact_path.write_bytes( + _build_rfc822_email_bytes( + plain_body=("A" * 916) + "\r\n" + ("B" * 5) + "\rC", + ) + ) + store.create_task_workspace( + task_workspace_id=task_workspace_id, + task_id=task_id, + user_id=user_id, + local_path=str(workspace_path), + ) + artifact = store.create_task_artifact( + task_id=task_id, + task_workspace_id=task_workspace_id, + status="registered", + ingestion_status="pending", + relative_path="mail/update.eml", + media_type_hint="message/rfc822", + ) + + response = ingest_task_artifact_record( + store, + user_id=user_id, + request=TaskArtifactIngestInput(task_artifact_id=artifact["id"]), + ) + + header_block = ( + "From: Alice <alice@example.com>\n" + "To: Bob <bob@example.com>\n" + "Subject: Sprint Update\n\n" + ) + assert response == { + "artifact": { + "id": str(artifact["id"]), + "task_id": str(task_id), + "task_workspace_id": str(task_workspace_id), + "status": "registered", + "ingestion_status": "ingested", + "relative_path": "mail/update.eml", + "media_type_hint": "message/rfc822", + "created_at": "2026-03-13T10:00:00+00:00", + "updated_at": "2026-03-13T10:30:00+00:00", + }, + "summary": { + "total_count": 2, + "total_characters": 1006, + "media_type": "message/rfc822", + "chunking_rule": TASK_ARTIFACT_CHUNKING_RULE, + "order": ["sequence_no_asc", "id_asc"], + }, + } + assert store.locked_artifact_ids == [artifact["id"]] + assert store.list_task_artifact_chunks(artifact["id"]) == [ + { + "id": store.artifact_chunks[0]["id"], + "user_id": user_id, + "task_artifact_id": artifact["id"], + "sequence_no": 1, + "char_start": 0, + "char_end_exclusive": 1000, + "text": header_block + ("A" * 916) + "\n" + "B", + "created_at": datetime(2026, 3, 13, 10, 0, tzinfo=UTC), + "updated_at": datetime(2026, 3, 13, 10, 0, tzinfo=UTC), + }, + { + "id": store.artifact_chunks[1]["id"], + "user_id": user_id, + "task_artifact_id": artifact["id"], + "sequence_no": 2, + "char_start": 1000, + "char_end_exclusive": 1006, + "text": "BBBB\nC", + "created_at": datetime(2026, 3, 13, 10, 0, 1, tzinfo=UTC), + "updated_at": datetime(2026, 3, 13, 10, 0, 1, tzinfo=UTC), + }, + ] + + +def test_ingest_task_artifact_record_extracts_plain_text_parts_from_multipart_rfc822_email( + tmp_path, +) -> None: + store = ArtifactStoreStub() + user_id = uuid4() + task_id = uuid4() + task_workspace_id = uuid4() + workspace_path = tmp_path / "workspaces" / str(user_id) / str(task_id) + workspace_path.mkdir(parents=True) + artifact_path = workspace_path / "mail" / "multipart.eml" + artifact_path.parent.mkdir(parents=True) + artifact_path.write_bytes( + _build_rfc822_email_bytes( + plain_parts=["Alpha\r\nBeta", "Gamma"], + html_body="<p>ignored</p>", + attachment_text="ignored attachment", + ) + ) + store.create_task_workspace( + task_workspace_id=task_workspace_id, + task_id=task_id, + user_id=user_id, + local_path=str(workspace_path), + ) + artifact = store.create_task_artifact( + task_id=task_id, + task_workspace_id=task_workspace_id, + status="registered", + ingestion_status="pending", + relative_path="mail/multipart.eml", + media_type_hint="message/rfc822", + ) + + response = ingest_task_artifact_record( + store, + user_id=user_id, + request=TaskArtifactIngestInput(task_artifact_id=artifact["id"]), + ) + + assert response["summary"] == { + "total_count": 1, + "total_characters": 99, + "media_type": "message/rfc822", + "chunking_rule": TASK_ARTIFACT_CHUNKING_RULE, + "order": ["sequence_no_asc", "id_asc"], + } + assert store.list_task_artifact_chunks(artifact["id"]) == [ + { + "id": store.artifact_chunks[0]["id"], + "user_id": user_id, + "task_artifact_id": artifact["id"], + "sequence_no": 1, + "char_start": 0, + "char_end_exclusive": 99, + "text": ( + "From: Alice <alice@example.com>\n" + "To: Bob <bob@example.com>\n" + "Subject: Sprint Update\n\n" + "Alpha\nBeta\n\nGamma" + ), + "created_at": datetime(2026, 3, 13, 10, 0, tzinfo=UTC), + "updated_at": datetime(2026, 3, 13, 10, 0, tzinfo=UTC), + } + ] + + +def test_ingest_task_artifact_record_excludes_nested_rfc822_message_bodies(tmp_path) -> None: + store = ArtifactStoreStub() + user_id = uuid4() + task_id = uuid4() + task_workspace_id = uuid4() + workspace_path = tmp_path / "workspaces" / str(user_id) / str(task_id) + workspace_path.mkdir(parents=True) + artifact_path = workspace_path / "mail" / "forwarded.eml" + artifact_path.parent.mkdir(parents=True) + artifact_path.write_bytes( + _build_rfc822_email_bytes( + plain_parts=["Outer body"], + nested_message_bytes=_build_rfc822_email_bytes( + headers=[ + ("From", "Nested <nested@example.com>"), + ("To", "Team <team@example.com>"), + ("Subject", "Nested"), + ], + plain_body="Inner body", + ), + ) + ) + store.create_task_workspace( + task_workspace_id=task_workspace_id, + task_id=task_id, + user_id=user_id, + local_path=str(workspace_path), + ) + artifact = store.create_task_artifact( + task_id=task_id, + task_workspace_id=task_workspace_id, + status="registered", + ingestion_status="pending", + relative_path="mail/forwarded.eml", + media_type_hint="message/rfc822", + ) + + response = ingest_task_artifact_record( + store, + user_id=user_id, + request=TaskArtifactIngestInput(task_artifact_id=artifact["id"]), + ) + + expected_text = ( + "From: Alice <alice@example.com>\n" + "To: Bob <bob@example.com>\n" + "Subject: Sprint Update\n\n" + "Outer body" + ) + assert response["summary"] == { + "total_count": 1, + "total_characters": len(expected_text), + "media_type": "message/rfc822", + "chunking_rule": TASK_ARTIFACT_CHUNKING_RULE, + "order": ["sequence_no_asc", "id_asc"], + } + assert store.list_task_artifact_chunks(artifact["id"]) == [ + { + "id": store.artifact_chunks[0]["id"], + "user_id": user_id, + "task_artifact_id": artifact["id"], + "sequence_no": 1, + "char_start": 0, + "char_end_exclusive": len(expected_text), + "text": expected_text, + "created_at": datetime(2026, 3, 13, 10, 0, tzinfo=UTC), + "updated_at": datetime(2026, 3, 13, 10, 0, tzinfo=UTC), + } + ] + + +def test_ingest_task_artifact_record_is_idempotent_for_already_ingested_artifact() -> None: + store = ArtifactStoreStub() + user_id = uuid4() + task_id = uuid4() + task_workspace_id = uuid4() + store.create_task_workspace( + task_workspace_id=task_workspace_id, + task_id=task_id, + user_id=user_id, + local_path="/tmp/alicebot/task-workspaces/user/task", + ) + artifact = store.create_task_artifact( + task_id=task_id, + task_workspace_id=task_workspace_id, + status="registered", + ingestion_status="ingested", + relative_path="docs/spec.txt", + media_type_hint="text/plain", + ) + store.create_task_artifact_chunk( + task_artifact_id=artifact["id"], + sequence_no=1, + char_start=0, + char_end_exclusive=4, + text="spec", + ) + + response = ingest_task_artifact_record( + store, + user_id=user_id, + request=TaskArtifactIngestInput(task_artifact_id=artifact["id"]), + ) + + assert response == { + "artifact": { + "id": str(artifact["id"]), + "task_id": str(task_id), + "task_workspace_id": str(task_workspace_id), + "status": "registered", + "ingestion_status": "ingested", + "relative_path": "docs/spec.txt", + "media_type_hint": "text/plain", + "created_at": "2026-03-13T10:00:00+00:00", + "updated_at": "2026-03-13T10:00:00+00:00", + }, + "summary": { + "total_count": 1, + "total_characters": 4, + "media_type": "text/plain", + "chunking_rule": TASK_ARTIFACT_CHUNKING_RULE, + "order": ["sequence_no_asc", "id_asc"], + }, + } + assert store.locked_artifact_ids == [artifact["id"]] + assert len(store.artifact_chunks) == 1 + + +def test_ingest_task_artifact_record_rejects_unsupported_media_type(tmp_path) -> None: + store = ArtifactStoreStub() + user_id = uuid4() + task_id = uuid4() + task_workspace_id = uuid4() + workspace_path = tmp_path / "workspaces" / str(user_id) / str(task_id) + workspace_path.mkdir(parents=True) + artifact_path = workspace_path / "docs" / "spec.bin" + artifact_path.parent.mkdir(parents=True) + artifact_path.write_bytes(b"\x00\x01\x02") + store.create_task_workspace( + task_workspace_id=task_workspace_id, + task_id=task_id, + user_id=user_id, + local_path=str(workspace_path), + ) + artifact = store.create_task_artifact( + task_id=task_id, + task_workspace_id=task_workspace_id, + status="registered", + ingestion_status="pending", + relative_path="docs/spec.bin", + media_type_hint="application/octet-stream", + ) + + with pytest.raises( + TaskArtifactValidationError, + match=( + "artifact docs/spec.bin has unsupported media type application/octet-stream; " + "supported types: text/plain, text/markdown, application/pdf, " + "application/vnd.openxmlformats-officedocument.wordprocessingml.document, " + "message/rfc822" + ), + ): + ingest_task_artifact_record( + store, + user_id=user_id, + request=TaskArtifactIngestInput(task_artifact_id=artifact["id"]), + ) + + +def test_ingest_task_artifact_record_rejects_textless_pdf(tmp_path) -> None: + store = ArtifactStoreStub() + user_id = uuid4() + task_id = uuid4() + task_workspace_id = uuid4() + workspace_path = tmp_path / "workspaces" / str(user_id) / str(task_id) + workspace_path.mkdir(parents=True) + artifact_path = workspace_path / "docs" / "scanned.pdf" + artifact_path.parent.mkdir(parents=True) + artifact_path.write_bytes(_build_pdf_bytes([[]], textless=True)) + store.create_task_workspace( + task_workspace_id=task_workspace_id, + task_id=task_id, + user_id=user_id, + local_path=str(workspace_path), + ) + artifact = store.create_task_artifact( + task_id=task_id, + task_workspace_id=task_workspace_id, + status="registered", + ingestion_status="pending", + relative_path="docs/scanned.pdf", + media_type_hint="application/pdf", + ) + + with pytest.raises( + TaskArtifactValidationError, + match="artifact docs/scanned.pdf does not contain extractable PDF text", + ): + ingest_task_artifact_record( + store, + user_id=user_id, + request=TaskArtifactIngestInput(task_artifact_id=artifact["id"]), + ) + + +def test_ingest_task_artifact_record_rejects_textless_docx(tmp_path) -> None: + store = ArtifactStoreStub() + user_id = uuid4() + task_id = uuid4() + task_workspace_id = uuid4() + workspace_path = tmp_path / "workspaces" / str(user_id) / str(task_id) + workspace_path.mkdir(parents=True) + artifact_path = workspace_path / "docs" / "empty.docx" + artifact_path.parent.mkdir(parents=True) + artifact_path.write_bytes(_build_docx_bytes(["", ""])) + store.create_task_workspace( + task_workspace_id=task_workspace_id, + task_id=task_id, + user_id=user_id, + local_path=str(workspace_path), + ) + artifact = store.create_task_artifact( + task_id=task_id, + task_workspace_id=task_workspace_id, + status="registered", + ingestion_status="pending", + relative_path="docs/empty.docx", + media_type_hint="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ) + + with pytest.raises( + TaskArtifactValidationError, + match="artifact docs/empty.docx does not contain extractable DOCX text", + ): + ingest_task_artifact_record( + store, + user_id=user_id, + request=TaskArtifactIngestInput(task_artifact_id=artifact["id"]), + ) + + +def test_ingest_task_artifact_record_rejects_malformed_docx(tmp_path) -> None: + store = ArtifactStoreStub() + user_id = uuid4() + task_id = uuid4() + task_workspace_id = uuid4() + workspace_path = tmp_path / "workspaces" / str(user_id) / str(task_id) + workspace_path.mkdir(parents=True) + artifact_path = workspace_path / "docs" / "broken.docx" + artifact_path.parent.mkdir(parents=True) + artifact_path.write_bytes(_build_docx_bytes(["broken"], malformed_document_xml=True)) + store.create_task_workspace( + task_workspace_id=task_workspace_id, + task_id=task_id, + user_id=user_id, + local_path=str(workspace_path), + ) + artifact = store.create_task_artifact( + task_id=task_id, + task_workspace_id=task_workspace_id, + status="registered", + ingestion_status="pending", + relative_path="docs/broken.docx", + media_type_hint="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ) + + with pytest.raises( + TaskArtifactValidationError, + match="artifact docs/broken.docx is not a valid DOCX", + ): + ingest_task_artifact_record( + store, + user_id=user_id, + request=TaskArtifactIngestInput(task_artifact_id=artifact["id"]), + ) + + +def test_ingest_task_artifact_record_rejects_textless_rfc822_email(tmp_path) -> None: + store = ArtifactStoreStub() + user_id = uuid4() + task_id = uuid4() + task_workspace_id = uuid4() + workspace_path = tmp_path / "workspaces" / str(user_id) / str(task_id) + workspace_path.mkdir(parents=True) + artifact_path = workspace_path / "mail" / "empty.eml" + artifact_path.parent.mkdir(parents=True) + artifact_path.write_bytes(_build_rfc822_email_bytes(html_body="<p>html only</p>")) + store.create_task_workspace( + task_workspace_id=task_workspace_id, + task_id=task_id, + user_id=user_id, + local_path=str(workspace_path), + ) + artifact = store.create_task_artifact( + task_id=task_id, + task_workspace_id=task_workspace_id, + status="registered", + ingestion_status="pending", + relative_path="mail/empty.eml", + media_type_hint="message/rfc822", + ) + + with pytest.raises( + TaskArtifactValidationError, + match="artifact mail/empty.eml does not contain extractable RFC822 email text", + ): + ingest_task_artifact_record( + store, + user_id=user_id, + request=TaskArtifactIngestInput(task_artifact_id=artifact["id"]), + ) + + +def test_ingest_task_artifact_record_rejects_malformed_rfc822_email(tmp_path) -> None: + store = ArtifactStoreStub() + user_id = uuid4() + task_id = uuid4() + task_workspace_id = uuid4() + workspace_path = tmp_path / "workspaces" / str(user_id) / str(task_id) + workspace_path.mkdir(parents=True) + artifact_path = workspace_path / "mail" / "broken.eml" + artifact_path.parent.mkdir(parents=True) + artifact_path.write_bytes(_build_rfc822_email_bytes(malformed_multipart=True)) + store.create_task_workspace( + task_workspace_id=task_workspace_id, + task_id=task_id, + user_id=user_id, + local_path=str(workspace_path), + ) + artifact = store.create_task_artifact( + task_id=task_id, + task_workspace_id=task_workspace_id, + status="registered", + ingestion_status="pending", + relative_path="mail/broken.eml", + media_type_hint="message/rfc822", + ) + + with pytest.raises( + TaskArtifactValidationError, + match="artifact mail/broken.eml is not a valid RFC822 email", + ): + ingest_task_artifact_record( + store, + user_id=user_id, + request=TaskArtifactIngestInput(task_artifact_id=artifact["id"]), + ) + + +def test_ingest_task_artifact_record_rejects_invalid_utf8_content(tmp_path) -> None: + store = ArtifactStoreStub() + user_id = uuid4() + task_id = uuid4() + task_workspace_id = uuid4() + workspace_path = tmp_path / "workspaces" / str(user_id) / str(task_id) + workspace_path.mkdir(parents=True) + artifact_path = workspace_path / "docs" / "broken.txt" + artifact_path.parent.mkdir(parents=True) + artifact_path.write_bytes(b"\xff\xfe\xfd") + store.create_task_workspace( + task_workspace_id=task_workspace_id, + task_id=task_id, + user_id=user_id, + local_path=str(workspace_path), + ) + artifact = store.create_task_artifact( + task_id=task_id, + task_workspace_id=task_workspace_id, + status="registered", + ingestion_status="pending", + relative_path="docs/broken.txt", + media_type_hint="text/plain", + ) + + with pytest.raises( + TaskArtifactValidationError, + match="artifact docs/broken.txt is not valid UTF-8 text", + ): + ingest_task_artifact_record( + store, + user_id=user_id, + request=TaskArtifactIngestInput(task_artifact_id=artifact["id"]), + ) + + +def test_ingest_task_artifact_record_rejects_paths_outside_workspace(tmp_path) -> None: + store = ArtifactStoreStub() + user_id = uuid4() + task_id = uuid4() + task_workspace_id = uuid4() + workspace_path = tmp_path / "workspaces" / str(user_id) / str(task_id) + workspace_path.mkdir(parents=True) + outside_path = tmp_path / "escape.pdf" + outside_path.write_bytes(_build_pdf_bytes([["escape"]])) + store.create_task_workspace( + task_workspace_id=task_workspace_id, + task_id=task_id, + user_id=user_id, + local_path=str(workspace_path), + ) + artifact = store.create_task_artifact( + task_id=task_id, + task_workspace_id=task_workspace_id, + status="registered", + ingestion_status="pending", + relative_path="../escape.pdf", + media_type_hint="application/pdf", + ) + + with pytest.raises(TaskArtifactValidationError, match="escapes workspace root"): + ingest_task_artifact_record( + store, + user_id=user_id, + request=TaskArtifactIngestInput(task_artifact_id=artifact["id"]), + ) + + +def test_ingest_task_artifact_record_rejects_docx_paths_outside_workspace(tmp_path) -> None: + store = ArtifactStoreStub() + user_id = uuid4() + task_id = uuid4() + task_workspace_id = uuid4() + workspace_path = tmp_path / "workspaces" / str(user_id) / str(task_id) + workspace_path.mkdir(parents=True) + outside_path = tmp_path / "escape.docx" + outside_path.write_bytes(_build_docx_bytes(["escape"])) + store.create_task_workspace( + task_workspace_id=task_workspace_id, + task_id=task_id, + user_id=user_id, + local_path=str(workspace_path), + ) + artifact = store.create_task_artifact( + task_id=task_id, + task_workspace_id=task_workspace_id, + status="registered", + ingestion_status="pending", + relative_path="../escape.docx", + media_type_hint="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ) + + with pytest.raises(TaskArtifactValidationError, match="escapes workspace root"): + ingest_task_artifact_record( + store, + user_id=user_id, + request=TaskArtifactIngestInput(task_artifact_id=artifact["id"]), + ) + + +def test_ingest_task_artifact_record_rejects_rfc822_paths_outside_workspace(tmp_path) -> None: + store = ArtifactStoreStub() + user_id = uuid4() + task_id = uuid4() + task_workspace_id = uuid4() + workspace_path = tmp_path / "workspaces" / str(user_id) / str(task_id) + workspace_path.mkdir(parents=True) + outside_path = tmp_path / "escape.eml" + outside_path.write_bytes(_build_rfc822_email_bytes(plain_body="escape")) + store.create_task_workspace( + task_workspace_id=task_workspace_id, + task_id=task_id, + user_id=user_id, + local_path=str(workspace_path), + ) + artifact = store.create_task_artifact( + task_id=task_id, + task_workspace_id=task_workspace_id, + status="registered", + ingestion_status="pending", + relative_path="../escape.eml", + media_type_hint="message/rfc822", + ) + + with pytest.raises(TaskArtifactValidationError, match="escapes workspace root"): + ingest_task_artifact_record( + store, + user_id=user_id, + request=TaskArtifactIngestInput(task_artifact_id=artifact["id"]), + ) + + +def test_list_task_artifact_chunk_records_are_deterministic() -> None: + store = ArtifactStoreStub() + user_id = uuid4() + task_id = uuid4() + task_workspace_id = uuid4() + store.create_task_workspace( + task_workspace_id=task_workspace_id, + task_id=task_id, + user_id=user_id, + local_path="/tmp/alicebot/task-workspaces/user/task", + ) + artifact = store.create_task_artifact( + task_id=task_id, + task_workspace_id=task_workspace_id, + status="registered", + ingestion_status="ingested", + relative_path="docs/spec.txt", + media_type_hint="text/plain", + ) + store.create_task_artifact_chunk( + task_artifact_id=artifact["id"], + sequence_no=1, + char_start=0, + char_end_exclusive=4, + text="spec", + ) + store.create_task_artifact_chunk( + task_artifact_id=artifact["id"], + sequence_no=2, + char_start=4, + char_end_exclusive=8, + text="plan", + ) + + assert list_task_artifact_chunk_records( + store, + user_id=user_id, + task_artifact_id=artifact["id"], + ) == { + "items": [ + { + "id": str(store.artifact_chunks[0]["id"]), + "task_artifact_id": str(artifact["id"]), + "sequence_no": 1, + "char_start": 0, + "char_end_exclusive": 4, + "text": "spec", + "created_at": "2026-03-13T10:00:00+00:00", + "updated_at": "2026-03-13T10:00:00+00:00", + }, + { + "id": str(store.artifact_chunks[1]["id"]), + "task_artifact_id": str(artifact["id"]), + "sequence_no": 2, + "char_start": 4, + "char_end_exclusive": 8, + "text": "plan", + "created_at": "2026-03-13T10:00:01+00:00", + "updated_at": "2026-03-13T10:00:01+00:00", + }, + ], + "summary": { + "total_count": 2, + "total_characters": 8, + "media_type": "text/plain", + "chunking_rule": TASK_ARTIFACT_CHUNKING_RULE, + "order": ["sequence_no_asc", "id_asc"], + }, + } + + +def test_extract_unique_lexical_terms_preserves_first_occurrence_order() -> None: + assert extract_unique_lexical_terms("Alpha beta, alpha\nbeta gamma") == [ + "alpha", + "beta", + "gamma", + ] + + +def test_match_artifact_chunk_text_returns_explicit_metadata() -> None: + assert match_artifact_chunk_text( + query_terms=["alpha", "beta", "delta"], + chunk_text="beta alpha release", + ) == { + "matched_query_terms": ["alpha", "beta"], + "matched_query_term_count": 2, + "first_match_char_start": 0, + } + + +def test_task_scoped_chunk_retrieval_orders_matches_deterministically_and_skips_pending() -> None: + store = ArtifactStoreStub() + user_id = uuid4() + task_id = uuid4() + task_workspace_id = uuid4() + store.create_task(task_id=task_id, user_id=user_id) + store.create_task_workspace( + task_workspace_id=task_workspace_id, + task_id=task_id, + user_id=user_id, + local_path="/tmp/alicebot/task-workspaces/user/task", + ) + docs_artifact = store.create_task_artifact( + task_id=task_id, + task_workspace_id=task_workspace_id, + status="registered", + ingestion_status="ingested", + relative_path="docs/a.txt", + media_type_hint="text/plain", + ) + notes_artifact = store.create_task_artifact( + task_id=task_id, + task_workspace_id=task_workspace_id, + status="registered", + ingestion_status="ingested", + relative_path="notes/b.md", + media_type_hint="text/markdown", + ) + pending_artifact = store.create_task_artifact( + task_id=task_id, + task_workspace_id=task_workspace_id, + status="registered", + ingestion_status="pending", + relative_path="notes/hidden.txt", + media_type_hint="text/plain", + ) + weak_match_artifact = store.create_task_artifact( + task_id=task_id, + task_workspace_id=task_workspace_id, + status="registered", + ingestion_status="ingested", + relative_path="notes/c.txt", + media_type_hint="text/plain", + ) + store.create_task_artifact_chunk( + task_artifact_id=docs_artifact["id"], + sequence_no=1, + char_start=0, + char_end_exclusive=14, + text="beta alpha doc", + ) + store.create_task_artifact_chunk( + task_artifact_id=notes_artifact["id"], + sequence_no=1, + char_start=0, + char_end_exclusive=15, + text="alpha beta note", + ) + store.create_task_artifact_chunk( + task_artifact_id=pending_artifact["id"], + sequence_no=1, + char_start=0, + char_end_exclusive=17, + text="alpha beta hidden", + ) + store.create_task_artifact_chunk( + task_artifact_id=weak_match_artifact["id"], + sequence_no=1, + char_start=0, + char_end_exclusive=9, + text="beta only", + ) + + assert retrieve_task_scoped_artifact_chunk_records( + store, + user_id=user_id, + request=TaskScopedArtifactChunkRetrievalInput( + task_id=task_id, + query="Alpha beta", + ), + ) == { + "items": [ + { + "id": str(store.artifact_chunks[0]["id"]), + "task_id": str(task_id), + "task_artifact_id": str(docs_artifact["id"]), + "relative_path": "docs/a.txt", + "media_type": "text/plain", + "sequence_no": 1, + "char_start": 0, + "char_end_exclusive": 14, + "text": "beta alpha doc", + "match": { + "matched_query_terms": ["alpha", "beta"], + "matched_query_term_count": 2, + "first_match_char_start": 0, + }, + }, + { + "id": str(store.artifact_chunks[1]["id"]), + "task_id": str(task_id), + "task_artifact_id": str(notes_artifact["id"]), + "relative_path": "notes/b.md", + "media_type": "text/markdown", + "sequence_no": 1, + "char_start": 0, + "char_end_exclusive": 15, + "text": "alpha beta note", + "match": { + "matched_query_terms": ["alpha", "beta"], + "matched_query_term_count": 2, + "first_match_char_start": 0, + }, + }, + { + "id": str(store.artifact_chunks[3]["id"]), + "task_id": str(task_id), + "task_artifact_id": str(weak_match_artifact["id"]), + "relative_path": "notes/c.txt", + "media_type": "text/plain", + "sequence_no": 1, + "char_start": 0, + "char_end_exclusive": 9, + "text": "beta only", + "match": { + "matched_query_terms": ["beta"], + "matched_query_term_count": 1, + "first_match_char_start": 0, + }, + }, + ], + "summary": { + "total_count": 3, + "searched_artifact_count": 3, + "query": "Alpha beta", + "query_terms": ["alpha", "beta"], + "matching_rule": TASK_ARTIFACT_CHUNK_RETRIEVAL_MATCHING_RULE, + "order": [ + "matched_query_term_count_desc", + "first_match_char_start_asc", + "relative_path_asc", + "sequence_no_asc", + "id_asc", + ], + "scope": { + "kind": "task", + "task_id": str(task_id), + }, + }, + } + + +def test_artifact_scoped_chunk_retrieval_returns_empty_for_non_ingested_artifact() -> None: + store = ArtifactStoreStub() + user_id = uuid4() + task_id = uuid4() + task_workspace_id = uuid4() + store.create_task(task_id=task_id, user_id=user_id) + store.create_task_workspace( + task_workspace_id=task_workspace_id, + task_id=task_id, + user_id=user_id, + local_path="/tmp/alicebot/task-workspaces/user/task", + ) + artifact = store.create_task_artifact( + task_id=task_id, + task_workspace_id=task_workspace_id, + status="registered", + ingestion_status="pending", + relative_path="docs/spec.txt", + media_type_hint="text/plain", + ) + store.create_task_artifact_chunk( + task_artifact_id=artifact["id"], + sequence_no=1, + char_start=0, + char_end_exclusive=10, + text="alpha beta", + ) + + assert retrieve_artifact_scoped_artifact_chunk_records( + store, + user_id=user_id, + request=ArtifactScopedArtifactChunkRetrievalInput( + task_artifact_id=artifact["id"], + query="alpha", + ), + ) == { + "items": [], + "summary": { + "total_count": 0, + "searched_artifact_count": 0, + "query": "alpha", + "query_terms": ["alpha"], + "matching_rule": TASK_ARTIFACT_CHUNK_RETRIEVAL_MATCHING_RULE, + "order": [ + "matched_query_term_count_desc", + "first_match_char_start_asc", + "relative_path_asc", + "sequence_no_asc", + "id_asc", + ], + "scope": { + "kind": "artifact", + "task_id": str(task_id), + "task_artifact_id": str(artifact["id"]), + }, + }, + } + + +def test_task_scoped_chunk_retrieval_returns_empty_when_no_chunks_match() -> None: + store = ArtifactStoreStub() + user_id = uuid4() + task_id = uuid4() + task_workspace_id = uuid4() + store.create_task(task_id=task_id, user_id=user_id) + store.create_task_workspace( + task_workspace_id=task_workspace_id, + task_id=task_id, + user_id=user_id, + local_path="/tmp/alicebot/task-workspaces/user/task", + ) + artifact = store.create_task_artifact( + task_id=task_id, + task_workspace_id=task_workspace_id, + status="registered", + ingestion_status="ingested", + relative_path="docs/spec.txt", + media_type_hint="text/plain", + ) + store.create_task_artifact_chunk( + task_artifact_id=artifact["id"], + sequence_no=1, + char_start=0, + char_end_exclusive=11, + text="release plan", + ) + + response = retrieve_task_scoped_artifact_chunk_records( + store, + user_id=user_id, + request=TaskScopedArtifactChunkRetrievalInput( + task_id=task_id, + query="alpha", + ), + ) + + assert response == { + "items": [], + "summary": { + "total_count": 0, + "searched_artifact_count": 1, + "query": "alpha", + "query_terms": ["alpha"], + "matching_rule": TASK_ARTIFACT_CHUNK_RETRIEVAL_MATCHING_RULE, + "order": [ + "matched_query_term_count_desc", + "first_match_char_start_asc", + "relative_path_asc", + "sequence_no_asc", + "id_asc", + ], + "scope": { + "kind": "task", + "task_id": str(task_id), + }, + }, + } + + +def test_task_scoped_chunk_retrieval_raises_when_task_is_missing() -> None: + with pytest.raises(TaskNotFoundError, match="was not found"): + retrieve_task_scoped_artifact_chunk_records( + ArtifactStoreStub(), + user_id=uuid4(), + request=TaskScopedArtifactChunkRetrievalInput( + task_id=uuid4(), + query="alpha", + ), + ) + + +def test_artifact_chunk_retrieval_rejects_query_without_words() -> None: + store = ArtifactStoreStub() + user_id = uuid4() + task_id = uuid4() + task_workspace_id = uuid4() + store.create_task(task_id=task_id, user_id=user_id) + store.create_task_workspace( + task_workspace_id=task_workspace_id, + task_id=task_id, + user_id=user_id, + local_path="/tmp/alicebot/task-workspaces/user/task", + ) + artifact = store.create_task_artifact( + task_id=task_id, + task_workspace_id=task_workspace_id, + status="registered", + ingestion_status="ingested", + relative_path="docs/spec.txt", + media_type_hint="text/plain", + ) + + with pytest.raises( + TaskArtifactChunkRetrievalValidationError, + match="must include at least one word", + ): + retrieve_artifact_scoped_artifact_chunk_records( + store, + user_id=user_id, + request=ArtifactScopedArtifactChunkRetrievalInput( + task_artifact_id=artifact["id"], + query=" ... ", + ), + ) + + +def test_list_and_get_task_artifact_records_are_deterministic() -> None: + store = ArtifactStoreStub() + user_id = uuid4() + task_id = uuid4() + task_workspace_id = uuid4() + store.create_task_workspace( + task_workspace_id=task_workspace_id, + task_id=task_id, + user_id=user_id, + local_path="/tmp/alicebot/task-workspaces/user/task", + ) + first = store.create_task_artifact( + task_id=task_id, + task_workspace_id=task_workspace_id, + status="registered", + ingestion_status="pending", + relative_path="docs/a.txt", + media_type_hint="text/plain", + ) + second = store.create_task_artifact( + task_id=task_id, + task_workspace_id=task_workspace_id, + status="registered", + ingestion_status="pending", + relative_path="docs/b.txt", + media_type_hint=None, + ) + + assert list_task_artifact_records(store, user_id=user_id) == { + "items": [ + serialize_task_artifact_row(first), + serialize_task_artifact_row(second), + ], + "summary": { + "total_count": 2, + "order": ["created_at_asc", "id_asc"], + }, + } + assert get_task_artifact_record( + store, + user_id=user_id, + task_artifact_id=first["id"], + ) == {"artifact": serialize_task_artifact_row(first)} + + +def test_get_task_artifact_record_raises_when_artifact_is_missing() -> None: + with pytest.raises(TaskArtifactNotFoundError, match="was not found"): + get_task_artifact_record( + ArtifactStoreStub(), + user_id=uuid4(), + task_artifact_id=uuid4(), + ) diff --git a/tests/unit/test_artifacts_main.py b/tests/unit/test_artifacts_main.py new file mode 100644 index 0000000..439d47f --- /dev/null +++ b/tests/unit/test_artifacts_main.py @@ -0,0 +1,548 @@ +from __future__ import annotations + +import json +from contextlib import contextmanager +from uuid import uuid4 + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.artifacts import ( + TaskArtifactAlreadyExistsError, + TaskArtifactChunkRetrievalValidationError, + TaskArtifactNotFoundError, + TaskArtifactValidationError, +) +from alicebot_api.semantic_retrieval import SemanticArtifactChunkRetrievalValidationError +from alicebot_api.tasks import TaskNotFoundError +from alicebot_api.workspaces import TaskWorkspaceNotFoundError + + +def test_list_task_artifacts_endpoint_returns_payload(monkeypatch) -> None: + user_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr( + main_module, + "list_task_artifact_records", + lambda *_args, **_kwargs: { + "items": [], + "summary": {"total_count": 0, "order": ["created_at_asc", "id_asc"]}, + }, + ) + + response = main_module.list_task_artifacts(user_id) + + assert response.status_code == 200 + assert json.loads(response.body) == { + "items": [], + "summary": {"total_count": 0, "order": ["created_at_asc", "id_asc"]}, + } + + +def test_get_task_artifact_endpoint_maps_not_found_to_404(monkeypatch) -> None: + user_id = uuid4() + task_artifact_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + def fake_get_task_artifact_record(*_args, **_kwargs): + raise TaskArtifactNotFoundError(f"task artifact {task_artifact_id} was not found") + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "get_task_artifact_record", fake_get_task_artifact_record) + + response = main_module.get_task_artifact(task_artifact_id, user_id) + + assert response.status_code == 404 + assert json.loads(response.body) == {"detail": f"task artifact {task_artifact_id} was not found"} + + +def test_list_task_artifact_chunks_endpoint_returns_payload(monkeypatch) -> None: + user_id = uuid4() + task_artifact_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr( + main_module, + "list_task_artifact_chunk_records", + lambda *_args, **_kwargs: { + "items": [], + "summary": { + "total_count": 0, + "total_characters": 0, + "media_type": "text/plain", + "chunking_rule": "normalized_utf8_text_fixed_window_1000_chars_v1", + "order": ["sequence_no_asc", "id_asc"], + }, + }, + ) + + response = main_module.list_task_artifact_chunks(task_artifact_id, user_id) + + assert response.status_code == 200 + assert json.loads(response.body) == { + "items": [], + "summary": { + "total_count": 0, + "total_characters": 0, + "media_type": "text/plain", + "chunking_rule": "normalized_utf8_text_fixed_window_1000_chars_v1", + "order": ["sequence_no_asc", "id_asc"], + }, + } + + +def test_retrieve_task_artifact_chunks_endpoint_returns_payload(monkeypatch) -> None: + user_id = uuid4() + task_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr( + main_module, + "retrieve_task_scoped_artifact_chunk_records", + lambda *_args, **_kwargs: { + "items": [], + "summary": { + "total_count": 0, + "searched_artifact_count": 1, + "query": "alpha", + "query_terms": ["alpha"], + "matching_rule": "casefolded_unicode_word_overlap_unique_query_terms_v1", + "order": [ + "matched_query_term_count_desc", + "first_match_char_start_asc", + "relative_path_asc", + "sequence_no_asc", + "id_asc", + ], + "scope": {"kind": "task", "task_id": str(task_id)}, + }, + }, + ) + + response = main_module.retrieve_task_artifact_chunks( + task_id, + main_module.RetrieveArtifactChunksRequest(user_id=user_id, query="alpha"), + ) + + assert response.status_code == 200 + assert json.loads(response.body) == { + "items": [], + "summary": { + "total_count": 0, + "searched_artifact_count": 1, + "query": "alpha", + "query_terms": ["alpha"], + "matching_rule": "casefolded_unicode_word_overlap_unique_query_terms_v1", + "order": [ + "matched_query_term_count_desc", + "first_match_char_start_asc", + "relative_path_asc", + "sequence_no_asc", + "id_asc", + ], + "scope": {"kind": "task", "task_id": str(task_id)}, + }, + } + + +def test_retrieve_task_artifact_chunks_endpoint_maps_task_not_found_to_404(monkeypatch) -> None: + user_id = uuid4() + task_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + def fake_retrieve_task_scoped_artifact_chunk_records(*_args, **_kwargs): + raise TaskNotFoundError(f"task {task_id} was not found") + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr( + main_module, + "retrieve_task_scoped_artifact_chunk_records", + fake_retrieve_task_scoped_artifact_chunk_records, + ) + + response = main_module.retrieve_task_artifact_chunks( + task_id, + main_module.RetrieveArtifactChunksRequest(user_id=user_id, query="alpha"), + ) + + assert response.status_code == 404 + assert json.loads(response.body) == {"detail": f"task {task_id} was not found"} + + +def test_retrieve_task_artifact_chunks_endpoint_maps_validation_to_400(monkeypatch) -> None: + user_id = uuid4() + task_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + def fake_retrieve_task_scoped_artifact_chunk_records(*_args, **_kwargs): + raise TaskArtifactChunkRetrievalValidationError( + "artifact chunk retrieval query must include at least one word" + ) + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr( + main_module, + "retrieve_task_scoped_artifact_chunk_records", + fake_retrieve_task_scoped_artifact_chunk_records, + ) + + response = main_module.retrieve_task_artifact_chunks( + task_id, + main_module.RetrieveArtifactChunksRequest(user_id=user_id, query="alpha"), + ) + + assert response.status_code == 400 + assert json.loads(response.body) == { + "detail": "artifact chunk retrieval query must include at least one word" + } + + +def test_retrieve_semantic_task_artifact_chunks_endpoint_returns_payload(monkeypatch) -> None: + user_id = uuid4() + task_id = uuid4() + config_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr( + main_module, + "retrieve_task_scoped_semantic_artifact_chunk_records", + lambda *_args, **_kwargs: { + "items": [], + "summary": { + "embedding_config_id": str(config_id), + "query_vector_dimensions": 3, + "limit": 5, + "returned_count": 0, + "searched_artifact_count": 1, + "similarity_metric": "cosine_similarity", + "order": ["score_desc", "relative_path_asc", "sequence_no_asc", "id_asc"], + "scope": {"kind": "task", "task_id": str(task_id)}, + }, + }, + ) + + response = main_module.retrieve_semantic_task_artifact_chunks( + task_id, + main_module.RetrieveSemanticArtifactChunksRequest( + user_id=user_id, + embedding_config_id=config_id, + query_vector=[1.0, 0.0, 0.0], + limit=5, + ), + ) + + assert response.status_code == 200 + assert json.loads(response.body) == { + "items": [], + "summary": { + "embedding_config_id": str(config_id), + "query_vector_dimensions": 3, + "limit": 5, + "returned_count": 0, + "searched_artifact_count": 1, + "similarity_metric": "cosine_similarity", + "order": ["score_desc", "relative_path_asc", "sequence_no_asc", "id_asc"], + "scope": {"kind": "task", "task_id": str(task_id)}, + }, + } + + +def test_retrieve_semantic_task_artifact_chunks_endpoint_maps_validation_to_400(monkeypatch) -> None: + user_id = uuid4() + task_id = uuid4() + config_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + def fake_retrieve_task_scoped_semantic_artifact_chunk_records(*_args, **_kwargs): + raise SemanticArtifactChunkRetrievalValidationError( + f"embedding_config_id must reference an existing embedding config owned by the user: {config_id}" + ) + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr( + main_module, + "retrieve_task_scoped_semantic_artifact_chunk_records", + fake_retrieve_task_scoped_semantic_artifact_chunk_records, + ) + + response = main_module.retrieve_semantic_task_artifact_chunks( + task_id, + main_module.RetrieveSemanticArtifactChunksRequest( + user_id=user_id, + embedding_config_id=config_id, + query_vector=[1.0, 0.0, 0.0], + limit=5, + ), + ) + + assert response.status_code == 400 + assert json.loads(response.body) == { + "detail": ( + "embedding_config_id must reference an existing embedding config owned by the user: " + f"{config_id}" + ) + } + + +def test_retrieve_semantic_artifact_chunk_endpoint_maps_not_found_to_404(monkeypatch) -> None: + user_id = uuid4() + task_artifact_id = uuid4() + config_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + def fake_retrieve_artifact_scoped_semantic_artifact_chunk_records(*_args, **_kwargs): + raise TaskArtifactNotFoundError(f"task artifact {task_artifact_id} was not found") + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr( + main_module, + "retrieve_artifact_scoped_semantic_artifact_chunk_records", + fake_retrieve_artifact_scoped_semantic_artifact_chunk_records, + ) + + response = main_module.retrieve_semantic_artifact_chunks_for_artifact( + task_artifact_id, + main_module.RetrieveSemanticArtifactChunksRequest( + user_id=user_id, + embedding_config_id=config_id, + query_vector=[1.0, 0.0, 0.0], + limit=5, + ), + ) + + assert response.status_code == 404 + assert json.loads(response.body) == { + "detail": f"task artifact {task_artifact_id} was not found" + } + + +def test_retrieve_artifact_chunk_endpoint_maps_not_found_to_404(monkeypatch) -> None: + user_id = uuid4() + task_artifact_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + def fake_retrieve_artifact_scoped_artifact_chunk_records(*_args, **_kwargs): + raise TaskArtifactNotFoundError(f"task artifact {task_artifact_id} was not found") + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr( + main_module, + "retrieve_artifact_scoped_artifact_chunk_records", + fake_retrieve_artifact_scoped_artifact_chunk_records, + ) + + response = main_module.retrieve_task_artifact_chunks_for_artifact( + task_artifact_id, + main_module.RetrieveArtifactChunksRequest(user_id=user_id, query="alpha"), + ) + + assert response.status_code == 404 + assert json.loads(response.body) == { + "detail": f"task artifact {task_artifact_id} was not found" + } + + +def test_register_task_artifact_endpoint_maps_workspace_not_found_to_404(monkeypatch) -> None: + user_id = uuid4() + task_workspace_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + def fake_register_task_artifact_record(*_args, **_kwargs): + raise TaskWorkspaceNotFoundError(f"task workspace {task_workspace_id} was not found") + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "register_task_artifact_record", fake_register_task_artifact_record) + + response = main_module.register_task_artifact( + task_workspace_id, + main_module.RegisterTaskArtifactRequest( + user_id=user_id, + local_path="/tmp/example.txt", + ), + ) + + assert response.status_code == 404 + assert json.loads(response.body) == {"detail": f"task workspace {task_workspace_id} was not found"} + + +def test_register_task_artifact_endpoint_maps_validation_to_400(monkeypatch) -> None: + user_id = uuid4() + task_workspace_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + def fake_register_task_artifact_record(*_args, **_kwargs): + raise TaskArtifactValidationError("artifact path /tmp/escape.txt escapes workspace root /tmp/workspace") + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "register_task_artifact_record", fake_register_task_artifact_record) + + response = main_module.register_task_artifact( + task_workspace_id, + main_module.RegisterTaskArtifactRequest( + user_id=user_id, + local_path="/tmp/escape.txt", + ), + ) + + assert response.status_code == 400 + assert json.loads(response.body) == { + "detail": "artifact path /tmp/escape.txt escapes workspace root /tmp/workspace" + } + + +def test_register_task_artifact_endpoint_maps_duplicate_to_409(monkeypatch) -> None: + user_id = uuid4() + task_workspace_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + def fake_register_task_artifact_record(*_args, **_kwargs): + raise TaskArtifactAlreadyExistsError( + f"artifact docs/spec.txt is already registered for task workspace {task_workspace_id}" + ) + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "register_task_artifact_record", fake_register_task_artifact_record) + + response = main_module.register_task_artifact( + task_workspace_id, + main_module.RegisterTaskArtifactRequest( + user_id=user_id, + local_path="/tmp/docs/spec.txt", + ), + ) + + assert response.status_code == 409 + assert json.loads(response.body) == { + "detail": f"artifact docs/spec.txt is already registered for task workspace {task_workspace_id}" + } + + +def test_ingest_task_artifact_endpoint_maps_validation_to_400(monkeypatch) -> None: + user_id = uuid4() + task_artifact_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + def fake_ingest_task_artifact_record(*_args, **_kwargs): + raise TaskArtifactValidationError( + "artifact docs/spec.bin has unsupported media type application/octet-stream; " + "supported types: text/plain, text/markdown, application/pdf, " + "application/vnd.openxmlformats-officedocument.wordprocessingml.document, " + "message/rfc822" + ) + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "ingest_task_artifact_record", fake_ingest_task_artifact_record) + + response = main_module.ingest_task_artifact( + task_artifact_id, + main_module.IngestTaskArtifactRequest(user_id=user_id), + ) + + assert response.status_code == 400 + assert json.loads(response.body) == { + "detail": ( + "artifact docs/spec.bin has unsupported media type application/octet-stream; " + "supported types: text/plain, text/markdown, application/pdf, " + "application/vnd.openxmlformats-officedocument.wordprocessingml.document, " + "message/rfc822" + ) + } + + +def test_ingest_task_artifact_endpoint_maps_not_found_to_404(monkeypatch) -> None: + user_id = uuid4() + task_artifact_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + def fake_ingest_task_artifact_record(*_args, **_kwargs): + raise TaskArtifactNotFoundError(f"task artifact {task_artifact_id} was not found") + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "ingest_task_artifact_record", fake_ingest_task_artifact_record) + + response = main_module.ingest_task_artifact( + task_artifact_id, + main_module.IngestTaskArtifactRequest(user_id=user_id), + ) + + assert response.status_code == 404 + assert json.loads(response.body) == {"detail": f"task artifact {task_artifact_id} was not found"} diff --git a/tests/unit/test_calendar.py b/tests/unit/test_calendar.py new file mode 100644 index 0000000..9f5b249 --- /dev/null +++ b/tests/unit/test_calendar.py @@ -0,0 +1,809 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from pathlib import Path +from uuid import UUID, uuid4 + +import pytest + +from alicebot_api.artifacts import TaskArtifactAlreadyExistsError +from alicebot_api.calendar import ( + CALENDAR_EVENT_ARTIFACT_MEDIA_TYPE, + CalendarAccountAlreadyExistsError, + CalendarAccountNotFoundError, + CalendarCredentialInvalidError, + CalendarCredentialNotFoundError, + CalendarEventListValidationError, + CalendarEventUnsupportedError, + build_calendar_event_artifact_relative_path, + build_calendar_protected_credential_blob, + build_calendar_secret_ref, + create_calendar_account_record, + get_calendar_account_record, + ingest_calendar_event_record, + list_calendar_account_records, + list_calendar_event_records, + resolve_calendar_access_token, +) +from alicebot_api.calendar_secret_manager import ( + CALENDAR_SECRET_MANAGER_KIND_FILE_V1, + CalendarSecretManagerError, +) +from alicebot_api.contracts import CALENDAR_PROTECTED_CREDENTIAL_KIND, CALENDAR_READONLY_SCOPE +from alicebot_api.contracts import ( + CalendarAccountConnectInput, + CalendarEventIngestInput, + CalendarEventListInput, +) +from alicebot_api.workspaces import TaskWorkspaceNotFoundError + + +class CalendarStoreStub: + def __init__(self) -> None: + self.base_time = datetime(2026, 3, 19, 10, 0, tzinfo=UTC) + self.calendar_accounts: list[dict[str, object]] = [] + self.calendar_account_credentials: dict[UUID, dict[str, object]] = {} + self.task_workspaces: list[dict[str, object]] = [] + self.task_artifacts: list[dict[str, object]] = [] + self.operations: list[tuple[str, object]] = [] + + def create_calendar_account( + self, + *, + provider_account_id: str, + email_address: str, + display_name: str | None, + scope: str, + ) -> dict[str, object]: + row = { + "id": uuid4(), + "user_id": uuid4(), + "provider_account_id": provider_account_id, + "email_address": email_address, + "display_name": display_name, + "scope": scope, + "created_at": self.base_time + timedelta(minutes=len(self.calendar_accounts)), + "updated_at": self.base_time + timedelta(minutes=len(self.calendar_accounts)), + } + self.calendar_accounts.append(row) + return row + + def create_calendar_account_credential( + self, + *, + calendar_account_id: UUID, + auth_kind: str, + credential_kind: str, + secret_manager_kind: str, + secret_ref: str | None, + credential_blob: dict[str, object] | None, + ) -> dict[str, object]: + row = { + "calendar_account_id": calendar_account_id, + "user_id": next( + account["user_id"] + for account in self.calendar_accounts + if account["id"] == calendar_account_id + ), + "auth_kind": auth_kind, + "credential_kind": credential_kind, + "secret_manager_kind": secret_manager_kind, + "secret_ref": secret_ref, + "credential_blob": credential_blob, + "created_at": self.base_time + timedelta(minutes=len(self.calendar_account_credentials)), + "updated_at": self.base_time + timedelta(minutes=len(self.calendar_account_credentials)), + } + self.calendar_account_credentials[calendar_account_id] = row + self.operations.append(("create_calendar_account_credential", calendar_account_id)) + return row + + def get_calendar_account_optional(self, calendar_account_id: UUID) -> dict[str, object] | None: + return next( + (row for row in self.calendar_accounts if row["id"] == calendar_account_id), + None, + ) + + def get_calendar_account_credential_optional( + self, + calendar_account_id: UUID, + ) -> dict[str, object] | None: + self.operations.append(("get_calendar_account_credential_optional", calendar_account_id)) + return self.calendar_account_credentials.get(calendar_account_id) + + def get_calendar_account_by_provider_account_id_optional( + self, + provider_account_id: str, + ) -> dict[str, object] | None: + return next( + ( + row + for row in self.calendar_accounts + if row["provider_account_id"] == provider_account_id + ), + None, + ) + + def list_calendar_accounts(self) -> list[dict[str, object]]: + return sorted( + self.calendar_accounts, + key=lambda row: (row["created_at"], row["id"]), + ) + + def create_task_workspace(self, *, task_workspace_id: UUID, local_path: str) -> dict[str, object]: + row = { + "id": task_workspace_id, + "user_id": uuid4(), + "task_id": uuid4(), + "status": "active", + "local_path": local_path, + "created_at": self.base_time, + "updated_at": self.base_time, + } + self.task_workspaces.append(row) + return row + + def get_task_workspace_optional(self, task_workspace_id: UUID) -> dict[str, object] | None: + return next( + (row for row in self.task_workspaces if row["id"] == task_workspace_id), + None, + ) + + def lock_task_artifacts(self, task_workspace_id: UUID) -> None: + self.operations.append(("lock_task_artifacts", task_workspace_id)) + + def create_task_artifact( + self, + *, + task_workspace_id: UUID, + relative_path: str, + ) -> dict[str, object]: + row = { + "id": uuid4(), + "user_id": uuid4(), + "task_id": uuid4(), + "task_workspace_id": task_workspace_id, + "status": "registered", + "ingestion_status": "ingested", + "relative_path": relative_path, + "media_type_hint": CALENDAR_EVENT_ARTIFACT_MEDIA_TYPE, + "created_at": self.base_time, + "updated_at": self.base_time, + } + self.task_artifacts.append(row) + return row + + def get_task_artifact_by_workspace_relative_path_optional( + self, + *, + task_workspace_id: UUID, + relative_path: str, + ) -> dict[str, object] | None: + self.operations.append( + ("get_task_artifact_by_workspace_relative_path_optional", task_workspace_id) + ) + return next( + ( + row + for row in self.task_artifacts + if row["task_workspace_id"] == task_workspace_id + and row["relative_path"] == relative_path + ), + None, + ) + + +class CalendarSecretManagerStub: + def __init__(self) -> None: + self.secrets: dict[str, dict[str, object]] = {} + self.operations: list[tuple[str, str]] = [] + + @property + def kind(self) -> str: + return CALENDAR_SECRET_MANAGER_KIND_FILE_V1 + + def load_secret(self, *, secret_ref: str) -> dict[str, object]: + self.operations.append(("load_secret", secret_ref)) + try: + return dict(self.secrets[secret_ref]) + except KeyError as exc: + raise CalendarSecretManagerError(f"calendar secret {secret_ref} was not found") from exc + + def write_secret(self, *, secret_ref: str, payload: dict[str, object]) -> None: + self.operations.append(("write_secret", secret_ref)) + self.secrets[secret_ref] = dict(payload) + + def delete_secret(self, *, secret_ref: str) -> None: + self.operations.append(("delete_secret", secret_ref)) + self.secrets.pop(secret_ref, None) + + +def test_create_list_and_get_calendar_account_records_are_deterministic() -> None: + store = CalendarStoreStub() + secret_manager = CalendarSecretManagerStub() + user_id = uuid4() + + first = create_calendar_account_record( + store, + secret_manager, + user_id=user_id, + request=CalendarAccountConnectInput( + provider_account_id="acct-001", + email_address="owner@example.com", + display_name="Owner", + scope=CALENDAR_READONLY_SCOPE, + access_token="token-1", + ), + ) + second = create_calendar_account_record( + store, + secret_manager, + user_id=user_id, + request=CalendarAccountConnectInput( + provider_account_id="acct-002", + email_address="owner+2@example.com", + display_name=None, + scope=CALENDAR_READONLY_SCOPE, + access_token="token-2", + ), + ) + + assert list_calendar_account_records(store, user_id=user_id) == { + "items": [first["account"], second["account"]], + "summary": {"total_count": 2, "order": ["created_at_asc", "id_asc"]}, + } + assert get_calendar_account_record( + store, + user_id=user_id, + calendar_account_id=UUID(second["account"]["id"]), + ) == {"account": second["account"]} + + +def test_create_calendar_account_record_persists_protected_credential_and_hides_secret() -> None: + store = CalendarStoreStub() + secret_manager = CalendarSecretManagerStub() + user_id = uuid4() + + response = create_calendar_account_record( + store, + secret_manager, + user_id=user_id, + request=CalendarAccountConnectInput( + provider_account_id="acct-001", + email_address="owner@example.com", + display_name="Owner", + scope=CALENDAR_READONLY_SCOPE, + access_token="token-1", + ), + ) + + account_id = UUID(response["account"]["id"]) + assert response["account"]["provider"] == "google_calendar" + secret_ref = build_calendar_secret_ref( + user_id=store.calendar_account_credentials[account_id]["user_id"], + calendar_account_id=account_id, + ) + assert store.calendar_account_credentials[account_id]["credential_blob"] is None + assert store.calendar_account_credentials[account_id]["credential_kind"] == ( + CALENDAR_PROTECTED_CREDENTIAL_KIND + ) + assert store.calendar_account_credentials[account_id]["secret_ref"] == secret_ref + assert secret_manager.secrets[secret_ref] == { + "credential_kind": CALENDAR_PROTECTED_CREDENTIAL_KIND, + "access_token": "token-1", + } + + +def test_create_calendar_account_record_rejects_duplicate_provider_account_id() -> None: + store = CalendarStoreStub() + secret_manager = CalendarSecretManagerStub() + request = CalendarAccountConnectInput( + provider_account_id="acct-001", + email_address="owner@example.com", + display_name="Owner", + scope=CALENDAR_READONLY_SCOPE, + access_token="token-1", + ) + create_calendar_account_record(store, secret_manager, user_id=uuid4(), request=request) + + with pytest.raises( + CalendarAccountAlreadyExistsError, + match="calendar account acct-001 is already connected", + ): + create_calendar_account_record(store, secret_manager, user_id=uuid4(), request=request) + + +def test_get_calendar_account_record_raises_when_account_is_missing() -> None: + with pytest.raises(CalendarAccountNotFoundError, match="was not found"): + get_calendar_account_record( + CalendarStoreStub(), + user_id=uuid4(), + calendar_account_id=uuid4(), + ) + + +def test_list_calendar_event_records_enforces_deterministic_utc_order_and_shape(monkeypatch) -> None: + store = CalendarStoreStub() + secret_manager = CalendarSecretManagerStub() + user_id = uuid4() + account = create_calendar_account_record( + store, + secret_manager, + user_id=user_id, + request=CalendarAccountConnectInput( + provider_account_id="acct-001", + email_address="owner@example.com", + display_name="Owner", + scope=CALENDAR_READONLY_SCOPE, + access_token="token-1", + ), + )["account"] + + monkeypatch.setattr( + "alicebot_api.calendar.fetch_calendar_event_list_payload", + lambda **_kwargs: [ + { + "id": "evt-c", + "summary": "Third", + "start": {"dateTime": "2026-03-25T10:00:00+02:00"}, + "end": {"dateTime": "2026-03-25T10:30:00+02:00"}, + "status": "confirmed", + "htmlLink": "https://calendar.google.com/event?eid=evt-c", + "updated": "2026-03-24T10:00:00+00:00", + }, + { + "id": "evt-a", + "summary": "First", + "start": {"date": "2026-03-20"}, + "end": {"date": "2026-03-21"}, + "status": "tentative", + "updated": "2026-03-19T10:00:00+00:00", + }, + { + "id": "evt-b", + "summary": "Second", + "start": {"dateTime": "2026-03-25T08:30:00+00:00"}, + "end": {"dateTime": "2026-03-25T09:30:00+00:00"}, + "status": "confirmed", + "updated": "2026-03-24T11:00:00+00:00", + }, + {"summary": "Missing id should be skipped"}, + ], + ) + + response = list_calendar_event_records( + store, + secret_manager, + user_id=user_id, + request=CalendarEventListInput( + calendar_account_id=UUID(account["id"]), + limit=2, + time_min=datetime(2026, 3, 20, 0, 0, tzinfo=UTC), + time_max=datetime(2026, 3, 27, 0, 0, tzinfo=UTC), + ), + ) + + assert response["account"] == account + assert response["items"] == [ + { + "provider_event_id": "evt-a", + "status": "tentative", + "summary": "First", + "start_time": "2026-03-20", + "end_time": "2026-03-21", + "html_link": None, + "updated_at": "2026-03-19T10:00:00+00:00", + }, + { + "provider_event_id": "evt-c", + "status": "confirmed", + "summary": "Third", + "start_time": "2026-03-25T10:00:00+02:00", + "end_time": "2026-03-25T10:30:00+02:00", + "html_link": "https://calendar.google.com/event?eid=evt-c", + "updated_at": "2026-03-24T10:00:00+00:00", + }, + ] + assert response["summary"] == { + "total_count": 2, + "limit": 2, + "order": ["start_time_asc", "provider_event_id_asc"], + "time_min": "2026-03-20T00:00:00+00:00", + "time_max": "2026-03-27T00:00:00+00:00", + } + + +def test_list_calendar_event_records_enforces_hard_max_limit(monkeypatch) -> None: + store = CalendarStoreStub() + secret_manager = CalendarSecretManagerStub() + account = create_calendar_account_record( + store, + secret_manager, + user_id=uuid4(), + request=CalendarAccountConnectInput( + provider_account_id="acct-001", + email_address="owner@example.com", + display_name="Owner", + scope=CALENDAR_READONLY_SCOPE, + access_token="token-1", + ), + )["account"] + monkeypatch.setattr( + "alicebot_api.calendar.fetch_calendar_event_list_payload", + lambda **_kwargs: [ + { + "id": f"evt-{index:03d}", + "start": {"dateTime": f"2026-03-20T09:{index % 60:02d}:00+00:00"}, + } + for index in range(60) + ], + ) + + response = list_calendar_event_records( + store, + secret_manager, + user_id=uuid4(), + request=CalendarEventListInput( + calendar_account_id=UUID(account["id"]), + limit=999, + ), + ) + + assert response["summary"]["limit"] == 50 + assert response["summary"]["total_count"] == 50 + assert len(response["items"]) == 50 + + +def test_list_calendar_event_records_raises_for_not_found_account() -> None: + with pytest.raises(CalendarAccountNotFoundError, match="was not found"): + list_calendar_event_records( + CalendarStoreStub(), + CalendarSecretManagerStub(), + user_id=uuid4(), + request=CalendarEventListInput( + calendar_account_id=uuid4(), + limit=10, + ), + ) + + +def test_list_calendar_event_records_rejects_invalid_time_window() -> None: + store = CalendarStoreStub() + secret_manager = CalendarSecretManagerStub() + account = create_calendar_account_record( + store, + secret_manager, + user_id=uuid4(), + request=CalendarAccountConnectInput( + provider_account_id="acct-001", + email_address="owner@example.com", + display_name="Owner", + scope=CALENDAR_READONLY_SCOPE, + access_token="token-1", + ), + )["account"] + + with pytest.raises( + CalendarEventListValidationError, + match="calendar event time_min must be less than or equal to time_max", + ): + list_calendar_event_records( + store, + secret_manager, + user_id=uuid4(), + request=CalendarEventListInput( + calendar_account_id=UUID(account["id"]), + time_min=datetime(2026, 3, 22, tzinfo=UTC), + time_max=datetime(2026, 3, 21, tzinfo=UTC), + ), + ) + + +def test_resolve_calendar_access_token_reads_protected_credential() -> None: + store = CalendarStoreStub() + secret_manager = CalendarSecretManagerStub() + account = create_calendar_account_record( + store, + secret_manager, + user_id=uuid4(), + request=CalendarAccountConnectInput( + provider_account_id="acct-001", + email_address="owner@example.com", + display_name="Owner", + scope=CALENDAR_READONLY_SCOPE, + access_token="token-1", + ), + )["account"] + + assert resolve_calendar_access_token( + store, + secret_manager, + calendar_account_id=UUID(account["id"]), + ) == "token-1" + + +def test_resolve_calendar_access_token_rejects_missing_and_invalid_protected_credentials() -> None: + store = CalendarStoreStub() + secret_manager = CalendarSecretManagerStub() + account = create_calendar_account_record( + store, + secret_manager, + user_id=uuid4(), + request=CalendarAccountConnectInput( + provider_account_id="acct-001", + email_address="owner@example.com", + display_name="Owner", + scope=CALENDAR_READONLY_SCOPE, + access_token="token-1", + ), + )["account"] + account_id = UUID(account["id"]) + + store.calendar_account_credentials.pop(account_id) + with pytest.raises( + CalendarCredentialNotFoundError, + match=f"calendar account {account_id} is missing protected credentials", + ): + resolve_calendar_access_token(store, secret_manager, calendar_account_id=account_id) + + secret_ref = build_calendar_secret_ref( + user_id=uuid4(), + calendar_account_id=account_id, + ) + store.calendar_account_credentials[account_id] = { + "calendar_account_id": account_id, + "user_id": uuid4(), + "auth_kind": "oauth_access_token", + "credential_kind": CALENDAR_PROTECTED_CREDENTIAL_KIND, + "secret_manager_kind": CALENDAR_SECRET_MANAGER_KIND_FILE_V1, + "secret_ref": secret_ref, + "credential_blob": None, + "created_at": store.base_time, + "updated_at": store.base_time, + } + secret_manager.secrets[secret_ref] = { + "credential_kind": CALENDAR_PROTECTED_CREDENTIAL_KIND, + "access_token": "", + } + + with pytest.raises( + CalendarCredentialInvalidError, + match=f"calendar account {account_id} has invalid protected credentials", + ): + resolve_calendar_access_token(store, secret_manager, calendar_account_id=account_id) + + +def test_ingest_calendar_event_record_writes_text_artifact_and_reuses_artifact_seam( + monkeypatch, + tmp_path, +) -> None: + store = CalendarStoreStub() + secret_manager = CalendarSecretManagerStub() + user_id = uuid4() + workspace_id = uuid4() + workspace = store.create_task_workspace( + task_workspace_id=workspace_id, + local_path=str((tmp_path / "workspace").resolve()), + ) + account = create_calendar_account_record( + store, + secret_manager, + user_id=user_id, + request=CalendarAccountConnectInput( + provider_account_id="acct-001", + email_address="owner@example.com", + display_name="Owner", + scope=CALENDAR_READONLY_SCOPE, + access_token="token-1", + ), + )["account"] + + monkeypatch.setattr( + "alicebot_api.calendar.fetch_calendar_event_payload", + lambda **_kwargs: { + "id": "evt-001", + "summary": "Sprint planning", + "description": "Discuss sprint goals", + "status": "confirmed", + "start": {"dateTime": "2026-03-20T09:00:00+00:00"}, + "end": {"dateTime": "2026-03-20T09:30:00+00:00"}, + }, + ) + + def fake_register(_store, *, user_id: UUID, request): + path = Path(request.local_path) + assert "Summary: Sprint planning" in path.read_text(encoding="utf-8") + return { + "artifact": { + "id": "00000000-0000-0000-0000-000000000123", + "task_id": str(workspace["task_id"]), + "task_workspace_id": str(workspace_id), + "status": "registered", + "ingestion_status": "pending", + "relative_path": path.relative_to(Path(workspace["local_path"])).as_posix(), + "media_type_hint": CALENDAR_EVENT_ARTIFACT_MEDIA_TYPE, + "created_at": "2026-03-19T10:00:00+00:00", + "updated_at": "2026-03-19T10:00:00+00:00", + } + } + + monkeypatch.setattr("alicebot_api.calendar.register_task_artifact_record", fake_register) + monkeypatch.setattr( + "alicebot_api.calendar.ingest_task_artifact_record", + lambda _store, *, user_id, request: { + "artifact": { + "id": "00000000-0000-0000-0000-000000000123", + "task_id": str(workspace["task_id"]), + "task_workspace_id": str(workspace_id), + "status": "registered", + "ingestion_status": "ingested", + "relative_path": build_calendar_event_artifact_relative_path( + provider_account_id="acct-001", + provider_event_id="evt-001", + ), + "media_type_hint": CALENDAR_EVENT_ARTIFACT_MEDIA_TYPE, + "created_at": "2026-03-19T10:00:00+00:00", + "updated_at": "2026-03-19T10:00:01+00:00", + }, + "summary": { + "total_count": 1, + "total_characters": 32, + "media_type": CALENDAR_EVENT_ARTIFACT_MEDIA_TYPE, + "chunking_rule": "normalized_utf8_text_fixed_window_1000_chars_v1", + "order": ["sequence_no_asc", "id_asc"], + }, + }, + ) + + response = ingest_calendar_event_record( + store, + secret_manager, + user_id=user_id, + request=CalendarEventIngestInput( + calendar_account_id=UUID(account["id"]), + task_workspace_id=workspace_id, + provider_event_id="evt-001", + ), + ) + + assert response["account"] == account + assert response["event"] == { + "provider_event_id": "evt-001", + "artifact_relative_path": "calendar/acct-001/evt-001.txt", + "media_type": CALENDAR_EVENT_ARTIFACT_MEDIA_TYPE, + } + assert response["artifact"]["relative_path"] == "calendar/acct-001/evt-001.txt" + + +def test_ingest_calendar_event_record_rejects_duplicate_sanitized_path_before_fetch_or_write( + monkeypatch, + tmp_path, +) -> None: + store = CalendarStoreStub() + secret_manager = CalendarSecretManagerStub() + workspace_id = uuid4() + workspace_path = (tmp_path / "workspace").resolve() + store.create_task_workspace( + task_workspace_id=workspace_id, + local_path=str(workspace_path), + ) + account = create_calendar_account_record( + store, + secret_manager, + user_id=uuid4(), + request=CalendarAccountConnectInput( + provider_account_id="acct-001", + email_address="owner@example.com", + display_name="Owner", + scope=CALENDAR_READONLY_SCOPE, + access_token="token-1", + ), + )["account"] + relative_path = build_calendar_event_artifact_relative_path( + provider_account_id="acct-001", + provider_event_id="evt/001", + ) + store.create_task_artifact( + task_workspace_id=workspace_id, + relative_path=relative_path, + ) + + monkeypatch.setattr( + "alicebot_api.calendar.fetch_calendar_event_payload", + lambda **_kwargs: (_ for _ in ()).throw( + AssertionError("fetch_calendar_event_payload should not be called") + ), + ) + + with pytest.raises( + TaskArtifactAlreadyExistsError, + match=f"artifact {relative_path} is already registered for task workspace {workspace_id}", + ): + ingest_calendar_event_record( + store, + secret_manager, + user_id=uuid4(), + request=CalendarEventIngestInput( + calendar_account_id=UUID(account["id"]), + task_workspace_id=workspace_id, + provider_event_id="evt:001", + ), + ) + + +def test_ingest_calendar_event_record_requires_visible_workspace() -> None: + store = CalendarStoreStub() + secret_manager = CalendarSecretManagerStub() + account = create_calendar_account_record( + store, + secret_manager, + user_id=uuid4(), + request=CalendarAccountConnectInput( + provider_account_id="acct-001", + email_address="owner@example.com", + display_name="Owner", + scope=CALENDAR_READONLY_SCOPE, + access_token="token-1", + ), + )["account"] + + with pytest.raises(TaskWorkspaceNotFoundError, match="task workspace .* was not found"): + ingest_calendar_event_record( + store, + secret_manager, + user_id=uuid4(), + request=CalendarEventIngestInput( + calendar_account_id=UUID(account["id"]), + task_workspace_id=uuid4(), + provider_event_id="evt-001", + ), + ) + + +def test_ingest_calendar_event_record_rejects_unsupported_event(monkeypatch, tmp_path) -> None: + store = CalendarStoreStub() + secret_manager = CalendarSecretManagerStub() + workspace_id = uuid4() + store.create_task_workspace( + task_workspace_id=workspace_id, + local_path=str((tmp_path / "workspace").resolve()), + ) + account = create_calendar_account_record( + store, + secret_manager, + user_id=uuid4(), + request=CalendarAccountConnectInput( + provider_account_id="acct-001", + email_address="owner@example.com", + display_name="Owner", + scope=CALENDAR_READONLY_SCOPE, + access_token="token-1", + ), + )["account"] + + monkeypatch.setattr( + "alicebot_api.calendar.fetch_calendar_event_payload", + lambda **_kwargs: {"id": "evt-unsupported", "start": {"dateTime": "2026-03-20T09:00:00+00:00"}}, + ) + + with pytest.raises( + CalendarEventUnsupportedError, + match="calendar event evt-unsupported is not supported for ingestion", + ): + ingest_calendar_event_record( + store, + secret_manager, + user_id=uuid4(), + request=CalendarEventIngestInput( + calendar_account_id=UUID(account["id"]), + task_workspace_id=workspace_id, + provider_event_id="evt-unsupported", + ), + ) + + +def test_build_calendar_protected_credential_blob_rejects_empty_token() -> None: + with pytest.raises( + ValueError, + match="calendar access token must be non-empty", + ): + build_calendar_protected_credential_blob(access_token="") diff --git a/tests/unit/test_calendar_main.py b/tests/unit/test_calendar_main.py new file mode 100644 index 0000000..73635f8 --- /dev/null +++ b/tests/unit/test_calendar_main.py @@ -0,0 +1,494 @@ +from __future__ import annotations + +import json +from contextlib import contextmanager +from uuid import uuid4 + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.calendar import ( + CalendarAccountAlreadyExistsError, + CalendarAccountNotFoundError, + CalendarCredentialInvalidError, + CalendarCredentialNotFoundError, + CalendarCredentialPersistenceError, + CalendarCredentialValidationError, + CalendarEventFetchError, + CalendarEventListValidationError, + CalendarEventNotFoundError, + CalendarEventUnsupportedError, +) +from alicebot_api.workspaces import TaskWorkspaceNotFoundError + + +def _settings() -> Settings: + return Settings( + database_url="postgresql://app", + calendar_secret_manager_url="file:///tmp/test-calendar-secrets", + ) + + +def test_list_calendar_accounts_endpoint_returns_payload(monkeypatch) -> None: + user_id = uuid4() + settings = _settings() + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr( + main_module, + "list_calendar_account_records", + lambda *_args, **_kwargs: { + "items": [], + "summary": {"total_count": 0, "order": ["created_at_asc", "id_asc"]}, + }, + ) + + response = main_module.list_calendar_accounts(user_id) + + assert response.status_code == 200 + assert json.loads(response.body) == { + "items": [], + "summary": {"total_count": 0, "order": ["created_at_asc", "id_asc"]}, + } + + +def test_connect_calendar_account_endpoint_maps_duplicate_to_409(monkeypatch) -> None: + user_id = uuid4() + settings = _settings() + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr( + main_module, + "create_calendar_account_record", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + CalendarAccountAlreadyExistsError("calendar account acct-001 is already connected") + ), + ) + + response = main_module.connect_calendar_account( + main_module.ConnectCalendarAccountRequest( + user_id=user_id, + provider_account_id="acct-001", + email_address="owner@example.com", + display_name="Owner", + access_token="token-1", + ) + ) + + assert response.status_code == 409 + assert json.loads(response.body) == { + "detail": "calendar account acct-001 is already connected" + } + + +def test_connect_calendar_account_endpoint_maps_validation_and_persistence_errors(monkeypatch) -> None: + user_id = uuid4() + settings = _settings() + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + + monkeypatch.setattr( + main_module, + "create_calendar_account_record", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + CalendarCredentialValidationError("calendar access token must be non-empty") + ), + ) + response = main_module.connect_calendar_account( + main_module.ConnectCalendarAccountRequest( + user_id=user_id, + provider_account_id="acct-001", + email_address="owner@example.com", + display_name="Owner", + access_token="token-1", + ) + ) + assert response.status_code == 400 + assert json.loads(response.body) == {"detail": "calendar access token must be non-empty"} + + monkeypatch.setattr( + main_module, + "create_calendar_account_record", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + CalendarCredentialPersistenceError("calendar protected credentials could not be persisted") + ), + ) + response = main_module.connect_calendar_account( + main_module.ConnectCalendarAccountRequest( + user_id=user_id, + provider_account_id="acct-001", + email_address="owner@example.com", + display_name="Owner", + access_token="token-1", + ) + ) + assert response.status_code == 409 + assert json.loads(response.body) == { + "detail": "calendar protected credentials could not be persisted" + } + + +def test_get_calendar_account_endpoint_maps_not_found_to_404(monkeypatch) -> None: + user_id = uuid4() + calendar_account_id = uuid4() + settings = _settings() + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr( + main_module, + "get_calendar_account_record", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + CalendarAccountNotFoundError(f"calendar account {calendar_account_id} was not found") + ), + ) + + response = main_module.get_calendar_account(calendar_account_id, user_id) + + assert response.status_code == 404 + assert json.loads(response.body) == { + "detail": f"calendar account {calendar_account_id} was not found" + } + + +def test_list_calendar_events_endpoint_returns_payload(monkeypatch) -> None: + user_id = uuid4() + calendar_account_id = uuid4() + settings = _settings() + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr( + main_module, + "list_calendar_event_records", + lambda *_args, **_kwargs: { + "account": { + "id": str(calendar_account_id), + "provider": "google_calendar", + "auth_kind": "oauth_access_token", + "provider_account_id": "acct-001", + "email_address": "owner@example.com", + "display_name": "Owner", + "scope": "https://www.googleapis.com/auth/calendar.readonly", + "created_at": "2026-03-19T10:00:00+00:00", + "updated_at": "2026-03-19T10:00:00+00:00", + }, + "items": [ + { + "provider_event_id": "evt-001", + "status": "confirmed", + "summary": "Sprint Planning", + "start_time": "2026-03-20T09:00:00+00:00", + "end_time": "2026-03-20T09:30:00+00:00", + "html_link": "https://calendar.google.com/event?eid=evt-001", + "updated_at": "2026-03-19T10:00:00+00:00", + } + ], + "summary": { + "total_count": 1, + "limit": 20, + "order": ["start_time_asc", "provider_event_id_asc"], + "time_min": None, + "time_max": None, + }, + }, + ) + + response = main_module.list_calendar_events(calendar_account_id, user_id) + + assert response.status_code == 200 + assert json.loads(response.body) == { + "account": { + "id": str(calendar_account_id), + "provider": "google_calendar", + "auth_kind": "oauth_access_token", + "provider_account_id": "acct-001", + "email_address": "owner@example.com", + "display_name": "Owner", + "scope": "https://www.googleapis.com/auth/calendar.readonly", + "created_at": "2026-03-19T10:00:00+00:00", + "updated_at": "2026-03-19T10:00:00+00:00", + }, + "items": [ + { + "provider_event_id": "evt-001", + "status": "confirmed", + "summary": "Sprint Planning", + "start_time": "2026-03-20T09:00:00+00:00", + "end_time": "2026-03-20T09:30:00+00:00", + "html_link": "https://calendar.google.com/event?eid=evt-001", + "updated_at": "2026-03-19T10:00:00+00:00", + } + ], + "summary": { + "total_count": 1, + "limit": 20, + "order": ["start_time_asc", "provider_event_id_asc"], + "time_min": None, + "time_max": None, + }, + } + + +def test_list_calendar_events_endpoint_maps_errors(monkeypatch) -> None: + user_id = uuid4() + calendar_account_id = uuid4() + settings = _settings() + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + + monkeypatch.setattr( + main_module, + "list_calendar_event_records", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + CalendarAccountNotFoundError(f"calendar account {calendar_account_id} was not found") + ), + ) + response = main_module.list_calendar_events(calendar_account_id, user_id) + assert response.status_code == 404 + assert json.loads(response.body) == { + "detail": f"calendar account {calendar_account_id} was not found" + } + + monkeypatch.setattr( + main_module, + "list_calendar_event_records", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + CalendarCredentialNotFoundError( + f"calendar account {calendar_account_id} is missing protected credentials" + ) + ), + ) + response = main_module.list_calendar_events(calendar_account_id, user_id) + assert response.status_code == 409 + assert json.loads(response.body) == { + "detail": f"calendar account {calendar_account_id} is missing protected credentials" + } + + monkeypatch.setattr( + main_module, + "list_calendar_event_records", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + CalendarEventListValidationError( + "calendar event time_min must be less than or equal to time_max" + ) + ), + ) + response = main_module.list_calendar_events(calendar_account_id, user_id) + assert response.status_code == 400 + assert json.loads(response.body) == { + "detail": "calendar event time_min must be less than or equal to time_max" + } + + monkeypatch.setattr( + main_module, + "list_calendar_event_records", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + CalendarEventFetchError("calendar events could not be fetched") + ), + ) + response = main_module.list_calendar_events(calendar_account_id, user_id) + assert response.status_code == 502 + assert json.loads(response.body) == { + "detail": "calendar events could not be fetched" + } + + +def test_ingest_calendar_event_endpoint_maps_workspace_not_found_to_404(monkeypatch) -> None: + user_id = uuid4() + calendar_account_id = uuid4() + task_workspace_id = uuid4() + settings = _settings() + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr( + main_module, + "ingest_calendar_event_record", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + TaskWorkspaceNotFoundError(f"task workspace {task_workspace_id} was not found") + ), + ) + + response = main_module.ingest_calendar_event( + calendar_account_id, + "evt-001", + main_module.IngestCalendarEventRequest( + user_id=user_id, + task_workspace_id=task_workspace_id, + ), + ) + + assert response.status_code == 404 + assert json.loads(response.body) == { + "detail": f"task workspace {task_workspace_id} was not found" + } + + +def test_ingest_calendar_event_endpoint_maps_upstream_errors(monkeypatch) -> None: + user_id = uuid4() + calendar_account_id = uuid4() + task_workspace_id = uuid4() + settings = _settings() + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + + monkeypatch.setattr( + main_module, + "ingest_calendar_event_record", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + CalendarEventNotFoundError("calendar event evt-missing was not found") + ), + ) + response = main_module.ingest_calendar_event( + calendar_account_id, + "evt-missing", + main_module.IngestCalendarEventRequest( + user_id=user_id, + task_workspace_id=task_workspace_id, + ), + ) + assert response.status_code == 404 + assert json.loads(response.body) == {"detail": "calendar event evt-missing was not found"} + + monkeypatch.setattr( + main_module, + "ingest_calendar_event_record", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + CalendarEventUnsupportedError("calendar event evt-unsupported is not supported for ingestion") + ), + ) + response = main_module.ingest_calendar_event( + calendar_account_id, + "evt-unsupported", + main_module.IngestCalendarEventRequest( + user_id=user_id, + task_workspace_id=task_workspace_id, + ), + ) + assert response.status_code == 400 + assert json.loads(response.body) == { + "detail": "calendar event evt-unsupported is not supported for ingestion" + } + + monkeypatch.setattr( + main_module, + "ingest_calendar_event_record", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + CalendarCredentialNotFoundError( + f"calendar account {calendar_account_id} is missing protected credentials" + ) + ), + ) + response = main_module.ingest_calendar_event( + calendar_account_id, + "evt-001", + main_module.IngestCalendarEventRequest( + user_id=user_id, + task_workspace_id=task_workspace_id, + ), + ) + assert response.status_code == 409 + assert json.loads(response.body) == { + "detail": f"calendar account {calendar_account_id} is missing protected credentials" + } + + monkeypatch.setattr( + main_module, + "ingest_calendar_event_record", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + CalendarCredentialInvalidError( + f"calendar account {calendar_account_id} has invalid protected credentials" + ) + ), + ) + response = main_module.ingest_calendar_event( + calendar_account_id, + "evt-001", + main_module.IngestCalendarEventRequest( + user_id=user_id, + task_workspace_id=task_workspace_id, + ), + ) + assert response.status_code == 409 + assert json.loads(response.body) == { + "detail": f"calendar account {calendar_account_id} has invalid protected credentials" + } + + monkeypatch.setattr( + main_module, + "ingest_calendar_event_record", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + CalendarCredentialPersistenceError( + f"calendar account {calendar_account_id} protected credentials could not be persisted" + ) + ), + ) + response = main_module.ingest_calendar_event( + calendar_account_id, + "evt-001", + main_module.IngestCalendarEventRequest( + user_id=user_id, + task_workspace_id=task_workspace_id, + ), + ) + assert response.status_code == 409 + assert json.loads(response.body) == { + "detail": f"calendar account {calendar_account_id} protected credentials could not be persisted" + } + + monkeypatch.setattr( + main_module, + "ingest_calendar_event_record", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + CalendarEventFetchError("calendar event evt-001 could not be fetched") + ), + ) + response = main_module.ingest_calendar_event( + calendar_account_id, + "evt-001", + main_module.IngestCalendarEventRequest( + user_id=user_id, + task_workspace_id=task_workspace_id, + ), + ) + assert response.status_code == 502 + assert json.loads(response.body) == { + "detail": "calendar event evt-001 could not be fetched" + } diff --git a/tests/unit/test_calendar_secret_manager.py b/tests/unit/test_calendar_secret_manager.py new file mode 100644 index 0000000..39217b9 --- /dev/null +++ b/tests/unit/test_calendar_secret_manager.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import stat + +import pytest + +from alicebot_api.calendar_secret_manager import ( + CalendarSecretManagerError, + build_calendar_secret_manager, +) + + +def test_build_calendar_secret_manager_rejects_non_file_schemes() -> None: + with pytest.raises(ValueError, match="CALENDAR_SECRET_MANAGER_URL must use the file:// scheme"): + build_calendar_secret_manager("memory://calendar-secrets") + + +def test_build_calendar_secret_manager_requires_explicit_configuration() -> None: + with pytest.raises(ValueError, match="CALENDAR_SECRET_MANAGER_URL must be configured"): + build_calendar_secret_manager("") + + +def test_file_calendar_secret_manager_round_trips_secret_payload(tmp_path) -> None: + manager = build_calendar_secret_manager(tmp_path.resolve().as_uri()) + secret_ref = "users/00000000-0000-0000-0000-000000000001/calendar-account-credentials/cred.json" + payload = { + "credential_kind": "calendar_oauth_access_token_v1", + "access_token": "token-001", + } + + manager.write_secret(secret_ref=secret_ref, payload=payload) + + assert manager.load_secret(secret_ref=secret_ref) == payload + secret_path = tmp_path / secret_ref + assert stat.S_IMODE(secret_path.stat().st_mode) == 0o600 + assert stat.S_IMODE(secret_path.parent.stat().st_mode) == 0o700 + + +def test_file_calendar_secret_manager_rejects_missing_or_escaped_refs(tmp_path) -> None: + manager = build_calendar_secret_manager(tmp_path.resolve().as_uri()) + + with pytest.raises(CalendarSecretManagerError, match="was not found"): + manager.load_secret(secret_ref="users/u/calendar-account-credentials/missing.json") + + with pytest.raises(CalendarSecretManagerError, match="outside the configured root"): + manager.write_secret( + secret_ref="../../escape.json", + payload={"credential_kind": "calendar_oauth_access_token_v1", "access_token": "token"}, + ) diff --git a/tests/unit/test_chief_of_staff.py b/tests/unit/test_chief_of_staff.py new file mode 100644 index 0000000..6f60c77 --- /dev/null +++ b/tests/unit/test_chief_of_staff.py @@ -0,0 +1,1706 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from uuid import UUID + +import alicebot_api.chief_of_staff as chief +from alicebot_api.contracts import ( + ChiefOfStaffHandoffOutcomeCaptureInput, + ChiefOfStaffHandoffReviewActionInput, + ChiefOfStaffPriorityBriefRequestInput, + ChiefOfStaffRecommendationOutcomeCaptureInput, +) + + +def _recall_item( + *, + item_id: str, + object_type: str, + status: str, + title: str, + created_at: str, + confidence: float = 0.9, + confirmation_status: str = "confirmed", +) -> dict[str, object]: + return { + "id": item_id, + "capture_event_id": f"capture-{item_id}", + "object_type": object_type, + "status": status, + "title": title, + "body": {"text": title}, + "provenance": {"thread_id": "thread-1", "source_event_ids": [f"event-{item_id}"]}, + "confirmation_status": confirmation_status, + "admission_posture": "DERIVED", + "confidence": confidence, + "relevance": 120.0, + "last_confirmed_at": None, + "supersedes_object_id": None, + "superseded_by_object_id": None, + "scope_matches": [{"kind": "thread", "value": "thread-1"}], + "provenance_references": [{"source_kind": "continuity_capture_event", "source_id": f"capture-{item_id}"}], + "ordering": { + "scope_match_count": 1, + "query_term_match_count": 1, + "confirmation_rank": 3, + "freshness_posture": "fresh", + "freshness_rank": 4, + "provenance_posture": "strong", + "provenance_rank": 3, + "supersession_posture": "current", + "supersession_rank": 3, + "posture_rank": 2, + "lifecycle_rank": 4, + "confidence": confidence, + }, + "created_at": created_at, + "updated_at": created_at, + } + + +def _outcome_recall_item( + *, + item_id: str, + created_at: str, + outcome: str, + recommendation_action_type: str, + recommendation_title: str, + target_priority_id: str | None = None, + rationale: str | None = None, +) -> dict[str, object]: + item = _recall_item( + item_id=item_id, + object_type="Note", + status="active", + title=f"Recommendation outcome: {outcome}", + created_at=created_at, + confidence=1.0, + confirmation_status="confirmed", + ) + item["body"] = { + "kind": "chief_of_staff_recommendation_outcome", + "outcome": outcome, + "recommendation_action_type": recommendation_action_type, + "recommendation_title": recommendation_title, + "target_priority_id": target_priority_id, + "rationale": rationale, + "rewritten_title": None, + } + return item + + +def _handoff_outcome_recall_item( + *, + item_id: str, + handoff_item_id: str, + created_at: str, + outcome_status: str, + previous_outcome_status: str | None = None, +) -> dict[str, object]: + item = _recall_item( + item_id=item_id, + object_type="Note", + status="active", + title=f"Handoff outcome: {outcome_status} ({handoff_item_id})", + created_at=created_at, + confidence=1.0, + confirmation_status="confirmed", + ) + item["body"] = { + "kind": "chief_of_staff_handoff_outcome", + "handoff_item_id": handoff_item_id, + "outcome_status": outcome_status, + "previous_outcome_status": previous_outcome_status, + "reason": f"Outcome {outcome_status} captured for {handoff_item_id}", + "note": None, + } + return item + + +def test_priority_brief_is_deterministic_and_provenance_backed(monkeypatch) -> None: + recall_items = [ + _recall_item( + item_id="next-1", + object_type="NextAction", + status="active", + title="Next Action: Send launch update", + created_at="2026-03-31T10:05:00+00:00", + confidence=0.98, + ), + _recall_item( + item_id="commitment-1", + object_type="Commitment", + status="active", + title="Commitment: Close sprint report", + created_at="2026-03-28T09:50:00+00:00", + confidence=0.9, + ), + _recall_item( + item_id="waiting-1", + object_type="WaitingFor", + status="active", + title="Waiting For: Vendor quote", + created_at="2026-03-27T09:00:00+00:00", + confidence=0.9, + confirmation_status="unconfirmed", + ), + _recall_item( + item_id="blocker-1", + object_type="Blocker", + status="active", + title="Blocker: Missing API key", + created_at="2026-03-23T08:30:00+00:00", + confidence=0.95, + confirmation_status="unconfirmed", + ), + _outcome_recall_item( + item_id="outcome-accept-1", + created_at="2026-03-31T11:00:00+00:00", + outcome="accept", + recommendation_action_type="execute_next_action", + recommendation_title="Next Action: Send launch update", + target_priority_id="next-1", + rationale="Accepted and executed directly.", + ), + _outcome_recall_item( + item_id="outcome-ignore-1", + created_at="2026-03-31T10:30:00+00:00", + outcome="ignore", + recommendation_action_type="follow_up_waiting_for", + recommendation_title="Waiting For: Vendor quote", + target_priority_id="waiting-1", + rationale="Deferred by operator due to dependency risk.", + ), + ] + + def fake_recall(*args, **kwargs): + return { + "items": recall_items, + "summary": { + "query": None, + "filters": {"thread_id": "thread-1", "since": None, "until": None}, + "limit": 100, + "returned_count": len(recall_items), + "total_count": len(recall_items), + "order": ["relevance_desc", "created_at_desc", "id_desc"], + }, + } + + def fake_open_loops(*args, **kwargs): + return { + "dashboard": { + "scope": {"thread_id": "thread-1", "since": None, "until": None}, + "waiting_for": { + "items": [recall_items[2]], + "summary": {"limit": 20, "returned_count": 1, "total_count": 1, "order": ["created_at_desc", "id_desc"]}, + "empty_state": {"is_empty": False, "message": "none"}, + }, + "blocker": { + "items": [recall_items[3]], + "summary": {"limit": 20, "returned_count": 1, "total_count": 1, "order": ["created_at_desc", "id_desc"]}, + "empty_state": {"is_empty": False, "message": "none"}, + }, + "stale": { + "items": [], + "summary": {"limit": 20, "returned_count": 0, "total_count": 0, "order": ["created_at_desc", "id_desc"]}, + "empty_state": {"is_empty": True, "message": "none"}, + }, + "next_action": { + "items": [recall_items[0]], + "summary": {"limit": 20, "returned_count": 1, "total_count": 1, "order": ["created_at_desc", "id_desc"]}, + "empty_state": {"is_empty": False, "message": "none"}, + }, + "summary": { + "limit": 20, + "total_count": 3, + "posture_order": ["waiting_for", "blocker", "stale", "next_action"], + "item_order": ["created_at_desc", "id_desc"], + }, + "sources": ["continuity_capture_events", "continuity_objects"], + } + } + + def fake_resumption(*args, **kwargs): + return { + "brief": { + "assembly_version": "continuity_resumption_brief_v0", + "scope": {"thread_id": "thread-1", "since": None, "until": None}, + "last_decision": {"item": None, "empty_state": {"is_empty": True, "message": "none"}}, + "open_loops": { + "items": [recall_items[2], recall_items[3]], + "summary": {"limit": 20, "returned_count": 2, "total_count": 2, "order": ["created_at_desc", "id_desc"]}, + "empty_state": {"is_empty": False, "message": "none"}, + }, + "recent_changes": { + "items": [recall_items[0], recall_items[1], recall_items[2], recall_items[3]], + "summary": {"limit": 20, "returned_count": 4, "total_count": 4, "order": ["created_at_desc", "id_desc"]}, + "empty_state": {"is_empty": False, "message": "none"}, + }, + "next_action": {"item": recall_items[0], "empty_state": {"is_empty": False, "message": "none"}}, + "sources": ["continuity_capture_events", "continuity_objects"], + } + } + + def fake_trust(*args, **kwargs): + return { + "dashboard": { + "quality_gate": {"status": "healthy"}, + "retrieval_quality": {"status": "pass"}, + } + } + + def fake_weekly_review(*args, **kwargs): + return { + "review": { + "assembly_version": "continuity_weekly_review_v0", + "scope": {"thread_id": "thread-1", "since": None, "until": None}, + "rollup": { + "total_count": 3, + "waiting_for_count": 1, + "blocker_count": 1, + "stale_count": 0, + "correction_recurrence_count": 0, + "freshness_drift_count": 0, + "next_action_count": 1, + "posture_order": ["waiting_for", "blocker", "stale", "next_action"], + }, + "waiting_for": {"items": [], "summary": {"limit": 5, "returned_count": 0, "total_count": 0, "order": []}, "empty_state": {"is_empty": True, "message": "none"}}, + "blocker": {"items": [], "summary": {"limit": 5, "returned_count": 0, "total_count": 0, "order": []}, "empty_state": {"is_empty": True, "message": "none"}}, + "stale": {"items": [], "summary": {"limit": 5, "returned_count": 0, "total_count": 0, "order": []}, "empty_state": {"is_empty": True, "message": "none"}}, + "next_action": {"items": [], "summary": {"limit": 5, "returned_count": 0, "total_count": 0, "order": []}, "empty_state": {"is_empty": True, "message": "none"}}, + "sources": ["continuity_capture_events", "continuity_objects"], + } + } + + monkeypatch.setattr(chief, "query_continuity_recall", fake_recall) + monkeypatch.setattr(chief, "compile_continuity_open_loop_dashboard", fake_open_loops) + monkeypatch.setattr(chief, "compile_continuity_weekly_review", fake_weekly_review) + monkeypatch.setattr(chief, "compile_continuity_resumption_brief", fake_resumption) + monkeypatch.setattr(chief, "get_memory_trust_dashboard_summary", fake_trust) + + request = ChiefOfStaffPriorityBriefRequestInput(thread_id=UUID("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"), limit=4) + + first = chief.compile_chief_of_staff_priority_brief( + object(), # type: ignore[arg-type] + user_id=UUID("11111111-1111-4111-8111-111111111111"), + request=request, + ) + second = chief.compile_chief_of_staff_priority_brief( + object(), # type: ignore[arg-type] + user_id=UUID("11111111-1111-4111-8111-111111111111"), + request=request, + ) + + assert first == second + assert first["brief"]["ranked_items"][0]["id"] == "next-1" + assert first["brief"]["ranked_items"][0]["priority_posture"] == "urgent" + assert first["brief"]["recommended_next_action"]["action_type"] == "execute_next_action" + assert first["brief"]["recommended_next_action"]["target_priority_id"] == "next-1" + assert first["brief"]["summary"]["trust_confidence_posture"] == "high" + assert first["brief"]["summary"]["follow_through_posture_order"] == [ + "overdue", + "stale_waiting_for", + "slipped_commitment", + ] + assert first["brief"]["summary"]["follow_through_item_order"] == [ + "recommendation_action_desc", + "age_hours_desc", + "created_at_desc", + "id_desc", + ] + assert first["brief"]["summary"]["follow_through_total_count"] == 3 + assert first["brief"]["summary"]["overdue_count"] == 1 + assert first["brief"]["summary"]["stale_waiting_for_count"] == 1 + assert first["brief"]["summary"]["slipped_commitment_count"] == 1 + assert first["brief"]["overdue_items"][0]["id"] == "blocker-1" + assert first["brief"]["overdue_items"][0]["recommendation_action"] == "escalate" + assert first["brief"]["stale_waiting_for_items"][0]["id"] == "waiting-1" + assert first["brief"]["slipped_commitments"][0]["id"] == "commitment-1" + assert first["brief"]["escalation_posture"]["posture"] == "critical" + assert first["brief"]["draft_follow_up"]["status"] == "drafted" + assert first["brief"]["draft_follow_up"]["mode"] == "draft_only" + assert first["brief"]["draft_follow_up"]["approval_required"] is True + assert first["brief"]["draft_follow_up"]["auto_send"] is False + assert first["brief"]["draft_follow_up"]["target_metadata"]["continuity_object_id"] == "blocker-1" + assert "artifact-only" in first["brief"]["draft_follow_up"]["content"]["body"] + assert first["brief"]["ranked_items"][0]["rationale"]["provenance_references"] + assert first["brief"]["ranked_items"][0]["rationale"]["reasons"] + assert first["brief"]["preparation_brief"]["summary"]["order"] == [ + "rank_asc", + "created_at_desc", + "id_desc", + ] + assert first["brief"]["what_changed_summary"]["summary"]["order"] == [ + "rank_asc", + "created_at_desc", + "id_desc", + ] + assert first["brief"]["prep_checklist"]["summary"]["order"] == [ + "rank_asc", + "created_at_desc", + "id_desc", + ] + assert first["brief"]["suggested_talking_points"]["summary"]["order"] == [ + "rank_asc", + "created_at_desc", + "id_desc", + ] + assert first["brief"]["resumption_supervision"]["summary"]["order"] == [ + "rank_asc", + ] + assert first["brief"]["preparation_brief"]["confidence_posture"] == "high" + assert first["brief"]["what_changed_summary"]["confidence_posture"] == "high" + assert first["brief"]["prep_checklist"]["confidence_posture"] == "high" + assert first["brief"]["suggested_talking_points"]["confidence_posture"] == "high" + assert first["brief"]["resumption_supervision"]["confidence_posture"] == "high" + assert first["brief"]["preparation_brief"]["context_items"] + assert first["brief"]["what_changed_summary"]["items"] + assert first["brief"]["prep_checklist"]["items"] + assert first["brief"]["suggested_talking_points"]["items"] + assert first["brief"]["resumption_supervision"]["recommendations"] + assert first["brief"]["weekly_review_brief"]["summary"]["guidance_order"] == ["close", "defer", "escalate"] + assert first["brief"]["recommendation_outcomes"]["summary"]["total_count"] == 2 + assert first["brief"]["recommendation_outcomes"]["summary"]["outcome_counts"]["accept"] == 1 + assert first["brief"]["recommendation_outcomes"]["summary"]["outcome_counts"]["ignore"] == 1 + assert first["brief"]["priority_learning_summary"]["acceptance_rate"] == 0.5 + assert first["brief"]["priority_learning_summary"]["override_rate"] == 0.5 + assert "Prioritization is reinforcing" in first["brief"]["priority_learning_summary"]["priority_shift_explanation"] + assert first["brief"]["pattern_drift_summary"]["posture"] == "stable" + assert first["brief"]["action_handoff_brief"]["order"] == [ + "score_desc", + "source_order_asc", + "source_reference_id_asc", + ] + assert first["brief"]["action_handoff_brief"]["source_order"] == [ + "recommended_next_action", + "follow_through", + "prep_checklist", + "weekly_review", + ] + assert first["brief"]["summary"]["handoff_item_count"] == len(first["brief"]["handoff_items"]) + assert first["brief"]["summary"]["handoff_item_order"] == [ + "score_desc", + "source_order_asc", + "source_reference_id_asc", + ] + assert first["brief"]["handoff_queue_summary"]["state_order"] == [ + "ready", + "pending_approval", + "executed", + "stale", + "expired", + ] + assert first["brief"]["handoff_queue_summary"]["item_order"] == [ + "queue_rank_asc", + "handoff_rank_asc", + "score_desc", + "handoff_item_id_asc", + ] + assert first["brief"]["handoff_queue_summary"]["total_count"] == len(first["brief"]["handoff_items"]) + assert first["brief"]["summary"]["handoff_queue_total_count"] == len(first["brief"]["handoff_items"]) + assert first["brief"]["summary"]["handoff_queue_state_order"] == [ + "ready", + "pending_approval", + "executed", + "stale", + "expired", + ] + assert first["brief"]["summary"]["handoff_queue_item_order"] == [ + "queue_rank_asc", + "handoff_rank_asc", + "score_desc", + "handoff_item_id_asc", + ] + assert first["brief"]["handoff_queue_groups"]["ready"]["summary"]["order"] == [ + "queue_rank_asc", + "handoff_rank_asc", + "score_desc", + "handoff_item_id_asc", + ] + assert first["brief"]["handoff_review_actions"] == [] + assert first["brief"]["execution_routing_summary"]["total_handoff_count"] == len(first["brief"]["handoff_items"]) + assert first["brief"]["execution_routing_summary"]["routed_handoff_count"] == 0 + assert first["brief"]["execution_routing_summary"]["unrouted_handoff_count"] == len( + first["brief"]["handoff_items"] + ) + assert first["brief"]["execution_routing_summary"]["route_target_order"] == [ + "task_workflow_draft", + "approval_workflow_draft", + "follow_up_draft_only", + ] + assert first["brief"]["execution_routing_summary"]["routed_item_order"] == [ + "handoff_rank_asc", + "handoff_item_id_asc", + ] + assert first["brief"]["execution_routing_summary"]["audit_order"] == [ + "created_at_desc", + "id_desc", + ] + assert first["brief"]["routed_handoff_items"] + assert first["brief"]["routed_handoff_items"][0]["routed_targets"] == [] + assert first["brief"]["routing_audit_trail"] == [] + assert first["brief"]["execution_readiness_posture"]["posture"] == "approval_required_draft_only" + assert first["brief"]["execution_readiness_posture"]["approval_required"] is True + assert first["brief"]["execution_readiness_posture"]["autonomous_execution"] is False + assert first["brief"]["execution_readiness_posture"]["external_side_effects_allowed"] is False + assert first["brief"]["execution_readiness_posture"]["approval_path_visible"] is True + assert first["brief"]["execution_readiness_posture"]["transition_order"] == [ + "routed", + "reaffirmed", + ] + assert first["brief"]["summary"]["execution_posture_order"] == ["approval_bounded_artifact_only"] + assert [item["source_kind"] for item in first["brief"]["handoff_items"]] == [ + "recommended_next_action", + "follow_through", + "prep_checklist", + "weekly_review", + ] + top_handoff_item = first["brief"]["handoff_items"][0] + assert first["brief"]["task_draft"]["source_handoff_item_id"] == top_handoff_item["handoff_item_id"] + assert first["brief"]["approval_draft"]["source_handoff_item_id"] == top_handoff_item["handoff_item_id"] + assert first["brief"]["task_draft"]["mode"] == "governed_request_draft" + assert first["brief"]["task_draft"]["approval_required"] is True + assert first["brief"]["task_draft"]["auto_execute"] is False + assert first["brief"]["approval_draft"]["mode"] == "approval_request_draft" + assert first["brief"]["approval_draft"]["decision"] == "approval_required" + assert first["brief"]["approval_draft"]["approval_required"] is True + assert first["brief"]["approval_draft"]["auto_submit"] is False + assert first["brief"]["execution_posture"]["posture"] == "approval_bounded_artifact_only" + assert first["brief"]["execution_posture"]["approval_required"] is True + assert first["brief"]["execution_posture"]["autonomous_execution"] is False + assert first["brief"]["execution_posture"]["external_side_effects_allowed"] is False + assert first["brief"]["execution_posture"]["default_routing_decision"] == "approval_required" + assert "No task, approval, connector send, or external side effect is executed" in first["brief"][ + "execution_posture" + ]["non_autonomous_guarantee"] + + +def test_follow_through_item_ranking_is_deterministic_for_ties() -> None: + def _follow_item( + *, + item_id: str, + recommendation_action: str, + age_hours: float, + created_at: str, + ) -> dict[str, object]: + return { + "rank": 0, + "id": item_id, + "capture_event_id": f"capture-{item_id}", + "object_type": "NextAction", + "status": "active", + "title": f"Next Action: {item_id}", + "current_priority_posture": "urgent", + "follow_through_posture": "overdue", + "recommendation_action": recommendation_action, + "reason": "deterministic test fixture", + "age_hours": age_hours, + "provenance_references": [], + "created_at": created_at, + "updated_at": created_at, + } + + ranked = chief._rank_follow_through_items( # type: ignore[attr-defined] + [ + _follow_item( + item_id="id-a", + recommendation_action="nudge", + age_hours=72.0, + created_at="2026-03-30T10:00:00+00:00", + ), + _follow_item( + item_id="id-b", + recommendation_action="nudge", + age_hours=72.0, + created_at="2026-03-30T10:00:00+00:00", + ), + _follow_item( + item_id="id-c", + recommendation_action="nudge", + age_hours=72.0, + created_at="2026-03-31T10:00:00+00:00", + ), + _follow_item( + item_id="id-d", + recommendation_action="escalate", + age_hours=60.0, + created_at="2026-03-29T10:00:00+00:00", + ), + ], + limit=10, + ) + + assert [item["id"] for item in ranked] == ["id-d", "id-c", "id-b", "id-a"] + assert [item["rank"] for item in ranked] == [1, 2, 3, 4] + + +def test_priority_brief_downgrades_confidence_when_trust_is_weak(monkeypatch) -> None: + recall_item = _recall_item( + item_id="next-1", + object_type="NextAction", + status="active", + title="Next Action: Ship priority brief", + created_at="2026-03-31T10:05:00+00:00", + confidence=0.99, + confirmation_status="confirmed", + ) + + monkeypatch.setattr( + chief, + "query_continuity_recall", + lambda *args, **kwargs: { + "items": [recall_item], + "summary": { + "query": None, + "filters": {"since": None, "until": None}, + "limit": 100, + "returned_count": 1, + "total_count": 1, + "order": ["relevance_desc", "created_at_desc", "id_desc"], + }, + }, + ) + monkeypatch.setattr( + chief, + "compile_continuity_open_loop_dashboard", + lambda *args, **kwargs: { + "dashboard": { + "waiting_for": {"items": []}, + "blocker": {"items": []}, + "stale": {"items": []}, + "next_action": {"items": [recall_item]}, + } + }, + ) + monkeypatch.setattr( + chief, + "compile_continuity_resumption_brief", + lambda *args, **kwargs: { + "brief": { + "recent_changes": {"items": [recall_item]}, + "next_action": {"item": recall_item}, + } + }, + ) + monkeypatch.setattr( + chief, + "compile_continuity_weekly_review", + lambda *args, **kwargs: { + "review": { + "scope": {"thread_id": None, "since": None, "until": None}, + "rollup": { + "total_count": 0, + "waiting_for_count": 0, + "blocker_count": 0, + "stale_count": 0, + "correction_recurrence_count": 0, + "freshness_drift_count": 0, + "next_action_count": 0, + "posture_order": ["waiting_for", "blocker", "stale", "next_action"], + }, + } + }, + ) + monkeypatch.setattr( + chief, + "get_memory_trust_dashboard_summary", + lambda *args, **kwargs: { + "dashboard": { + "quality_gate": {"status": "degraded"}, + "retrieval_quality": {"status": "pass"}, + } + }, + ) + + payload = chief.compile_chief_of_staff_priority_brief( + object(), # type: ignore[arg-type] + user_id=UUID("11111111-1111-4111-8111-111111111111"), + request=ChiefOfStaffPriorityBriefRequestInput(limit=1), + ) + + ranked = payload["brief"]["ranked_items"][0] + assert payload["brief"]["summary"]["trust_confidence_posture"] == "low" + assert ranked["confidence_posture"] == "low" + assert ranked["rationale"]["trust_signals"]["downgraded_by_trust"] is True + assert payload["brief"]["recommended_next_action"]["confidence_posture"] == "low" + assert payload["brief"]["escalation_posture"]["posture"] == "watch" + assert payload["brief"]["draft_follow_up"]["status"] == "none" + assert payload["brief"]["preparation_brief"]["confidence_posture"] == "low" + assert payload["brief"]["what_changed_summary"]["confidence_posture"] == "low" + assert payload["brief"]["prep_checklist"]["confidence_posture"] == "low" + assert payload["brief"]["suggested_talking_points"]["confidence_posture"] == "low" + assert payload["brief"]["resumption_supervision"]["confidence_posture"] == "low" + assert payload["brief"]["resumption_supervision"]["recommendations"][0]["action"] in { + "execute_next_action", + "capture_new_priority", + } + assert any( + recommendation["action"] == "review_scope" and recommendation["provenance_references"] + for recommendation in payload["brief"]["resumption_supervision"]["recommendations"] + ) + assert payload["brief"]["action_handoff_brief"]["confidence_posture"] == "low" + assert payload["brief"]["handoff_items"] + assert payload["brief"]["task_draft"]["approval_required"] is True + assert payload["brief"]["task_draft"]["auto_execute"] is False + assert payload["brief"]["approval_draft"]["decision"] == "approval_required" + assert payload["brief"]["approval_draft"]["auto_submit"] is False + assert payload["brief"]["execution_posture"]["autonomous_execution"] is False + assert payload["brief"]["execution_posture"]["external_side_effects_allowed"] is False + assert payload["brief"]["execution_routing_summary"]["routed_handoff_count"] == 0 + assert payload["brief"]["routing_audit_trail"] == [] + assert payload["brief"]["execution_readiness_posture"]["approval_required"] is True + + +def test_priority_brief_retrieval_failure_respects_non_healthy_quality_caps(monkeypatch) -> None: + recall_item = _recall_item( + item_id="next-1", + object_type="NextAction", + status="active", + title="Next Action: Validate confidence caps", + created_at="2026-03-31T10:05:00+00:00", + confidence=0.99, + confirmation_status="confirmed", + ) + + monkeypatch.setattr( + chief, + "query_continuity_recall", + lambda *args, **kwargs: { + "items": [recall_item], + "summary": { + "query": None, + "filters": {"since": None, "until": None}, + "limit": 100, + "returned_count": 1, + "total_count": 1, + "order": ["relevance_desc", "created_at_desc", "id_desc"], + }, + }, + ) + monkeypatch.setattr( + chief, + "compile_continuity_open_loop_dashboard", + lambda *args, **kwargs: { + "dashboard": { + "waiting_for": {"items": []}, + "blocker": {"items": []}, + "stale": {"items": []}, + "next_action": {"items": [recall_item]}, + } + }, + ) + monkeypatch.setattr( + chief, + "compile_continuity_resumption_brief", + lambda *args, **kwargs: { + "brief": { + "recent_changes": {"items": [recall_item]}, + "next_action": {"item": recall_item}, + } + }, + ) + monkeypatch.setattr( + chief, + "compile_continuity_weekly_review", + lambda *args, **kwargs: { + "review": { + "scope": {"thread_id": None, "since": None, "until": None}, + "rollup": { + "total_count": 0, + "waiting_for_count": 0, + "blocker_count": 0, + "stale_count": 0, + "correction_recurrence_count": 0, + "freshness_drift_count": 0, + "next_action_count": 0, + "posture_order": ["waiting_for", "blocker", "stale", "next_action"], + }, + } + }, + ) + + def compile_with_trust_status(status: str) -> dict[str, object]: + monkeypatch.setattr( + chief, + "get_memory_trust_dashboard_summary", + lambda *args, **kwargs: { + "dashboard": { + "quality_gate": {"status": status}, + "retrieval_quality": {"status": "fail"}, + } + }, + ) + return chief.compile_chief_of_staff_priority_brief( + object(), # type: ignore[arg-type] + user_id=UUID("11111111-1111-4111-8111-111111111111"), + request=ChiefOfStaffPriorityBriefRequestInput(limit=1), + ) + + needs_review_payload = compile_with_trust_status("needs_review") + needs_review_ranked = needs_review_payload["brief"]["ranked_items"][0] + assert needs_review_payload["brief"]["summary"]["trust_confidence_posture"] == "medium" + assert needs_review_ranked["confidence_posture"] == "medium" + assert needs_review_ranked["rationale"]["trust_signals"]["retrieval_status"] == "fail" + assert needs_review_ranked["rationale"]["trust_signals"]["trust_confidence_cap"] == "medium" + assert "needs review" in needs_review_ranked["rationale"]["trust_signals"]["reason"] + + insufficient_sample_payload = compile_with_trust_status("insufficient_sample") + insufficient_sample_ranked = insufficient_sample_payload["brief"]["ranked_items"][0] + assert insufficient_sample_payload["brief"]["summary"]["trust_confidence_posture"] == "low" + assert insufficient_sample_ranked["confidence_posture"] == "low" + assert insufficient_sample_ranked["rationale"]["trust_signals"]["retrieval_status"] == "fail" + assert insufficient_sample_ranked["rationale"]["trust_signals"]["trust_confidence_cap"] == "low" + assert "weak" in insufficient_sample_ranked["rationale"]["trust_signals"]["reason"] + assert insufficient_sample_payload["brief"]["draft_follow_up"]["status"] == "none" + assert insufficient_sample_payload["brief"]["resumption_supervision"]["confidence_posture"] == "low" + + +def test_capture_handoff_review_action_records_transition_and_returns_updated_queue(monkeypatch) -> None: + class _FakeStore: + def __init__(self) -> None: + self.capture_event_payloads: list[dict[str, object]] = [] + self.object_payloads: list[dict[str, object]] = [] + + def create_continuity_capture_event( + self, + *, + raw_content: str, + explicit_signal: str, + admission_posture: str, + admission_reason: str, + ) -> dict[str, object]: + self.capture_event_payloads.append( + { + "raw_content": raw_content, + "explicit_signal": explicit_signal, + "admission_posture": admission_posture, + "admission_reason": admission_reason, + } + ) + return {"id": UUID("11111111-1111-4111-8111-111111111111")} + + def create_continuity_object( + self, + *, + capture_event_id: UUID, + object_type: str, + status: str, + title: str, + body: dict[str, object], + provenance: dict[str, object], + confidence: float, + is_preserved: bool = True, + is_searchable: bool = True, + is_promotable: bool = True, + ) -> dict[str, object]: + self.object_payloads.append( + { + "capture_event_id": capture_event_id, + "object_type": object_type, + "status": status, + "is_preserved": is_preserved, + "is_searchable": is_searchable, + "is_promotable": is_promotable, + "title": title, + "body": body, + "provenance": provenance, + "confidence": confidence, + } + ) + return { + "id": UUID("22222222-2222-4222-8222-222222222222"), + "capture_event_id": capture_event_id, + "created_at": datetime(2026, 4, 1, 9, 0, tzinfo=UTC), + "updated_at": datetime(2026, 4, 1, 9, 0, tzinfo=UTC), + } + + first_brief = { + "handoff_queue_groups": { + "ready": { + "items": [ + { + "handoff_item_id": "handoff-1", + "lifecycle_state": "ready", + } + ] + }, + "pending_approval": {"items": []}, + "executed": {"items": []}, + "stale": {"items": []}, + "expired": {"items": []}, + } + } + second_brief = { + "handoff_queue_summary": { + "total_count": 1, + "ready_count": 0, + "pending_approval_count": 0, + "executed_count": 0, + "stale_count": 1, + "expired_count": 0, + "state_order": ["ready", "pending_approval", "executed", "stale", "expired"], + "group_order": ["ready", "pending_approval", "executed", "stale", "expired"], + "item_order": ["queue_rank_asc", "handoff_rank_asc", "score_desc", "handoff_item_id_asc"], + "review_action_order": ["mark_ready", "mark_pending_approval", "mark_executed", "mark_stale", "mark_expired"], + }, + "handoff_queue_groups": { + "ready": {"items": []}, + "pending_approval": {"items": []}, + "executed": {"items": []}, + "stale": { + "items": [ + { + "handoff_item_id": "handoff-1", + "lifecycle_state": "stale", + } + ] + }, + "expired": {"items": []}, + }, + "handoff_review_actions": [], + } + + compile_calls = {"count": 0} + + def fake_compile(*args, **kwargs): + compile_calls["count"] += 1 + if compile_calls["count"] == 1: + return {"brief": first_brief} + return {"brief": second_brief} + + monkeypatch.setattr(chief, "compile_chief_of_staff_priority_brief", fake_compile) + + store = _FakeStore() + response = chief.capture_chief_of_staff_handoff_review_action( + store, # type: ignore[arg-type] + user_id=UUID("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"), + request=ChiefOfStaffHandoffReviewActionInput( + handoff_item_id="handoff-1", + review_action="mark_stale", + thread_id=UUID("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"), + ), + ) + + assert compile_calls["count"] == 2 + assert store.capture_event_payloads + assert store.object_payloads + assert store.object_payloads[0]["body"]["kind"] == "chief_of_staff_handoff_review_action" + assert response["review_action"]["handoff_item_id"] == "handoff-1" + assert response["review_action"]["review_action"] == "mark_stale" + assert response["review_action"]["previous_lifecycle_state"] == "ready" + assert response["review_action"]["next_lifecycle_state"] == "stale" + assert response["handoff_queue_summary"]["stale_count"] == 1 + + +def test_capture_execution_routing_action_records_transition_and_returns_updated_routing(monkeypatch) -> None: + class _FakeStore: + def __init__(self) -> None: + self.capture_event_payloads: list[dict[str, object]] = [] + self.object_payloads: list[dict[str, object]] = [] + + def create_continuity_capture_event( + self, + *, + raw_content: str, + explicit_signal: str, + admission_posture: str, + admission_reason: str, + ) -> dict[str, object]: + self.capture_event_payloads.append( + { + "raw_content": raw_content, + "explicit_signal": explicit_signal, + "admission_posture": admission_posture, + "admission_reason": admission_reason, + } + ) + return {"id": UUID("11111111-1111-4111-8111-111111111111")} + + def create_continuity_object( + self, + *, + capture_event_id: UUID, + object_type: str, + status: str, + title: str, + body: dict[str, object], + provenance: dict[str, object], + confidence: float, + is_preserved: bool = True, + is_searchable: bool = True, + is_promotable: bool = True, + ) -> dict[str, object]: + self.object_payloads.append( + { + "capture_event_id": capture_event_id, + "object_type": object_type, + "status": status, + "is_preserved": is_preserved, + "is_searchable": is_searchable, + "is_promotable": is_promotable, + "title": title, + "body": body, + "provenance": provenance, + "confidence": confidence, + } + ) + return { + "id": UUID("33333333-3333-4333-8333-333333333333"), + "capture_event_id": capture_event_id, + "created_at": datetime(2026, 4, 1, 9, 30, tzinfo=UTC), + "updated_at": datetime(2026, 4, 1, 9, 30, tzinfo=UTC), + } + + first_brief = { + "routed_handoff_items": [ + { + "handoff_rank": 1, + "handoff_item_id": "handoff-1", + "title": "Next Action: Ship dashboard", + "source_kind": "recommended_next_action", + "recommendation_action": "execute_next_action", + "route_target_order": ["task_workflow_draft", "approval_workflow_draft", "follow_up_draft_only"], + "available_route_targets": ["task_workflow_draft", "approval_workflow_draft"], + "routed_targets": [], + "is_routed": False, + "task_workflow_draft_routed": False, + "approval_workflow_draft_routed": False, + "follow_up_draft_only_routed": False, + "follow_up_draft_only_applicable": False, + "task_draft": { + "status": "draft", + "mode": "governed_request_draft", + "approval_required": True, + "auto_execute": False, + "source_handoff_item_id": "handoff-1", + "title": "Next Action: Ship dashboard", + "summary": "Draft-only governed request.", + "target": {"thread_id": "thread-1", "task_id": None, "project": None, "person": None}, + "request": { + "action": "execute_next_action", + "scope": "chief_of_staff_priority", + "domain_hint": "planning", + "risk_hint": "governed_handoff", + "attributes": {"handoff_item_id": "handoff-1"}, + }, + "rationale": "deterministic fixture", + "provenance_references": [], + }, + "approval_draft": { + "status": "draft_only", + "mode": "approval_request_draft", + "decision": "approval_required", + "approval_required": True, + "auto_submit": False, + "source_handoff_item_id": "handoff-1", + "request": { + "action": "execute_next_action", + "scope": "chief_of_staff_priority", + "domain_hint": "planning", + "risk_hint": "governed_handoff", + "attributes": {"handoff_item_id": "handoff-1"}, + }, + "reason": "approval required", + "required_checks": ["operator_review_handoff_artifact"], + "provenance_references": [], + }, + "last_routing_transition": None, + } + ] + } + second_brief = { + "execution_routing_summary": { + "total_handoff_count": 1, + "routed_handoff_count": 1, + "unrouted_handoff_count": 0, + "task_workflow_draft_count": 1, + "approval_workflow_draft_count": 0, + "follow_up_draft_only_count": 0, + "route_target_order": ["task_workflow_draft", "approval_workflow_draft", "follow_up_draft_only"], + "routed_item_order": ["handoff_rank_asc", "handoff_item_id_asc"], + "audit_order": ["created_at_desc", "id_desc"], + "transition_order": ["routed", "reaffirmed"], + "approval_required": True, + "non_autonomous_guarantee": "No task, approval, connector send, or external side effect is executed by this endpoint.", + "reason": "Routing transitions are explicit and auditable.", + }, + "routed_handoff_items": [ + { + **first_brief["routed_handoff_items"][0], + "routed_targets": ["task_workflow_draft"], + "is_routed": True, + "task_workflow_draft_routed": True, + "last_routing_transition": { + "id": "route-1", + "capture_event_id": "capture-route-1", + "handoff_item_id": "handoff-1", + "route_target": "task_workflow_draft", + "transition": "routed", + "previously_routed": False, + "route_state": True, + "reason": "Operator routed handoff.", + "note": None, + "provenance_references": [], + "created_at": "2026-04-01T09:30:00+00:00", + "updated_at": "2026-04-01T09:30:00+00:00", + }, + } + ], + "routing_audit_trail": [], + "execution_readiness_posture": { + "posture": "approval_required_draft_only", + "approval_required": True, + "autonomous_execution": False, + "external_side_effects_allowed": False, + "approval_path_visible": True, + "route_target_order": ["task_workflow_draft", "approval_workflow_draft", "follow_up_draft_only"], + "required_route_targets": ["task_workflow_draft", "approval_workflow_draft"], + "transition_order": ["routed", "reaffirmed"], + "non_autonomous_guarantee": "No task, approval, connector send, or external side effect is executed by this endpoint.", + "reason": "draft-only", + }, + } + + compile_calls = {"count": 0} + + def fake_compile(*args, **kwargs): + compile_calls["count"] += 1 + if compile_calls["count"] == 1: + return {"brief": first_brief} + return {"brief": second_brief} + + monkeypatch.setattr(chief, "compile_chief_of_staff_priority_brief", fake_compile) + + store = _FakeStore() + response = chief.capture_chief_of_staff_execution_routing_action( + store, # type: ignore[arg-type] + user_id=UUID("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"), + request=chief.ChiefOfStaffExecutionRoutingActionInput( + handoff_item_id="handoff-1", + route_target="task_workflow_draft", + thread_id=UUID("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"), + ), + ) + + assert compile_calls["count"] == 2 + assert store.capture_event_payloads + assert store.object_payloads + assert store.object_payloads[0]["body"]["kind"] == "chief_of_staff_execution_routing_action" + assert response["routing_action"]["handoff_item_id"] == "handoff-1" + assert response["routing_action"]["route_target"] == "task_workflow_draft" + assert response["routing_action"]["transition"] == "routed" + assert response["execution_routing_summary"]["routed_handoff_count"] == 1 + assert response["routed_handoff_items"][0]["task_workflow_draft_routed"] is True + assert response["execution_readiness_posture"]["approval_required"] is True + + +def test_governed_handoff_state_maps_keep_executed_over_pending() -> None: + class _FakeStore: + def list_approvals(self) -> list[dict[str, object]]: + return [ + { + "status": "pending", + "request": {"attributes": {"handoff_item_id": "handoff-executed"}}, + }, + { + "status": "pending", + "request": {"attributes": {"handoff_item_id": "handoff-pending"}}, + }, + ] + + def list_tasks(self) -> list[dict[str, object]]: + return [ + { + "status": "executed", + "request": {"attributes": {"handoff_item_id": "handoff-executed"}}, + }, + { + "status": "pending_approval", + "request": {"attributes": {"handoff_item_id": "handoff-pending-from-task"}}, + }, + ] + + pending_ids, executed_ids = chief._build_governed_handoff_state_maps( # type: ignore[attr-defined] + store=_FakeStore(), # type: ignore[arg-type] + ) + + assert executed_ids == {"handoff-executed"} + assert pending_ids == {"handoff-pending", "handoff-pending-from-task"} + assert "handoff-executed" not in pending_ids + + +def test_handoff_queue_infers_deterministic_governed_and_age_based_states() -> None: + class _FakeStore: + def list_approvals(self) -> list[dict[str, object]]: + return [ + { + "status": "pending", + "request": {"attributes": {"handoff_item_id": "handoff-pending"}}, + } + ] + + def list_tasks(self) -> list[dict[str, object]]: + return [ + { + "status": "executed", + "request": {"attributes": {"handoff_item_id": "handoff-executed"}}, + } + ] + + handoff_items = [ + { + "rank": 5, + "handoff_item_id": "handoff-ready", + "source_kind": "recommended_next_action", + "source_reference_id": "source-latest", + "title": "Next Action: Ready", + "recommendation_action": "execute_next_action", + "priority_posture": "urgent", + "confidence_posture": "high", + "score": 1500.0, + "provenance_references": [], + }, + { + "rank": 1, + "handoff_item_id": "handoff-pending", + "source_kind": "recommended_next_action", + "source_reference_id": "source-pending", + "title": "Next Action: Pending", + "recommendation_action": "execute_next_action", + "priority_posture": "urgent", + "confidence_posture": "high", + "score": 1400.0, + "provenance_references": [], + }, + { + "rank": 2, + "handoff_item_id": "handoff-executed", + "source_kind": "recommended_next_action", + "source_reference_id": "source-executed", + "title": "Next Action: Executed", + "recommendation_action": "execute_next_action", + "priority_posture": "urgent", + "confidence_posture": "high", + "score": 1300.0, + "provenance_references": [], + }, + { + "rank": 3, + "handoff_item_id": "handoff-stale", + "source_kind": "recommended_next_action", + "source_reference_id": "source-stale", + "title": "Next Action: Stale", + "recommendation_action": "execute_next_action", + "priority_posture": "high", + "confidence_posture": "medium", + "score": 1200.0, + "provenance_references": [], + }, + { + "rank": 4, + "handoff_item_id": "handoff-expired", + "source_kind": "recommended_next_action", + "source_reference_id": "source-expired", + "title": "Next Action: Expired", + "recommendation_action": "execute_next_action", + "priority_posture": "high", + "confidence_posture": "medium", + "score": 1100.0, + "provenance_references": [], + }, + ] + recall_items = [ + _recall_item( + item_id="source-latest", + object_type="NextAction", + status="active", + title="Latest source", + created_at="2026-04-01T12:00:00+00:00", + ), + _recall_item( + item_id="source-pending", + object_type="NextAction", + status="active", + title="Pending source", + created_at="2026-04-01T11:00:00+00:00", + ), + _recall_item( + item_id="source-executed", + object_type="NextAction", + status="active", + title="Executed source", + created_at="2026-04-01T10:00:00+00:00", + ), + _recall_item( + item_id="source-stale", + object_type="NextAction", + status="active", + title="Stale source", + created_at="2026-03-27T11:00:00+00:00", + ), + _recall_item( + item_id="source-expired", + object_type="NextAction", + status="active", + title="Expired source", + created_at="2026-03-18T11:00:00+00:00", + ), + ] + + summary, groups = chief._build_handoff_queue( # type: ignore[attr-defined] + store=_FakeStore(), # type: ignore[arg-type] + handoff_items=handoff_items, # type: ignore[arg-type] + recall_items=recall_items, # type: ignore[arg-type] + all_follow_through_items=[], + handoff_review_actions=[], + ) + + assert summary["total_count"] == 5 + assert summary["ready_count"] == 1 + assert summary["pending_approval_count"] == 1 + assert summary["executed_count"] == 1 + assert summary["stale_count"] == 1 + assert summary["expired_count"] == 1 + assert groups["ready"]["items"][0]["handoff_item_id"] == "handoff-ready" + assert groups["pending_approval"]["items"][0]["handoff_item_id"] == "handoff-pending" + assert groups["executed"]["items"][0]["handoff_item_id"] == "handoff-executed" + assert groups["stale"]["items"][0]["handoff_item_id"] == "handoff-stale" + assert groups["expired"]["items"][0]["handoff_item_id"] == "handoff-expired" + assert groups["ready"]["items"][0]["queue_rank"] == 1 + assert groups["pending_approval"]["items"][0]["queue_rank"] == 2 + assert groups["executed"]["items"][0]["queue_rank"] == 3 + assert groups["stale"]["items"][0]["queue_rank"] == 4 + assert groups["expired"]["items"][0]["queue_rank"] == 5 + assert groups["ready"]["items"][0]["lifecycle_state"] == "ready" + assert groups["pending_approval"]["items"][0]["lifecycle_state"] == "pending_approval" + assert groups["executed"]["items"][0]["lifecycle_state"] == "executed" + assert groups["stale"]["items"][0]["lifecycle_state"] == "stale" + assert groups["expired"]["items"][0]["lifecycle_state"] == "expired" + assert "mark_ready" not in groups["ready"]["items"][0]["available_review_actions"] + assert "mark_pending_approval" not in groups["pending_approval"]["items"][0]["available_review_actions"] + assert "mark_executed" not in groups["executed"]["items"][0]["available_review_actions"] + assert "mark_stale" not in groups["stale"]["items"][0]["available_review_actions"] + assert "mark_expired" not in groups["expired"]["items"][0]["available_review_actions"] + + +def test_capture_recommendation_outcome_creates_auditable_note_and_returns_learning(monkeypatch) -> None: + class _FakeStore: + def __init__(self) -> None: + self.capture_event_payloads: list[dict[str, object]] = [] + self.object_payloads: list[dict[str, object]] = [] + + def create_continuity_capture_event( + self, + *, + raw_content: str, + explicit_signal: str, + admission_posture: str, + admission_reason: str, + ) -> dict[str, object]: + self.capture_event_payloads.append( + { + "raw_content": raw_content, + "explicit_signal": explicit_signal, + "admission_posture": admission_posture, + "admission_reason": admission_reason, + } + ) + return {"id": UUID("11111111-1111-4111-8111-111111111111")} + + def create_continuity_object( + self, + *, + capture_event_id: UUID, + object_type: str, + status: str, + title: str, + body: dict[str, object], + provenance: dict[str, object], + confidence: float, + is_preserved: bool = True, + is_searchable: bool = True, + is_promotable: bool = True, + ) -> dict[str, object]: + self.object_payloads.append( + { + "capture_event_id": capture_event_id, + "object_type": object_type, + "status": status, + "is_preserved": is_preserved, + "is_searchable": is_searchable, + "is_promotable": is_promotable, + "title": title, + "body": body, + "provenance": provenance, + "confidence": confidence, + } + ) + return { + "id": UUID("22222222-2222-4222-8222-222222222222"), + "capture_event_id": capture_event_id, + "created_at": datetime(2026, 3, 31, 12, 0, tzinfo=UTC), + "updated_at": datetime(2026, 3, 31, 12, 0, tzinfo=UTC), + } + + monkeypatch.setattr( + chief, + "compile_chief_of_staff_priority_brief", + lambda *args, **kwargs: { + "brief": { + "recommendation_outcomes": { + "items": [], + "summary": { + "returned_count": 0, + "total_count": 1, + "outcome_counts": {"accept": 1, "defer": 0, "ignore": 0, "rewrite": 0}, + "order": ["created_at_desc", "id_desc"], + }, + }, + "priority_learning_summary": { + "total_count": 1, + "accept_count": 1, + "defer_count": 0, + "ignore_count": 0, + "rewrite_count": 0, + "acceptance_rate": 1.0, + "override_rate": 0.0, + "defer_hotspots": [], + "ignore_hotspots": [], + "priority_shift_explanation": "Prioritization is reinforcing currently accepted recommendation patterns while tracking defer/override hotspots.", + "hotspot_order": ["count_desc", "key_asc"], + }, + "pattern_drift_summary": { + "posture": "improving", + "reason": "Accepted outcomes are leading with bounded defers/overrides, indicating improving recommendation fit.", + "supporting_signals": [], + }, + } + }, + ) + + store = _FakeStore() + response = chief.capture_chief_of_staff_recommendation_outcome( + store, # type: ignore[arg-type] + user_id=UUID("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"), + request=ChiefOfStaffRecommendationOutcomeCaptureInput( + outcome="accept", + recommendation_action_type="execute_next_action", + recommendation_title="Next Action: Ship dashboard", + rationale="Accepted in weekly review.", + target_priority_id=UUID("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"), + thread_id=UUID("cccccccc-cccc-4ccc-8ccc-cccccccccccc"), + ), + ) + + assert store.capture_event_payloads + assert store.object_payloads + assert store.object_payloads[0]["object_type"] == "Note" + assert store.object_payloads[0]["body"]["kind"] == "chief_of_staff_recommendation_outcome" + assert response["outcome"]["outcome"] == "accept" + assert response["outcome"]["recommendation_action_type"] == "execute_next_action" + assert response["recommendation_outcomes"]["summary"]["outcome_counts"]["accept"] == 1 + assert response["priority_learning_summary"]["acceptance_rate"] == 1.0 + assert response["pattern_drift_summary"]["posture"] == "improving" + + +def test_handoff_outcome_rollups_are_deterministic_and_latest_state_driven() -> None: + recall_items = [ + _handoff_outcome_recall_item( + item_id="handoff-outcome-1", + handoff_item_id="handoff-1", + created_at="2026-04-07T09:00:00+00:00", + outcome_status="reviewed", + previous_outcome_status=None, + ), + _handoff_outcome_recall_item( + item_id="handoff-outcome-2", + handoff_item_id="handoff-1", + created_at="2026-04-07T09:15:00+00:00", + outcome_status="executed", + previous_outcome_status="reviewed", + ), + _handoff_outcome_recall_item( + item_id="handoff-outcome-3", + handoff_item_id="handoff-2", + created_at="2026-04-07T09:10:00+00:00", + outcome_status="ignored", + previous_outcome_status=None, + ), + ] + + all_outcomes = chief._list_handoff_outcome_records( # type: ignore[attr-defined] + recall_items, # type: ignore[arg-type] + ) + + assert [item["id"] for item in all_outcomes] == [ + "handoff-outcome-2", + "handoff-outcome-3", + "handoff-outcome-1", + ] + assert all_outcomes[0]["is_latest_outcome"] is True + assert all_outcomes[1]["is_latest_outcome"] is True + assert all_outcomes[2]["is_latest_outcome"] is False + + summary, selected, latest_counts = chief._build_handoff_outcome_artifacts( # type: ignore[attr-defined] + all_handoff_outcomes=all_outcomes, # type: ignore[arg-type] + limit=10, + ) + assert summary["total_count"] == 3 + assert summary["latest_total_count"] == 2 + assert summary["status_counts"]["reviewed"] == 1 + assert summary["status_counts"]["executed"] == 1 + assert summary["status_counts"]["ignored"] == 1 + assert summary["latest_status_counts"]["reviewed"] == 0 + assert summary["latest_status_counts"]["executed"] == 1 + assert summary["latest_status_counts"]["ignored"] == 1 + assert selected[0]["id"] == "handoff-outcome-2" + assert latest_counts["executed"] == 1 + assert latest_counts["ignored"] == 1 + + closure_quality = chief._build_closure_quality_summary( # type: ignore[attr-defined] + handoff_outcome_summary=summary, + latest_status_counts=latest_counts, + ) + conversion = chief._build_conversion_signal_summary( # type: ignore[attr-defined] + total_handoff_count=3, + handoff_outcome_summary=summary, + latest_status_counts=latest_counts, + ) + escalation = chief._build_stale_ignored_escalation_posture( # type: ignore[attr-defined] + handoff_queue_summary={ + "total_count": 3, + "ready_count": 1, + "pending_approval_count": 0, + "executed_count": 1, + "stale_count": 1, + "expired_count": 0, + "state_order": ["ready", "pending_approval", "executed", "stale", "expired"], + "group_order": ["ready", "pending_approval", "executed", "stale", "expired"], + "item_order": ["queue_rank_asc", "handoff_rank_asc", "score_desc", "handoff_item_id_asc"], + "review_action_order": ["mark_ready", "mark_pending_approval", "mark_executed", "mark_stale", "mark_expired"], + }, # type: ignore[arg-type] + latest_status_counts=latest_counts, + ) + + assert closure_quality["posture"] == "watch" + assert closure_quality["closed_loop_count"] == 1 + assert closure_quality["ignored_count"] == 1 + assert conversion["recommendation_to_execution_conversion_rate"] == 0.333333 + assert conversion["recommendation_to_closure_conversion_rate"] == 0.333333 + assert conversion["capture_coverage_rate"] == 0.666667 + assert escalation["posture"] in {"elevated", "critical"} + assert escalation["trigger_count"] == 2 + + +def test_capture_handoff_outcome_records_event_and_returns_updated_learning(monkeypatch) -> None: + class _FakeStore: + def __init__(self) -> None: + self.capture_event_payloads: list[dict[str, object]] = [] + self.object_payloads: list[dict[str, object]] = [] + + def create_continuity_capture_event( + self, + *, + raw_content: str, + explicit_signal: str, + admission_posture: str, + admission_reason: str, + ) -> dict[str, object]: + self.capture_event_payloads.append( + { + "raw_content": raw_content, + "explicit_signal": explicit_signal, + "admission_posture": admission_posture, + "admission_reason": admission_reason, + } + ) + return {"id": UUID("11111111-1111-4111-8111-111111111111")} + + def create_continuity_object( + self, + *, + capture_event_id: UUID, + object_type: str, + status: str, + title: str, + body: dict[str, object], + provenance: dict[str, object], + confidence: float, + is_preserved: bool = True, + is_searchable: bool = True, + is_promotable: bool = True, + ) -> dict[str, object]: + self.object_payloads.append( + { + "capture_event_id": capture_event_id, + "object_type": object_type, + "status": status, + "is_preserved": is_preserved, + "is_searchable": is_searchable, + "is_promotable": is_promotable, + "title": title, + "body": body, + "provenance": provenance, + "confidence": confidence, + } + ) + return { + "id": UUID("44444444-4444-4444-8444-444444444444"), + "capture_event_id": capture_event_id, + "created_at": datetime(2026, 4, 7, 9, 30, tzinfo=UTC), + "updated_at": datetime(2026, 4, 7, 9, 30, tzinfo=UTC), + } + + first_brief = { + "routed_handoff_items": [ + { + "handoff_rank": 1, + "handoff_item_id": "handoff-1", + "title": "Next Action: Ship dashboard", + "source_kind": "recommended_next_action", + "recommendation_action": "execute_next_action", + "route_target_order": ["task_workflow_draft", "approval_workflow_draft", "follow_up_draft_only"], + "available_route_targets": ["task_workflow_draft", "approval_workflow_draft"], + "routed_targets": ["task_workflow_draft"], + "is_routed": True, + "task_workflow_draft_routed": True, + "approval_workflow_draft_routed": False, + "follow_up_draft_only_routed": False, + "follow_up_draft_only_applicable": False, + "task_draft": { + "status": "draft", + "mode": "governed_request_draft", + "approval_required": True, + "auto_execute": False, + "source_handoff_item_id": "handoff-1", + "title": "Next Action: Ship dashboard", + "summary": "Draft-only governed request.", + "target": {"thread_id": "thread-1", "task_id": None, "project": None, "person": None}, + "request": { + "action": "execute_next_action", + "scope": "chief_of_staff_priority", + "domain_hint": "planning", + "risk_hint": "governed_handoff", + "attributes": {}, + }, + "rationale": "fixture rationale", + "provenance_references": [], + }, + "approval_draft": { + "status": "draft_only", + "mode": "approval_request_draft", + "decision": "approval_required", + "approval_required": True, + "auto_submit": False, + "source_handoff_item_id": "handoff-1", + "request": { + "action": "execute_next_action", + "scope": "chief_of_staff_priority", + "domain_hint": "planning", + "risk_hint": "governed_handoff", + "attributes": {}, + }, + "reason": "approval required", + "required_checks": ["operator_review_handoff_artifact"], + "provenance_references": [], + }, + "last_routing_transition": None, + } + ], + "handoff_outcomes": [], + } + second_brief = { + "handoff_outcome_summary": { + "returned_count": 1, + "total_count": 1, + "latest_total_count": 1, + "status_counts": { + "reviewed": 0, + "approved": 0, + "rejected": 0, + "rewritten": 0, + "executed": 1, + "ignored": 0, + "expired": 0, + }, + "latest_status_counts": { + "reviewed": 0, + "approved": 0, + "rejected": 0, + "rewritten": 0, + "executed": 1, + "ignored": 0, + "expired": 0, + }, + "status_order": ["reviewed", "approved", "rejected", "rewritten", "executed", "ignored", "expired"], + "order": ["created_at_desc", "id_desc"], + }, + "handoff_outcomes": [], + "closure_quality_summary": { + "posture": "healthy", + "reason": "Closed-loop outcomes are leading with bounded unresolved and ignored outcomes.", + "closed_loop_count": 1, + "unresolved_count": 0, + "rejected_count": 0, + "ignored_count": 0, + "expired_count": 0, + "closure_rate": 1.0, + "explanation": "Closure quality uses latest immutable outcomes.", + }, + "conversion_signal_summary": { + "total_handoff_count": 1, + "latest_outcome_count": 1, + "executed_count": 1, + "approved_count": 0, + "reviewed_count": 0, + "rewritten_count": 0, + "rejected_count": 0, + "ignored_count": 0, + "expired_count": 0, + "recommendation_to_execution_conversion_rate": 1.0, + "recommendation_to_closure_conversion_rate": 1.0, + "capture_coverage_rate": 1.0, + "explanation": "Conversion signals are derived from latest immutable outcomes.", + }, + "stale_ignored_escalation_posture": { + "posture": "watch", + "reason": "No stale queue pressure or ignored/expired latest outcomes are currently detected.", + "stale_queue_count": 0, + "ignored_count": 0, + "expired_count": 0, + "trigger_count": 0, + "guidance_posture_explanation": "Guidance posture is derived from stale queue load.", + "supporting_signals": [], + }, + } + + compile_calls = {"count": 0} + + def fake_compile(*args, **kwargs): + compile_calls["count"] += 1 + if compile_calls["count"] == 1: + return {"brief": first_brief} + return {"brief": second_brief} + + monkeypatch.setattr(chief, "compile_chief_of_staff_priority_brief", fake_compile) + + store = _FakeStore() + response = chief.capture_chief_of_staff_handoff_outcome( + store, # type: ignore[arg-type] + user_id=UUID("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"), + request=ChiefOfStaffHandoffOutcomeCaptureInput( + handoff_item_id="handoff-1", + outcome_status="executed", + thread_id=UUID("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"), + ), + ) + + assert compile_calls["count"] == 2 + assert store.capture_event_payloads + assert store.object_payloads + assert store.object_payloads[0]["body"]["kind"] == "chief_of_staff_handoff_outcome" + assert response["handoff_outcome"]["handoff_item_id"] == "handoff-1" + assert response["handoff_outcome"]["outcome_status"] == "executed" + assert response["handoff_outcome_summary"]["latest_status_counts"]["executed"] == 1 + assert response["closure_quality_summary"]["posture"] == "healthy" + assert response["conversion_signal_summary"]["recommendation_to_execution_conversion_rate"] == 1.0 diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py new file mode 100644 index 0000000..cffe06a --- /dev/null +++ b/tests/unit/test_cli.py @@ -0,0 +1,232 @@ +from __future__ import annotations + +from uuid import UUID, uuid4 + +import alicebot_api.cli as cli_module +from alicebot_api.config import Settings +from alicebot_api.contracts import ContinuityRecallResponse + + +def test_parser_routes_required_commands() -> None: + parser = cli_module.build_parser() + continuity_object_id = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + + cases = [ + (["capture", "Decision: Keep rollout phased"], "_run_capture"), + (["recall"], "_run_recall"), + (["lifecycle", "list"], "_run_lifecycle_list"), + (["lifecycle", "show", continuity_object_id], "_run_lifecycle_show"), + (["resume"], "_run_resume"), + (["open-loops"], "_run_open_loops"), + (["review", "queue"], "_run_review_queue"), + (["review", "show", continuity_object_id], "_run_review_show"), + (["review", "apply", continuity_object_id, "--action", "confirm"], "_run_review_apply"), + (["status"], "_run_status"), + ] + + for argv, expected_handler_name in cases: + parsed = parser.parse_args(argv) + assert parsed.handler.__name__ == expected_handler_name + + +def test_resolve_user_id_prefers_flag_then_settings_then_env_then_default(monkeypatch) -> None: + flag_user_id = UUID("11111111-1111-4111-8111-111111111111") + configured_user_id = UUID("22222222-2222-4222-8222-222222222222") + env_user_id = UUID("33333333-3333-4333-8333-333333333333") + + settings_without_auth = Settings(auth_user_id="") + settings_with_auth = Settings(auth_user_id=str(configured_user_id)) + + monkeypatch.setenv("ALICEBOT_AUTH_USER_ID", str(env_user_id)) + assert cli_module._resolve_user_id(settings_without_auth, str(flag_user_id)) == flag_user_id + assert cli_module._resolve_user_id(settings_with_auth, None) == configured_user_id + assert cli_module._resolve_user_id(settings_without_auth, None) == env_user_id + + monkeypatch.delenv("ALICEBOT_AUTH_USER_ID") + assert cli_module._resolve_user_id(settings_without_auth, None) == UUID(cli_module.DEFAULT_CLI_USER_ID) + + +def test_main_returns_error_for_non_object_json_on_review_apply(monkeypatch, capsys) -> None: + monkeypatch.setattr( + cli_module, + "get_settings", + lambda: Settings(database_url="postgresql://db", auth_user_id=str(uuid4())), + ) + + exit_code = cli_module.main( + [ + "review", + "apply", + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "--action", + "edit", + "--body-json", + "[]", + ] + ) + + captured = capsys.readouterr() + assert exit_code == 1 + assert captured.out == "" + assert "error: --body-json must be a JSON object" in captured.err + + +def test_recall_formatting_is_deterministic() -> None: + payload: ContinuityRecallResponse = { + "items": [ + { + "id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "capture_event_id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + "object_type": "Decision", + "status": "active", + "lifecycle": { + "is_preserved": True, + "preservation_status": "preserved", + "is_searchable": True, + "searchability_status": "searchable", + "is_promotable": True, + "promotion_status": "promotable", + }, + "title": "Decision: Keep rollout phased", + "body": {"decision_text": "Keep rollout phased"}, + "provenance": {"thread_id": "thread-1"}, + "confirmation_status": "confirmed", + "admission_posture": "DERIVED", + "confidence": 0.95, + "relevance": 1.0, + "last_confirmed_at": "2026-03-30T10:00:00+00:00", + "supersedes_object_id": None, + "superseded_by_object_id": None, + "scope_matches": [{"kind": "thread", "value": "thread-1"}], + "provenance_references": [ + {"source_kind": "continuity_capture_event", "source_id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"}, + {"source_kind": "thread", "source_id": "thread-1"}, + ], + "ordering": { + "scope_match_count": 1, + "query_term_match_count": 2, + "confirmation_rank": 3, + "freshness_posture": "fresh", + "freshness_rank": 4, + "provenance_posture": "strong", + "provenance_rank": 3, + "supersession_posture": "current", + "supersession_rank": 3, + "posture_rank": 2, + "lifecycle_rank": 4, + "confidence": 0.95, + }, + "created_at": "2026-03-30T09:59:00+00:00", + "updated_at": "2026-03-30T10:00:00+00:00", + } + ], + "summary": { + "query": "rollout", + "filters": {"thread_id": "thread-1", "since": None, "until": None}, + "limit": 20, + "returned_count": 1, + "total_count": 1, + "order": ["relevance_desc", "created_at_desc", "id_desc"], + }, + } + + rendered = cli_module.format_recall_output(payload) + + assert rendered == ( + "recall summary\n" + "query: rollout\n" + "filters: thread_id=thread-1\n" + "returned: 1/1 (limit=20)\n" + "order: relevance_desc, created_at_desc, id_desc\n" + "items:\n" + " 1. [Decision|active] Decision: Keep rollout phased\n" + " id=aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa capture_event_id=bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb\n" + " lifecycle=preserved:True searchable:True promotable:True\n" + " confidence=0.950 relevance=1.000 confirmation=confirmed\n" + " freshness=fresh provenance=strong supersession=current\n" + " source=(unknown)\n" + " provenance_refs=continuity_capture_event:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb; thread:thread-1" + ) + + +def test_status_command_returns_unreachable_without_db_connection(monkeypatch, capsys) -> None: + user_id = UUID("44444444-4444-4444-8444-444444444444") + monkeypatch.setattr( + cli_module, + "get_settings", + lambda: Settings( + database_url="postgresql://db", + healthcheck_timeout_seconds=2, + auth_user_id=str(user_id), + ), + ) + monkeypatch.setattr(cli_module, "ping_database", lambda *_args, **_kwargs: False) + + exit_code = cli_module.main(["status"]) + captured = capsys.readouterr() + + assert exit_code == 0 + assert "database: unreachable" in captured.out + assert f"user_id: {user_id}" in captured.out + + +def test_recall_formatting_renders_provenance_source_label_when_present() -> None: + payload: ContinuityRecallResponse = { + "items": [ + { + "id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "capture_event_id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + "object_type": "Decision", + "status": "active", + "lifecycle": { + "is_preserved": True, + "preservation_status": "preserved", + "is_searchable": True, + "searchability_status": "searchable", + "is_promotable": True, + "promotion_status": "promotable", + }, + "title": "Decision: Keep rollout phased", + "body": {"decision_text": "Keep rollout phased"}, + "provenance": {"source_kind": "openclaw_import", "source_label": "OpenClaw"}, + "confirmation_status": "confirmed", + "admission_posture": "DERIVED", + "confidence": 0.95, + "relevance": 1.0, + "last_confirmed_at": "2026-03-30T10:00:00+00:00", + "supersedes_object_id": None, + "superseded_by_object_id": None, + "scope_matches": [], + "provenance_references": [ + {"source_kind": "continuity_capture_event", "source_id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"} + ], + "ordering": { + "scope_match_count": 0, + "query_term_match_count": 0, + "confirmation_rank": 3, + "freshness_posture": "fresh", + "freshness_rank": 4, + "provenance_posture": "strong", + "provenance_rank": 3, + "supersession_posture": "current", + "supersession_rank": 3, + "posture_rank": 2, + "lifecycle_rank": 4, + "confidence": 0.95, + }, + "created_at": "2026-03-30T09:59:00+00:00", + "updated_at": "2026-03-30T10:00:00+00:00", + } + ], + "summary": { + "query": None, + "filters": {"since": None, "until": None}, + "limit": 20, + "returned_count": 1, + "total_count": 1, + "order": ["relevance_desc", "created_at_desc", "id_desc"], + }, + } + + rendered = cli_module.format_recall_output(payload) + assert "source=OpenClaw (openclaw_import)" in rendered diff --git a/tests/unit/test_compiler.py b/tests/unit/test_compiler.py new file mode 100644 index 0000000..d966804 --- /dev/null +++ b/tests/unit/test_compiler.py @@ -0,0 +1,1829 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from uuid import uuid4 + +from alicebot_api.compiler import ( + SUMMARY_TRACE_EVENT_KIND, + _compile_artifact_chunk_section, + _compile_memory_section, + compile_resumption_brief, + compile_continuity_context, +) +from alicebot_api.contracts import ( + CompileContextArtifactScopedArtifactRetrievalInput, + CompileContextArtifactScopedSemanticArtifactRetrievalInput, + CompileContextSemanticRetrievalInput, + CompileContextTaskScopedSemanticArtifactRetrievalInput, + CompileContextTaskScopedArtifactRetrievalInput, + ContextCompilerLimits, +) + + +def test_compile_continuity_context_is_deterministic_and_stably_ordered() -> None: + user_id = uuid4() + thread_id = uuid4() + base_time = datetime(2026, 3, 11, 9, 0, tzinfo=UTC) + session_ids = [uuid4(), uuid4(), uuid4()] + event_ids = [uuid4(), uuid4(), uuid4(), uuid4()] + memory_ids = [uuid4(), uuid4(), uuid4()] + entity_ids = [uuid4(), uuid4(), uuid4()] + edge_ids = [uuid4(), uuid4(), uuid4(), uuid4()] + + user = { + "id": user_id, + "email": "owner@example.com", + "display_name": "Owner", + "created_at": base_time, + } + thread = { + "id": thread_id, + "user_id": user_id, + "title": "Traceable thread", + "created_at": base_time, + "updated_at": base_time + timedelta(minutes=4), + } + sessions = [ + { + "id": session_ids[0], + "user_id": user_id, + "thread_id": thread_id, + "status": "done", + "started_at": base_time, + "ended_at": base_time + timedelta(minutes=1), + "created_at": base_time, + }, + { + "id": session_ids[1], + "user_id": user_id, + "thread_id": thread_id, + "status": "done", + "started_at": base_time + timedelta(minutes=2), + "ended_at": base_time + timedelta(minutes=3), + "created_at": base_time + timedelta(minutes=2), + }, + { + "id": session_ids[2], + "user_id": user_id, + "thread_id": thread_id, + "status": "active", + "started_at": base_time + timedelta(minutes=4), + "ended_at": None, + "created_at": base_time + timedelta(minutes=4), + }, + ] + events = [ + { + "id": event_ids[0], + "user_id": user_id, + "thread_id": thread_id, + "session_id": session_ids[0], + "sequence_no": 1, + "kind": "message.user", + "payload": {"text": "one"}, + "created_at": base_time, + }, + { + "id": event_ids[1], + "user_id": user_id, + "thread_id": thread_id, + "session_id": session_ids[1], + "sequence_no": 2, + "kind": "message.assistant", + "payload": {"text": "two"}, + "created_at": base_time + timedelta(minutes=2), + }, + { + "id": event_ids[2], + "user_id": user_id, + "thread_id": thread_id, + "session_id": session_ids[2], + "sequence_no": 3, + "kind": "message.user", + "payload": {"text": "three"}, + "created_at": base_time + timedelta(minutes=4), + }, + { + "id": event_ids[3], + "user_id": user_id, + "thread_id": thread_id, + "session_id": session_ids[2], + "sequence_no": 4, + "kind": "message.assistant", + "payload": {"text": "four"}, + "created_at": base_time + timedelta(minutes=5), + }, + ] + memories = [ + { + "id": memory_ids[0], + "user_id": user_id, + "memory_key": "user.preference.tea", + "value": {"likes": "green"}, + "status": "active", + "source_event_ids": [str(event_ids[0])], + "created_at": base_time, + "updated_at": base_time + timedelta(minutes=1), + "deleted_at": None, + }, + { + "id": memory_ids[1], + "user_id": user_id, + "memory_key": "user.preference.coffee", + "value": {"likes": "oat milk"}, + "status": "active", + "source_event_ids": [str(event_ids[1])], + "created_at": base_time + timedelta(minutes=1), + "updated_at": base_time + timedelta(minutes=4), + "deleted_at": None, + }, + { + "id": memory_ids[2], + "user_id": user_id, + "memory_key": "user.preference.snacks", + "value": {"likes": "almonds"}, + "status": "active", + "source_event_ids": [str(event_ids[2])], + "created_at": base_time + timedelta(minutes=2), + "updated_at": base_time + timedelta(minutes=5), + "deleted_at": None, + }, + ] + entities = [ + { + "id": entity_ids[0], + "user_id": user_id, + "entity_type": "person", + "name": "Alex", + "source_memory_ids": [str(memory_ids[0])], + "created_at": base_time, + }, + { + "id": entity_ids[1], + "user_id": user_id, + "entity_type": "merchant", + "name": "Neighborhood Cafe", + "source_memory_ids": [str(memory_ids[1])], + "created_at": base_time + timedelta(minutes=3), + }, + { + "id": entity_ids[2], + "user_id": user_id, + "entity_type": "project", + "name": "AliceBot", + "source_memory_ids": [str(memory_ids[1]), str(memory_ids[2])], + "created_at": base_time + timedelta(minutes=6), + }, + ] + entity_edges = [ + { + "id": edge_ids[0], + "user_id": user_id, + "from_entity_id": entity_ids[0], + "to_entity_id": entity_ids[1], + "relationship_type": "visits", + "valid_from": None, + "valid_to": None, + "source_memory_ids": [str(memory_ids[0])], + "created_at": base_time + timedelta(minutes=2), + }, + { + "id": edge_ids[1], + "user_id": user_id, + "from_entity_id": entity_ids[2], + "to_entity_id": entity_ids[0], + "relationship_type": "references", + "valid_from": base_time + timedelta(minutes=5), + "valid_to": None, + "source_memory_ids": [str(memory_ids[2])], + "created_at": base_time + timedelta(minutes=5), + }, + { + "id": edge_ids[2], + "user_id": user_id, + "from_entity_id": entity_ids[1], + "to_entity_id": entity_ids[2], + "relationship_type": "works_on", + "valid_from": None, + "valid_to": base_time + timedelta(minutes=8), + "source_memory_ids": [str(memory_ids[1]), str(memory_ids[2])], + "created_at": base_time + timedelta(minutes=8), + }, + { + "id": edge_ids[3], + "user_id": user_id, + "from_entity_id": entity_ids[0], + "to_entity_id": entity_ids[0], + "relationship_type": "self_loop", + "valid_from": None, + "valid_to": None, + "source_memory_ids": [str(memory_ids[0])], + "created_at": base_time + timedelta(minutes=9), + }, + ] + limits = ContextCompilerLimits( + max_sessions=2, + max_events=2, + max_memories=2, + max_entities=2, + max_entity_edges=2, + ) + + first_run = compile_continuity_context( + user=user, + thread=thread, + sessions=sessions, + events=events, + memories=memories, + entities=entities, + entity_edges=entity_edges, + limits=limits, + ) + second_run = compile_continuity_context( + user=user, + thread=thread, + sessions=sessions, + events=events, + memories=memories, + entities=entities, + entity_edges=entity_edges, + limits=limits, + ) + + assert first_run.context_pack == second_run.context_pack + assert first_run.trace_events == second_run.trace_events + assert [session["id"] for session in first_run.context_pack["sessions"]] == [ + str(session_ids[1]), + str(session_ids[2]), + ] + assert [event["sequence_no"] for event in first_run.context_pack["events"]] == [3, 4] + assert [memory["memory_key"] for memory in first_run.context_pack["memories"]] == [ + "user.preference.coffee", + "user.preference.snacks", + ] + assert [memory["source_provenance"] for memory in first_run.context_pack["memories"]] == [ + {"sources": ["symbolic"], "semantic_score": None}, + {"sources": ["symbolic"], "semantic_score": None}, + ] + assert [entity["id"] for entity in first_run.context_pack["entities"]] == [ + str(entity_ids[1]), + str(entity_ids[2]), + ] + assert [edge["id"] for edge in first_run.context_pack["entity_edges"]] == [ + str(edge_ids[1]), + str(edge_ids[2]), + ] + assert first_run.context_pack["memory_summary"] == { + "candidate_count": 2, + "included_count": 2, + "excluded_deleted_count": 0, + "excluded_limit_count": 0, + "hybrid_retrieval": { + "requested": False, + "embedding_config_id": None, + "query_vector_dimensions": 0, + "semantic_limit": 0, + "symbolic_selected_count": 2, + "semantic_selected_count": 0, + "merged_candidate_count": 2, + "deduplicated_count": 0, + "included_symbolic_only_count": 2, + "included_semantic_only_count": 0, + "included_dual_source_count": 0, + "similarity_metric": None, + "source_precedence": ["symbolic", "semantic"], + "symbolic_order": ["updated_at_asc", "created_at_asc", "id_asc"], + "semantic_order": ["score_desc", "created_at_asc", "id_asc"], + }, + } + assert first_run.context_pack["artifact_chunks"] == [] + assert first_run.context_pack["artifact_chunk_summary"] == { + "requested": False, + "lexical_requested": False, + "semantic_requested": False, + "scope": None, + "query": None, + "query_terms": [], + "embedding_config_id": None, + "query_vector_dimensions": 0, + "limit": 0, + "lexical_limit": 0, + "semantic_limit": 0, + "searched_artifact_count": 0, + "lexical_candidate_count": 0, + "semantic_candidate_count": 0, + "merged_candidate_count": 0, + "deduplicated_count": 0, + "included_count": 0, + "included_lexical_only_count": 0, + "included_semantic_only_count": 0, + "included_dual_source_count": 0, + "excluded_uningested_artifact_count": 0, + "excluded_limit_count": 0, + "matching_rule": None, + "similarity_metric": None, + "source_precedence": ["lexical", "semantic"], + "lexical_order": [ + "matched_query_term_count_desc", + "first_match_char_start_asc", + "relative_path_asc", + "sequence_no_asc", + "id_asc", + ], + "semantic_order": ["score_desc", "relative_path_asc", "sequence_no_asc", "id_asc"], + "merged_order": [ + "source_precedence_asc", + "lexical_rank_asc", + "semantic_rank_asc", + "relative_path_asc", + "sequence_no_asc", + "id_asc", + ], + } + assert first_run.context_pack["entity_summary"] == { + "candidate_count": 3, + "included_count": 2, + "excluded_limit_count": 1, + } + assert first_run.context_pack["entity_edge_summary"] == { + "anchor_entity_count": 2, + "candidate_count": 3, + "included_count": 2, + "excluded_limit_count": 1, + } + + +def test_compile_continuity_context_records_included_and_excluded_reasons() -> None: + user_id = uuid4() + thread_id = uuid4() + base_time = datetime(2026, 3, 11, 9, 0, tzinfo=UTC) + kept_session_id = uuid4() + dropped_session_id = uuid4() + dropped_by_session_event_id = uuid4() + dropped_by_event_limit_id = uuid4() + kept_event_id = uuid4() + dropped_by_memory_limit_id = uuid4() + kept_memory_id = uuid4() + deleted_memory_id = uuid4() + dropped_entity_id = uuid4() + kept_entity_id = uuid4() + dropped_entity_edge_id = uuid4() + kept_entity_edge_id = uuid4() + ignored_entity_edge_id = uuid4() + external_entity_id = uuid4() + kept_edge_valid_from = base_time + timedelta(minutes=5) + + compiler_run = compile_continuity_context( + user={ + "id": user_id, + "email": "owner@example.com", + "display_name": "Owner", + "created_at": base_time, + }, + thread={ + "id": thread_id, + "user_id": user_id, + "title": "Traceable thread", + "created_at": base_time, + "updated_at": base_time, + }, + sessions=[ + { + "id": dropped_session_id, + "user_id": user_id, + "thread_id": thread_id, + "status": "done", + "started_at": base_time, + "ended_at": base_time, + "created_at": base_time, + }, + { + "id": kept_session_id, + "user_id": user_id, + "thread_id": thread_id, + "status": "active", + "started_at": base_time + timedelta(minutes=1), + "ended_at": None, + "created_at": base_time + timedelta(minutes=1), + }, + ], + events=[ + { + "id": dropped_by_session_event_id, + "user_id": user_id, + "thread_id": thread_id, + "session_id": dropped_session_id, + "sequence_no": 1, + "kind": "message.user", + "payload": {"text": "old session"}, + "created_at": base_time, + }, + { + "id": dropped_by_event_limit_id, + "user_id": user_id, + "thread_id": thread_id, + "session_id": kept_session_id, + "sequence_no": 2, + "kind": "message.assistant", + "payload": {"text": "too old"}, + "created_at": base_time + timedelta(minutes=1), + }, + { + "id": kept_event_id, + "user_id": user_id, + "thread_id": thread_id, + "session_id": kept_session_id, + "sequence_no": 3, + "kind": "message.user", + "payload": {"text": "keep"}, + "created_at": base_time + timedelta(minutes=2), + }, + ], + memories=[ + { + "id": dropped_by_memory_limit_id, + "user_id": user_id, + "memory_key": "user.preference.old", + "value": {"likes": "black"}, + "status": "active", + "source_event_ids": [str(dropped_by_session_event_id)], + "created_at": base_time, + "updated_at": base_time, + "deleted_at": None, + }, + { + "id": kept_memory_id, + "user_id": user_id, + "memory_key": "user.preference.keep", + "value": {"likes": "oat milk"}, + "status": "active", + "source_event_ids": [str(kept_event_id)], + "created_at": base_time + timedelta(minutes=1), + "updated_at": base_time + timedelta(minutes=2), + "deleted_at": None, + }, + { + "id": deleted_memory_id, + "user_id": user_id, + "memory_key": "user.preference.deleted", + "value": {"likes": "espresso"}, + "status": "deleted", + "source_event_ids": [str(dropped_by_event_limit_id)], + "created_at": base_time + timedelta(minutes=2), + "updated_at": base_time + timedelta(minutes=3), + "deleted_at": base_time + timedelta(minutes=3), + }, + ], + entities=[ + { + "id": dropped_entity_id, + "user_id": user_id, + "entity_type": "person", + "name": "Alex", + "source_memory_ids": [str(dropped_by_memory_limit_id)], + "created_at": base_time, + }, + { + "id": kept_entity_id, + "user_id": user_id, + "entity_type": "project", + "name": "AliceBot", + "source_memory_ids": [str(kept_memory_id)], + "created_at": base_time + timedelta(minutes=4), + }, + ], + entity_edges=[ + { + "id": dropped_entity_edge_id, + "user_id": user_id, + "from_entity_id": dropped_entity_id, + "to_entity_id": kept_entity_id, + "relationship_type": "related_to", + "valid_from": None, + "valid_to": None, + "source_memory_ids": [str(kept_memory_id)], + "created_at": base_time + timedelta(minutes=3), + }, + { + "id": kept_entity_edge_id, + "user_id": user_id, + "from_entity_id": kept_entity_id, + "to_entity_id": external_entity_id, + "relationship_type": "depends_on", + "valid_from": kept_edge_valid_from, + "valid_to": None, + "source_memory_ids": [str(kept_memory_id)], + "created_at": base_time + timedelta(minutes=5), + }, + { + "id": ignored_entity_edge_id, + "user_id": user_id, + "from_entity_id": dropped_entity_id, + "to_entity_id": external_entity_id, + "relationship_type": "ignored", + "valid_from": None, + "valid_to": None, + "source_memory_ids": [str(dropped_by_memory_limit_id)], + "created_at": base_time + timedelta(minutes=6), + }, + ], + limits=ContextCompilerLimits( + max_sessions=1, + max_events=1, + max_memories=1, + max_entities=1, + max_entity_edges=1, + ), + ) + + trace_payloads = [trace_event.payload for trace_event in compiler_run.trace_events] + + assert {"entity_type": "session", "entity_id": str(kept_session_id), "reason": "within_session_limit", "position": 1} in trace_payloads + assert {"entity_type": "session", "entity_id": str(dropped_session_id), "reason": "session_limit_exceeded", "position": 1} in trace_payloads + assert {"entity_type": "event", "entity_id": str(dropped_by_session_event_id), "reason": "session_not_included", "position": 1} in trace_payloads + assert {"entity_type": "event", "entity_id": str(dropped_by_event_limit_id), "reason": "event_limit_exceeded", "position": 2} in trace_payloads + assert {"entity_type": "event", "entity_id": str(kept_event_id), "reason": "within_event_limit", "position": 3} in trace_payloads + assert { + "entity_type": "memory", + "entity_id": str(kept_memory_id), + "reason": "within_hybrid_memory_limit", + "position": 1, + "memory_key": "user.preference.keep", + "status": "active", + "source_event_ids": [str(kept_event_id)], + "embedding_config_id": None, + "selected_sources": ["symbolic"], + "semantic_score": None, + } in trace_payloads + assert { + "entity_type": "memory", + "entity_id": str(deleted_memory_id), + "reason": "hybrid_memory_deleted", + "position": 1, + "memory_key": "user.preference.deleted", + "status": "deleted", + "source_event_ids": [str(dropped_by_event_limit_id)], + "embedding_config_id": None, + "selected_sources": ["symbolic"], + "semantic_score": None, + } in trace_payloads + assert { + "entity_type": "entity", + "entity_id": str(dropped_entity_id), + "reason": "entity_limit_exceeded", + "position": 1, + "record_entity_type": "person", + "name": "Alex", + "source_memory_ids": [str(dropped_by_memory_limit_id)], + } in trace_payloads + assert { + "entity_type": "entity", + "entity_id": str(kept_entity_id), + "reason": "within_entity_limit", + "position": 2, + "record_entity_type": "project", + "name": "AliceBot", + "source_memory_ids": [str(kept_memory_id)], + } in trace_payloads + assert { + "entity_type": "entity_edge", + "entity_id": str(dropped_entity_edge_id), + "reason": "entity_edge_limit_exceeded", + "position": 1, + "from_entity_id": str(dropped_entity_id), + "to_entity_id": str(kept_entity_id), + "relationship_type": "related_to", + "valid_from": None, + "valid_to": None, + "source_memory_ids": [str(kept_memory_id)], + "attached_included_entity_ids": [str(kept_entity_id)], + } in trace_payloads + assert { + "entity_type": "entity_edge", + "entity_id": str(kept_entity_edge_id), + "reason": "within_entity_edge_limit", + "position": 2, + "from_entity_id": str(kept_entity_id), + "to_entity_id": str(external_entity_id), + "relationship_type": "depends_on", + "valid_from": kept_edge_valid_from.isoformat(), + "valid_to": None, + "source_memory_ids": [str(kept_memory_id)], + "attached_included_entity_ids": [str(kept_entity_id)], + } in trace_payloads + assert all(payload.get("entity_id") != str(ignored_entity_edge_id) for payload in trace_payloads) + assert compiler_run.trace_events[-1].kind == SUMMARY_TRACE_EVENT_KIND + assert compiler_run.context_pack["events"][0]["id"] == str(kept_event_id) + assert compiler_run.context_pack["memories"] == [ + { + "id": str(kept_memory_id), + "memory_key": "user.preference.keep", + "value": {"likes": "oat milk"}, + "status": "active", + "source_event_ids": [str(kept_event_id)], + "created_at": (base_time + timedelta(minutes=1)).isoformat(), + "updated_at": (base_time + timedelta(minutes=2)).isoformat(), + "source_provenance": {"sources": ["symbolic"], "semantic_score": None}, + } + ] + assert compiler_run.context_pack["memory_summary"] == { + "candidate_count": 2, + "included_count": 1, + "excluded_deleted_count": 1, + "excluded_limit_count": 0, + "hybrid_retrieval": { + "requested": False, + "embedding_config_id": None, + "query_vector_dimensions": 0, + "semantic_limit": 0, + "symbolic_selected_count": 1, + "semantic_selected_count": 0, + "merged_candidate_count": 1, + "deduplicated_count": 0, + "included_symbolic_only_count": 1, + "included_semantic_only_count": 0, + "included_dual_source_count": 0, + "similarity_metric": None, + "source_precedence": ["symbolic", "semantic"], + "symbolic_order": ["updated_at_asc", "created_at_asc", "id_asc"], + "semantic_order": ["score_desc", "created_at_asc", "id_asc"], + }, + } + assert compiler_run.context_pack["artifact_chunks"] == [] + assert compiler_run.context_pack["artifact_chunk_summary"] == { + "requested": False, + "lexical_requested": False, + "semantic_requested": False, + "scope": None, + "query": None, + "query_terms": [], + "embedding_config_id": None, + "query_vector_dimensions": 0, + "limit": 0, + "lexical_limit": 0, + "semantic_limit": 0, + "searched_artifact_count": 0, + "lexical_candidate_count": 0, + "semantic_candidate_count": 0, + "merged_candidate_count": 0, + "deduplicated_count": 0, + "included_count": 0, + "included_lexical_only_count": 0, + "included_semantic_only_count": 0, + "included_dual_source_count": 0, + "excluded_uningested_artifact_count": 0, + "excluded_limit_count": 0, + "matching_rule": None, + "similarity_metric": None, + "source_precedence": ["lexical", "semantic"], + "lexical_order": [ + "matched_query_term_count_desc", + "first_match_char_start_asc", + "relative_path_asc", + "sequence_no_asc", + "id_asc", + ], + "semantic_order": ["score_desc", "relative_path_asc", "sequence_no_asc", "id_asc"], + "merged_order": [ + "source_precedence_asc", + "lexical_rank_asc", + "semantic_rank_asc", + "relative_path_asc", + "sequence_no_asc", + "id_asc", + ], + } + assert compiler_run.context_pack["entities"] == [ + { + "id": str(kept_entity_id), + "entity_type": "project", + "name": "AliceBot", + "source_memory_ids": [str(kept_memory_id)], + "created_at": (base_time + timedelta(minutes=4)).isoformat(), + } + ] + assert compiler_run.context_pack["entity_edges"] == [ + { + "id": str(kept_entity_edge_id), + "from_entity_id": str(kept_entity_id), + "to_entity_id": str(external_entity_id), + "relationship_type": "depends_on", + "valid_from": kept_edge_valid_from.isoformat(), + "valid_to": None, + "source_memory_ids": [str(kept_memory_id)], + "created_at": (base_time + timedelta(minutes=5)).isoformat(), + } + ] + assert compiler_run.context_pack["entity_edge_summary"] == { + "anchor_entity_count": 1, + "candidate_count": 2, + "included_count": 1, + "excluded_limit_count": 1, + } + assert compiler_run.trace_events[-1].payload["included_entity_edge_count"] == 1 + assert compiler_run.trace_events[-1].payload["excluded_entity_edge_limit_count"] == 1 + assert compiler_run.trace_events[-1].payload["hybrid_memory_requested"] is False + assert compiler_run.trace_events[-1].payload["hybrid_memory_candidate_count"] == 2 + assert compiler_run.trace_events[-1].payload["hybrid_memory_merged_candidate_count"] == 1 + assert compiler_run.trace_events[-1].payload["hybrid_memory_deduplicated_count"] == 0 + assert compiler_run.trace_events[-1].payload["artifact_retrieval_requested"] is False + assert compiler_run.trace_events[-1].payload["artifact_lexical_retrieval_requested"] is False + assert compiler_run.trace_events[-1].payload["artifact_semantic_retrieval_requested"] is False + assert compiler_run.trace_events[-1].payload["artifact_lexical_candidate_count"] == 0 + assert compiler_run.trace_events[-1].payload["artifact_semantic_candidate_count"] == 0 + assert compiler_run.trace_events[-1].payload["artifact_merged_candidate_count"] == 0 + assert compiler_run.trace_events[-1].payload["artifact_deduplicated_count"] == 0 + assert compiler_run.trace_events[-1].payload["included_artifact_chunk_count"] == 0 + assert compiler_run.trace_events[-1].payload["included_dual_source_artifact_chunk_count"] == 0 + assert compiler_run.trace_events[-1].payload["excluded_artifact_chunk_limit_count"] == 0 + assert compiler_run.trace_events[-1].payload["excluded_uningested_artifact_count"] == 0 + + +class SemanticCompileStoreStub: + def __init__(self) -> None: + self.base_time = datetime(2026, 3, 12, 12, 0, tzinfo=UTC) + self.config_id = uuid4() + self.memory_ids = [uuid4(), uuid4(), uuid4()] + self.event_ids = [uuid4(), uuid4(), uuid4()] + + def get_embedding_config_optional(self, embedding_config_id): + if embedding_config_id != self.config_id: + return None + return {"id": self.config_id, "dimensions": 3} + + def retrieve_semantic_memory_matches(self, *, embedding_config_id, query_vector, limit): + assert embedding_config_id == self.config_id + assert query_vector == [1.0, 0.0, 0.0] + assert limit > 1000 + return [ + { + "id": self.memory_ids[0], + "user_id": uuid4(), + "memory_key": "user.preference.breakfast", + "value": {"likes": "porridge"}, + "status": "active", + "source_event_ids": [str(self.event_ids[0])], + "created_at": self.base_time, + "updated_at": self.base_time, + "deleted_at": None, + "score": 1.0, + }, + { + "id": self.memory_ids[1], + "user_id": uuid4(), + "memory_key": "user.preference.lunch", + "value": {"likes": "ramen"}, + "status": "active", + "source_event_ids": [str(self.event_ids[1])], + "created_at": self.base_time + timedelta(minutes=1), + "updated_at": self.base_time + timedelta(minutes=1), + "deleted_at": None, + "score": 1.0, + }, + ] + + def list_memory_embeddings_for_config(self, embedding_config_id): + assert embedding_config_id == self.config_id + return [ + { + "id": uuid4(), + "user_id": uuid4(), + "memory_id": self.memory_ids[2], + "embedding_config_id": self.config_id, + "dimensions": 3, + "vector": [1.0, 0.0, 0.0], + "created_at": self.base_time + timedelta(minutes=2), + "updated_at": self.base_time + timedelta(minutes=2), + } + ] + + +class ArtifactCompileStoreStub: + def __init__(self) -> None: + self.base_time = datetime(2026, 3, 14, 12, 0, tzinfo=UTC) + self.config_id = uuid4() + self.task_id = uuid4() + self.artifact_ids = [uuid4(), uuid4(), uuid4(), uuid4()] + self.chunk_ids = [uuid4(), uuid4(), uuid4(), uuid4()] + + def get_embedding_config_optional(self, embedding_config_id): + if embedding_config_id != self.config_id: + return None + return {"id": self.config_id, "dimensions": 3} + + def get_task_optional(self, task_id): + if task_id != self.task_id: + return None + return {"id": self.task_id} + + def list_task_artifacts_for_task(self, task_id): + assert task_id == self.task_id + return [ + { + "id": self.artifact_ids[0], + "task_id": self.task_id, + "task_workspace_id": uuid4(), + "status": "registered", + "ingestion_status": "ingested", + "relative_path": "docs/a.txt", + "media_type_hint": "text/plain", + "created_at": self.base_time, + "updated_at": self.base_time, + }, + { + "id": self.artifact_ids[1], + "task_id": self.task_id, + "task_workspace_id": uuid4(), + "status": "registered", + "ingestion_status": "ingested", + "relative_path": "notes/b.md", + "media_type_hint": "text/markdown", + "created_at": self.base_time + timedelta(minutes=1), + "updated_at": self.base_time + timedelta(minutes=1), + }, + { + "id": self.artifact_ids[2], + "task_id": self.task_id, + "task_workspace_id": uuid4(), + "status": "registered", + "ingestion_status": "pending", + "relative_path": "notes/hidden.txt", + "media_type_hint": "text/plain", + "created_at": self.base_time + timedelta(minutes=2), + "updated_at": self.base_time + timedelta(minutes=2), + }, + { + "id": self.artifact_ids[3], + "task_id": self.task_id, + "task_workspace_id": uuid4(), + "status": "registered", + "ingestion_status": "ingested", + "relative_path": "notes/c.txt", + "media_type_hint": "text/plain", + "created_at": self.base_time + timedelta(minutes=3), + "updated_at": self.base_time + timedelta(minutes=3), + }, + ] + + def list_task_artifact_chunks(self, task_artifact_id): + if task_artifact_id == self.artifact_ids[0]: + return [ + { + "id": self.chunk_ids[0], + "task_artifact_id": task_artifact_id, + "sequence_no": 1, + "char_start": 0, + "char_end_exclusive": 14, + "text": "beta alpha doc", + "created_at": self.base_time, + "updated_at": self.base_time, + } + ] + if task_artifact_id == self.artifact_ids[1]: + return [ + { + "id": self.chunk_ids[1], + "task_artifact_id": task_artifact_id, + "sequence_no": 1, + "char_start": 0, + "char_end_exclusive": 15, + "text": "alpha beta note", + "created_at": self.base_time, + "updated_at": self.base_time, + } + ] + if task_artifact_id == self.artifact_ids[3]: + return [ + { + "id": self.chunk_ids[2], + "task_artifact_id": task_artifact_id, + "sequence_no": 1, + "char_start": 0, + "char_end_exclusive": 9, + "text": "beta only", + "created_at": self.base_time, + "updated_at": self.base_time, + } + ] + return [] + + def get_task_artifact_optional(self, task_artifact_id): + for artifact_row in self.list_task_artifacts_for_task(self.task_id): + if artifact_row["id"] == task_artifact_id: + return artifact_row + return None + + def retrieve_task_scoped_semantic_artifact_chunk_matches( + self, + *, + task_id, + embedding_config_id, + query_vector, + limit, + ): + assert task_id == self.task_id + assert embedding_config_id == self.config_id + assert query_vector == [1.0, 0.0, 0.0] + rows = [ + { + "id": self.chunk_ids[0], + "user_id": uuid4(), + "task_id": self.task_id, + "task_artifact_id": self.artifact_ids[0], + "relative_path": "docs/a.txt", + "media_type_hint": "text/plain", + "sequence_no": 1, + "char_start": 0, + "char_end_exclusive": 14, + "text": "beta alpha doc", + "created_at": self.base_time, + "updated_at": self.base_time, + "embedding_config_id": self.config_id, + "score": 1.0, + }, + { + "id": self.chunk_ids[1], + "user_id": uuid4(), + "task_id": self.task_id, + "task_artifact_id": self.artifact_ids[1], + "relative_path": "notes/b.md", + "media_type_hint": "text/markdown", + "sequence_no": 1, + "char_start": 0, + "char_end_exclusive": 15, + "text": "alpha beta note", + "created_at": self.base_time + timedelta(minutes=1), + "updated_at": self.base_time + timedelta(minutes=1), + "embedding_config_id": self.config_id, + "score": 1.0, + }, + { + "id": self.chunk_ids[3], + "user_id": uuid4(), + "task_id": self.task_id, + "task_artifact_id": self.artifact_ids[3], + "relative_path": "notes/c.txt", + "media_type_hint": "text/plain", + "sequence_no": 1, + "char_start": 0, + "char_end_exclusive": 9, + "text": "beta only", + "created_at": self.base_time + timedelta(minutes=3), + "updated_at": self.base_time + timedelta(minutes=3), + "embedding_config_id": self.config_id, + "score": 0.25, + }, + ] + return list(rows[:limit]) + + def retrieve_artifact_scoped_semantic_artifact_chunk_matches( + self, + *, + task_artifact_id, + embedding_config_id, + query_vector, + limit, + ): + assert embedding_config_id == self.config_id + assert query_vector == [1.0, 0.0, 0.0] + rows = [ + row + for row in self.retrieve_task_scoped_semantic_artifact_chunk_matches( + task_id=self.task_id, + embedding_config_id=embedding_config_id, + query_vector=query_vector, + limit=10, + ) + if row["task_artifact_id"] == task_artifact_id + ] + return list(rows[:limit]) + + +def test_compile_artifact_chunk_section_orders_limits_and_excludes_non_ingested() -> None: + store = ArtifactCompileStoreStub() + + artifact_section = _compile_artifact_chunk_section( + store, # type: ignore[arg-type] + artifact_retrieval=CompileContextTaskScopedArtifactRetrievalInput( + task_id=store.task_id, + query="Alpha beta", + limit=2, + ), + semantic_artifact_retrieval=None, + ) + + assert artifact_section.items == [ + { + "id": str(store.chunk_ids[0]), + "task_id": str(store.task_id), + "task_artifact_id": str(store.artifact_ids[0]), + "relative_path": "docs/a.txt", + "media_type": "text/plain", + "sequence_no": 1, + "char_start": 0, + "char_end_exclusive": 14, + "text": "beta alpha doc", + "source_provenance": { + "sources": ["lexical"], + "lexical_match": { + "matched_query_terms": ["alpha", "beta"], + "matched_query_term_count": 2, + "first_match_char_start": 0, + }, + "semantic_score": None, + }, + }, + { + "id": str(store.chunk_ids[1]), + "task_id": str(store.task_id), + "task_artifact_id": str(store.artifact_ids[1]), + "relative_path": "notes/b.md", + "media_type": "text/markdown", + "sequence_no": 1, + "char_start": 0, + "char_end_exclusive": 15, + "text": "alpha beta note", + "source_provenance": { + "sources": ["lexical"], + "lexical_match": { + "matched_query_terms": ["alpha", "beta"], + "matched_query_term_count": 2, + "first_match_char_start": 0, + }, + "semantic_score": None, + }, + }, + ] + assert artifact_section.summary == { + "requested": True, + "lexical_requested": True, + "semantic_requested": False, + "scope": {"kind": "task", "task_id": str(store.task_id)}, + "query": "Alpha beta", + "query_terms": ["alpha", "beta"], + "embedding_config_id": None, + "query_vector_dimensions": 0, + "limit": 2, + "lexical_limit": 2, + "semantic_limit": 0, + "searched_artifact_count": 3, + "lexical_candidate_count": 3, + "semantic_candidate_count": 0, + "merged_candidate_count": 3, + "deduplicated_count": 0, + "included_count": 2, + "included_lexical_only_count": 2, + "included_semantic_only_count": 0, + "included_dual_source_count": 0, + "excluded_uningested_artifact_count": 1, + "excluded_limit_count": 1, + "matching_rule": "casefolded_unicode_word_overlap_unique_query_terms_v1", + "similarity_metric": None, + "source_precedence": ["lexical", "semantic"], + "lexical_order": [ + "matched_query_term_count_desc", + "first_match_char_start_asc", + "relative_path_asc", + "sequence_no_asc", + "id_asc", + ], + "semantic_order": ["score_desc", "relative_path_asc", "sequence_no_asc", "id_asc"], + "merged_order": [ + "source_precedence_asc", + "lexical_rank_asc", + "semantic_rank_asc", + "relative_path_asc", + "sequence_no_asc", + "id_asc", + ], + } + assert [decision.reason for decision in artifact_section.decisions] == [ + "hybrid_artifact_not_ingested", + "within_hybrid_artifact_chunk_limit", + "within_hybrid_artifact_chunk_limit", + "hybrid_artifact_chunk_limit_exceeded", + ] + assert artifact_section.decisions[0].metadata["relative_path"] == "notes/hidden.txt" + assert artifact_section.decisions[-1].metadata["relative_path"] == "notes/c.txt" + + +def test_compile_artifact_chunk_section_supports_semantic_only_scope() -> None: + store = ArtifactCompileStoreStub() + + artifact_section = _compile_artifact_chunk_section( + store, # type: ignore[arg-type] + artifact_retrieval=None, + semantic_artifact_retrieval=CompileContextTaskScopedSemanticArtifactRetrievalInput( + task_id=store.task_id, + embedding_config_id=store.config_id, + query_vector=(1.0, 0.0, 0.0), + limit=2, + ), + ) + + assert artifact_section.items == [ + { + "id": str(store.chunk_ids[0]), + "task_id": str(store.task_id), + "task_artifact_id": str(store.artifact_ids[0]), + "relative_path": "docs/a.txt", + "media_type": "text/plain", + "sequence_no": 1, + "char_start": 0, + "char_end_exclusive": 14, + "text": "beta alpha doc", + "source_provenance": { + "sources": ["semantic"], + "lexical_match": None, + "semantic_score": 1.0, + }, + }, + { + "id": str(store.chunk_ids[1]), + "task_id": str(store.task_id), + "task_artifact_id": str(store.artifact_ids[1]), + "relative_path": "notes/b.md", + "media_type": "text/markdown", + "sequence_no": 1, + "char_start": 0, + "char_end_exclusive": 15, + "text": "alpha beta note", + "source_provenance": { + "sources": ["semantic"], + "lexical_match": None, + "semantic_score": 1.0, + }, + }, + ] + assert artifact_section.summary == { + "requested": True, + "lexical_requested": False, + "semantic_requested": True, + "scope": {"kind": "task", "task_id": str(store.task_id)}, + "query": None, + "query_terms": [], + "embedding_config_id": str(store.config_id), + "query_vector_dimensions": 3, + "limit": 2, + "lexical_limit": 0, + "semantic_limit": 2, + "searched_artifact_count": 3, + "lexical_candidate_count": 0, + "semantic_candidate_count": 3, + "merged_candidate_count": 3, + "deduplicated_count": 0, + "included_count": 2, + "included_lexical_only_count": 0, + "included_semantic_only_count": 2, + "included_dual_source_count": 0, + "excluded_uningested_artifact_count": 1, + "excluded_limit_count": 1, + "matching_rule": None, + "similarity_metric": "cosine_similarity", + "source_precedence": ["lexical", "semantic"], + "lexical_order": [ + "matched_query_term_count_desc", + "first_match_char_start_asc", + "relative_path_asc", + "sequence_no_asc", + "id_asc", + ], + "semantic_order": ["score_desc", "relative_path_asc", "sequence_no_asc", "id_asc"], + "merged_order": [ + "source_precedence_asc", + "lexical_rank_asc", + "semantic_rank_asc", + "relative_path_asc", + "sequence_no_asc", + "id_asc", + ], + } + assert [decision.reason for decision in artifact_section.decisions] == [ + "hybrid_artifact_not_ingested", + "within_hybrid_artifact_chunk_limit", + "within_hybrid_artifact_chunk_limit", + "hybrid_artifact_chunk_limit_exceeded", + ] + assert artifact_section.decisions[0].metadata["relative_path"] == "notes/hidden.txt" + assert artifact_section.decisions[-1].metadata["relative_path"] == "notes/c.txt" + + +def test_compile_artifact_chunk_section_merges_dual_source_provenance_for_artifact_scope() -> None: + store = ArtifactCompileStoreStub() + + artifact_section = _compile_artifact_chunk_section( + store, # type: ignore[arg-type] + artifact_retrieval=CompileContextArtifactScopedArtifactRetrievalInput( + task_artifact_id=store.artifact_ids[1], + query="Alpha beta", + limit=2, + ), + semantic_artifact_retrieval=CompileContextArtifactScopedSemanticArtifactRetrievalInput( + task_artifact_id=store.artifact_ids[1], + embedding_config_id=store.config_id, + query_vector=(1.0, 0.0, 0.0), + limit=2, + ), + ) + + assert artifact_section.items == [ + { + "id": str(store.chunk_ids[1]), + "task_id": str(store.task_id), + "task_artifact_id": str(store.artifact_ids[1]), + "relative_path": "notes/b.md", + "media_type": "text/markdown", + "sequence_no": 1, + "char_start": 0, + "char_end_exclusive": 15, + "text": "alpha beta note", + "source_provenance": { + "sources": ["lexical", "semantic"], + "lexical_match": { + "matched_query_terms": ["alpha", "beta"], + "matched_query_term_count": 2, + "first_match_char_start": 0, + }, + "semantic_score": 1.0, + }, + } + ] + assert artifact_section.summary == { + "requested": True, + "lexical_requested": True, + "semantic_requested": True, + "scope": { + "kind": "artifact", + "task_id": str(store.task_id), + "task_artifact_id": str(store.artifact_ids[1]), + }, + "query": "Alpha beta", + "query_terms": ["alpha", "beta"], + "embedding_config_id": str(store.config_id), + "query_vector_dimensions": 3, + "limit": 2, + "lexical_limit": 2, + "semantic_limit": 2, + "searched_artifact_count": 1, + "lexical_candidate_count": 1, + "semantic_candidate_count": 1, + "merged_candidate_count": 1, + "deduplicated_count": 1, + "included_count": 1, + "included_lexical_only_count": 0, + "included_semantic_only_count": 0, + "included_dual_source_count": 1, + "excluded_uningested_artifact_count": 0, + "excluded_limit_count": 0, + "matching_rule": "casefolded_unicode_word_overlap_unique_query_terms_v1", + "similarity_metric": "cosine_similarity", + "source_precedence": ["lexical", "semantic"], + "lexical_order": [ + "matched_query_term_count_desc", + "first_match_char_start_asc", + "relative_path_asc", + "sequence_no_asc", + "id_asc", + ], + "semantic_order": ["score_desc", "relative_path_asc", "sequence_no_asc", "id_asc"], + "merged_order": [ + "source_precedence_asc", + "lexical_rank_asc", + "semantic_rank_asc", + "relative_path_asc", + "sequence_no_asc", + "id_asc", + ], + } + assert [decision.reason for decision in artifact_section.decisions] == [ + "hybrid_artifact_chunk_deduplicated", + "within_hybrid_artifact_chunk_limit", + ] + assert artifact_section.decisions[1].metadata["selected_sources"] == ["lexical", "semantic"] + + +def test_compile_memory_section_orders_limits_and_excludes_deleted() -> None: + store = SemanticCompileStoreStub() + deleted_memory = { + "id": store.memory_ids[2], + "user_id": uuid4(), + "memory_key": "user.preference.deleted", + "value": {"likes": "hidden"}, + "status": "deleted", + "source_event_ids": [str(store.event_ids[2])], + "created_at": store.base_time + timedelta(minutes=2), + "updated_at": store.base_time + timedelta(minutes=3), + "deleted_at": store.base_time + timedelta(minutes=3), + } + + memory_section = _compile_memory_section( + store, # type: ignore[arg-type] + memories=[deleted_memory], + agent_profile_id="assistant_default", + limits=ContextCompilerLimits(max_memories=1), + semantic_retrieval=CompileContextSemanticRetrievalInput( + embedding_config_id=store.config_id, + query_vector=(1.0, 0.0, 0.0), + limit=1, + ), + ) + + assert memory_section.items == [ + { + "id": str(store.memory_ids[0]), + "memory_key": "user.preference.breakfast", + "value": {"likes": "porridge"}, + "status": "active", + "source_event_ids": [str(store.event_ids[0])], + "created_at": store.base_time.isoformat(), + "updated_at": store.base_time.isoformat(), + "source_provenance": { + "sources": ["semantic"], + "semantic_score": 1.0, + }, + } + ] + assert memory_section.summary == { + "candidate_count": 2, + "included_count": 1, + "excluded_deleted_count": 1, + "excluded_limit_count": 0, + "hybrid_retrieval": { + "requested": True, + "embedding_config_id": str(store.config_id), + "query_vector_dimensions": 3, + "semantic_limit": 1, + "symbolic_selected_count": 0, + "semantic_selected_count": 1, + "merged_candidate_count": 1, + "deduplicated_count": 0, + "included_symbolic_only_count": 0, + "included_semantic_only_count": 1, + "included_dual_source_count": 0, + "similarity_metric": "cosine_similarity", + "source_precedence": ["symbolic", "semantic"], + "symbolic_order": ["updated_at_asc", "created_at_asc", "id_asc"], + "semantic_order": ["score_desc", "created_at_asc", "id_asc"], + }, + } + assert [decision.reason for decision in memory_section.decisions] == [ + "within_hybrid_memory_limit", + "hybrid_memory_deleted", + ] + assert memory_section.decisions[-1].metadata["selected_sources"] == ["symbolic"] + + +def test_compile_continuity_context_includes_open_loops_when_present() -> None: + user_id = uuid4() + thread_id = uuid4() + base_time = datetime(2026, 3, 23, 9, 0, tzinfo=UTC) + newer_open_loop_id = uuid4() + older_open_loop_id = uuid4() + + compiler_run = compile_continuity_context( + user={ + "id": user_id, + "email": "owner@example.com", + "display_name": "Owner", + "created_at": base_time, + }, + thread={ + "id": thread_id, + "user_id": user_id, + "title": "Open-loop context", + "created_at": base_time, + "updated_at": base_time, + }, + sessions=[], + events=[], + memories=[], + entities=[], + entity_edges=[], + limits=ContextCompilerLimits( + max_sessions=1, + max_events=1, + max_memories=1, + max_entities=1, + max_entity_edges=1, + ), + open_loops=[ + { + "id": older_open_loop_id, + "user_id": user_id, + "memory_id": None, + "title": "Older open loop", + "status": "open", + "opened_at": base_time, + "due_at": None, + "resolved_at": None, + "resolution_note": None, + "created_at": base_time, + "updated_at": base_time, + }, + { + "id": newer_open_loop_id, + "user_id": user_id, + "memory_id": None, + "title": "Newer open loop", + "status": "open", + "opened_at": base_time + timedelta(minutes=2), + "due_at": None, + "resolved_at": None, + "resolution_note": None, + "created_at": base_time + timedelta(minutes=2), + "updated_at": base_time + timedelta(minutes=2), + }, + ], + ) + + assert compiler_run.context_pack["open_loops"] == [ + { + "id": str(newer_open_loop_id), + "memory_id": None, + "title": "Newer open loop", + "status": "open", + "opened_at": (base_time + timedelta(minutes=2)).isoformat(), + "due_at": None, + "resolved_at": None, + "resolution_note": None, + "created_at": (base_time + timedelta(minutes=2)).isoformat(), + "updated_at": (base_time + timedelta(minutes=2)).isoformat(), + } + ] + assert compiler_run.context_pack["open_loop_summary"] == { + "candidate_count": 2, + "included_count": 1, + "excluded_limit_count": 1, + "order": ["opened_at_desc", "created_at_desc", "id_desc"], + } + reasons = [event.payload["reason"] for event in compiler_run.trace_events if "reason" in event.payload] + assert "within_open_loop_limit" in reasons + assert "open_loop_limit_exceeded" in reasons + + +class ResumptionBriefStoreStub: + def __init__(self) -> None: + self.base_time = datetime(2026, 3, 23, 10, 0, tzinfo=UTC) + self.thread_id = uuid4() + self.other_thread_id = uuid4() + self.latest_task_id = uuid4() + self.latest_step_trace_id = uuid4() + + def list_thread_events(self, thread_id): + if thread_id != self.thread_id: + return [] + return [ + { + "id": uuid4(), + "user_id": uuid4(), + "thread_id": self.thread_id, + "session_id": uuid4(), + "sequence_no": 1, + "kind": "message.user", + "payload": {"text": "first"}, + "created_at": self.base_time, + }, + { + "id": uuid4(), + "user_id": uuid4(), + "thread_id": self.thread_id, + "session_id": uuid4(), + "sequence_no": 2, + "kind": "approval.request", + "payload": {"approval_id": "approval-1"}, + "created_at": self.base_time + timedelta(minutes=1), + }, + { + "id": uuid4(), + "user_id": uuid4(), + "thread_id": self.thread_id, + "session_id": uuid4(), + "sequence_no": 3, + "kind": "message.assistant", + "payload": {"text": "second"}, + "created_at": self.base_time + timedelta(minutes=2), + }, + ] + + def list_open_loops(self, *, status=None, limit=None): + assert status == "open" + assert limit is None + return [ + { + "id": uuid4(), + "user_id": uuid4(), + "memory_id": None, + "title": "Latest open loop", + "status": "open", + "opened_at": self.base_time + timedelta(minutes=3), + "due_at": None, + "resolved_at": None, + "resolution_note": None, + "created_at": self.base_time + timedelta(minutes=3), + "updated_at": self.base_time + timedelta(minutes=3), + }, + { + "id": uuid4(), + "user_id": uuid4(), + "memory_id": None, + "title": "Older open loop", + "status": "open", + "opened_at": self.base_time + timedelta(minutes=1), + "due_at": None, + "resolved_at": None, + "resolution_note": None, + "created_at": self.base_time + timedelta(minutes=1), + "updated_at": self.base_time + timedelta(minutes=1), + }, + ] + + def list_context_memories(self): + return [ + { + "id": uuid4(), + "user_id": uuid4(), + "memory_key": "user.preference.older", + "value": {"likes": "tea"}, + "status": "active", + "source_event_ids": [], + "memory_type": "preference", + "confidence": None, + "salience": None, + "confirmation_status": "unconfirmed", + "valid_from": None, + "valid_to": None, + "last_confirmed_at": None, + "created_at": self.base_time, + "updated_at": self.base_time, + "deleted_at": None, + }, + { + "id": uuid4(), + "user_id": uuid4(), + "memory_key": "user.preference.deleted", + "value": {"likes": "espresso"}, + "status": "deleted", + "source_event_ids": [], + "memory_type": "preference", + "confidence": None, + "salience": None, + "confirmation_status": "unconfirmed", + "valid_from": None, + "valid_to": None, + "last_confirmed_at": None, + "created_at": self.base_time + timedelta(minutes=1), + "updated_at": self.base_time + timedelta(minutes=1), + "deleted_at": self.base_time + timedelta(minutes=1), + }, + { + "id": uuid4(), + "user_id": uuid4(), + "memory_key": "user.preference.latest", + "value": {"likes": "oat milk"}, + "status": "active", + "source_event_ids": [], + "memory_type": "preference", + "confidence": None, + "salience": None, + "confirmation_status": "unconfirmed", + "valid_from": None, + "valid_to": None, + "last_confirmed_at": None, + "created_at": self.base_time + timedelta(minutes=2), + "updated_at": self.base_time + timedelta(minutes=2), + "deleted_at": None, + }, + ] + + def list_tasks(self): + return [ + { + "id": uuid4(), + "user_id": uuid4(), + "thread_id": self.other_thread_id, + "tool_id": uuid4(), + "status": "approved", + "request": { + "thread_id": str(self.other_thread_id), + "tool_id": "tool-1", + "action": "tool.run", + "scope": "workspace", + "domain_hint": None, + "risk_hint": None, + "attributes": {}, + }, + "tool": { + "id": "tool-1", + "tool_key": "proxy.echo", + "name": "Proxy Echo", + "description": "Deterministic proxy handler.", + "version": "1.0.0", + "metadata_version": "tool_metadata_v0", + "active": True, + "tags": [], + "action_hints": [], + "scope_hints": [], + "domain_hints": [], + "risk_hints": [], + "metadata": {}, + "created_at": self.base_time.isoformat(), + }, + "latest_approval_id": None, + "latest_execution_id": None, + "created_at": self.base_time, + "updated_at": self.base_time, + }, + { + "id": self.latest_task_id, + "user_id": uuid4(), + "thread_id": self.thread_id, + "tool_id": uuid4(), + "status": "approved", + "request": { + "thread_id": str(self.thread_id), + "tool_id": "tool-2", + "action": "tool.run", + "scope": "workspace", + "domain_hint": None, + "risk_hint": None, + "attributes": {"task": "current"}, + }, + "tool": { + "id": "tool-2", + "tool_key": "proxy.echo", + "name": "Proxy Echo", + "description": "Deterministic proxy handler.", + "version": "1.0.0", + "metadata_version": "tool_metadata_v0", + "active": True, + "tags": [], + "action_hints": [], + "scope_hints": [], + "domain_hints": [], + "risk_hints": [], + "metadata": {}, + "created_at": self.base_time.isoformat(), + }, + "latest_approval_id": None, + "latest_execution_id": None, + "created_at": self.base_time + timedelta(minutes=2), + "updated_at": self.base_time + timedelta(minutes=2), + }, + ] + + def list_task_steps_for_task(self, task_id): + if task_id != self.latest_task_id: + return [] + return [ + { + "id": uuid4(), + "user_id": uuid4(), + "task_id": self.latest_task_id, + "sequence_no": 1, + "parent_step_id": None, + "source_approval_id": None, + "source_execution_id": None, + "kind": "governed_request", + "status": "approved", + "request": { + "thread_id": str(self.thread_id), + "tool_id": "tool-2", + "action": "tool.run", + "scope": "workspace", + "domain_hint": None, + "risk_hint": None, + "attributes": {"task": "current"}, + }, + "outcome": { + "routing_decision": "ready", + "approval_id": None, + "approval_status": None, + "execution_id": None, + "execution_status": None, + "blocked_reason": None, + }, + "trace_id": uuid4(), + "trace_kind": "task.step.sequence", + "created_at": self.base_time + timedelta(minutes=2), + "updated_at": self.base_time + timedelta(minutes=2), + }, + { + "id": uuid4(), + "user_id": uuid4(), + "task_id": self.latest_task_id, + "sequence_no": 2, + "parent_step_id": None, + "source_approval_id": None, + "source_execution_id": None, + "kind": "governed_request", + "status": "executed", + "request": { + "thread_id": str(self.thread_id), + "tool_id": "tool-2", + "action": "tool.run", + "scope": "workspace", + "domain_hint": None, + "risk_hint": None, + "attributes": {"task": "current"}, + }, + "outcome": { + "routing_decision": "ready", + "approval_id": None, + "approval_status": None, + "execution_id": "execution-1", + "execution_status": "completed", + "blocked_reason": None, + }, + "trace_id": self.latest_step_trace_id, + "trace_kind": "task.step.transition", + "created_at": self.base_time + timedelta(minutes=3), + "updated_at": self.base_time + timedelta(minutes=3), + }, + ] + + +def test_compile_resumption_brief_builds_deterministic_bounded_sections() -> None: + store = ResumptionBriefStoreStub() + thread = { + "id": store.thread_id, + "user_id": uuid4(), + "title": "Resumption thread", + "created_at": store.base_time, + "updated_at": store.base_time + timedelta(minutes=3), + } + + brief = compile_resumption_brief( + store, + thread=thread, + event_limit=1, + open_loop_limit=1, + memory_limit=1, + ) + + assert brief["assembly_version"] == "resumption_brief_v0" + assert brief["conversation"]["summary"] == { + "limit": 1, + "returned_count": 1, + "total_count": 2, + "order": ["sequence_no_asc"], + "kinds": ["message.user", "message.assistant"], + } + assert [event["sequence_no"] for event in brief["conversation"]["items"]] == [3] + assert brief["open_loops"]["summary"] == { + "limit": 1, + "returned_count": 1, + "total_count": 2, + "order": ["opened_at_desc", "created_at_desc", "id_desc"], + } + assert [item["title"] for item in brief["open_loops"]["items"]] == ["Latest open loop"] + assert brief["memory_highlights"]["summary"] == { + "limit": 1, + "returned_count": 1, + "total_count": 2, + "order": ["updated_at_asc", "created_at_asc", "id_asc"], + } + assert [item["memory_key"] for item in brief["memory_highlights"]["items"]] == [ + "user.preference.latest" + ] + assert brief["workflow"] is not None + assert brief["workflow"]["task"]["id"] == str(store.latest_task_id) + assert brief["workflow"]["latest_task_step"] is not None + assert brief["workflow"]["latest_task_step"]["sequence_no"] == 2 + assert brief["workflow"]["latest_task_step"]["trace"]["trace_id"] == str( + store.latest_step_trace_id + ) + assert brief["workflow"]["summary"] == { + "present": True, + "task_order": ["created_at_asc", "id_asc"], + "task_step_order": ["sequence_no_asc", "created_at_asc", "id_asc"], + } + assert brief["sources"] == ["threads", "events", "open_loops", "memories", "tasks", "task_steps"] + + +def test_compile_resumption_brief_supports_zero_limits_and_missing_workflow() -> None: + store = ResumptionBriefStoreStub() + store.list_tasks = lambda: [] # type: ignore[method-assign] + thread = { + "id": store.thread_id, + "user_id": uuid4(), + "title": "Resumption thread", + "created_at": store.base_time, + "updated_at": store.base_time + timedelta(minutes=3), + } + + brief = compile_resumption_brief( + store, + thread=thread, + event_limit=0, + open_loop_limit=0, + memory_limit=0, + ) + + assert brief["conversation"]["items"] == [] + assert brief["open_loops"]["items"] == [] + assert brief["memory_highlights"]["items"] == [] + assert brief["conversation"]["summary"]["limit"] == 0 + assert brief["open_loops"]["summary"]["limit"] == 0 + assert brief["memory_highlights"]["summary"]["limit"] == 0 + assert brief["workflow"] is None + assert brief["sources"] == ["threads", "events", "open_loops", "memories"] diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py new file mode 100644 index 0000000..34b1697 --- /dev/null +++ b/tests/unit/test_config.py @@ -0,0 +1,484 @@ +from __future__ import annotations + +import pytest + +from alicebot_api.config import Settings + + +def test_settings_defaults(monkeypatch): + for key in ( + "APP_ENV", + "APP_HOST", + "APP_PORT", + "DATABASE_URL", + "DATABASE_ADMIN_URL", + "REDIS_URL", + "S3_ENDPOINT_URL", + "S3_ACCESS_KEY", + "S3_SECRET_KEY", + "S3_BUCKET", + "HEALTHCHECK_TIMEOUT_SECONDS", + "MODEL_PROVIDER", + "MODEL_BASE_URL", + "MODEL_NAME", + "MODEL_API_KEY", + "MODEL_TIMEOUT_SECONDS", + "TASK_WORKSPACE_ROOT", + "GMAIL_SECRET_MANAGER_URL", + "CALENDAR_SECRET_MANAGER_URL", + "ALICEBOT_AUTH_USER_ID", + "RESPONSE_RATE_LIMIT_WINDOW_SECONDS", + "RESPONSE_RATE_LIMIT_MAX_REQUESTS", + "TELEGRAM_LINK_TTL_SECONDS", + "TELEGRAM_BOT_USERNAME", + "TELEGRAM_WEBHOOK_SECRET", + "TELEGRAM_BOT_TOKEN", + "HOSTED_CHAT_RATE_LIMIT_WINDOW_SECONDS", + "HOSTED_CHAT_RATE_LIMIT_MAX_REQUESTS", + "HOSTED_SCHEDULER_RATE_LIMIT_WINDOW_SECONDS", + "HOSTED_SCHEDULER_RATE_LIMIT_MAX_REQUESTS", + "HOSTED_ABUSE_WINDOW_SECONDS", + "HOSTED_ABUSE_BLOCK_THRESHOLD", + "HOSTED_RATE_LIMITS_ENABLED_BY_DEFAULT", + "HOSTED_ABUSE_CONTROLS_ENABLED_BY_DEFAULT", + "MAGIC_LINK_START_RATE_LIMIT_WINDOW_SECONDS", + "MAGIC_LINK_START_RATE_LIMIT_MAX_REQUESTS", + "MAGIC_LINK_VERIFY_RATE_LIMIT_WINDOW_SECONDS", + "MAGIC_LINK_VERIFY_RATE_LIMIT_MAX_REQUESTS", + "TELEGRAM_WEBHOOK_RATE_LIMIT_WINDOW_SECONDS", + "TELEGRAM_WEBHOOK_RATE_LIMIT_MAX_REQUESTS", + "CORS_ALLOWED_ORIGINS", + "CORS_ALLOWED_METHODS", + "CORS_ALLOWED_HEADERS", + "CORS_ALLOW_CREDENTIALS", + "CORS_PREFLIGHT_MAX_AGE_SECONDS", + "SECURITY_HEADERS_ENABLED", + "SECURITY_HEADERS_HSTS_MAX_AGE_SECONDS", + "SECURITY_HEADERS_HSTS_INCLUDE_SUBDOMAINS", + "TRUST_PROXY_HEADERS", + "TRUSTED_PROXY_IPS", + "ENTRYPOINT_RATE_LIMIT_BACKEND", + ): + monkeypatch.delenv(key, raising=False) + + settings = Settings.from_env() + + assert settings.app_env == "development" + assert settings.app_port == 8000 + assert settings.database_url.endswith("/alicebot") + assert settings.database_admin_url.endswith("/alicebot") + assert settings.s3_bucket == "alicebot-local" + assert settings.model_provider == "openai_responses" + assert settings.model_base_url == "https://api.openai.com/v1" + assert settings.model_name == "gpt-5-mini" + assert settings.model_timeout_seconds == 30 + assert settings.task_workspace_root == "/tmp/alicebot/task-workspaces" + assert settings.gmail_secret_manager_url == "" + assert settings.calendar_secret_manager_url == "" + assert settings.auth_user_id == "" + assert settings.response_rate_limit_window_seconds == 60 + assert settings.response_rate_limit_max_requests == 20 + assert settings.telegram_link_ttl_seconds == 600 + assert settings.telegram_bot_username == "alicebot" + assert settings.telegram_webhook_secret == "" + assert settings.telegram_bot_token == "" + assert settings.hosted_chat_rate_limit_window_seconds == 60 + assert settings.hosted_chat_rate_limit_max_requests == 20 + assert settings.hosted_scheduler_rate_limit_window_seconds == 300 + assert settings.hosted_scheduler_rate_limit_max_requests == 20 + assert settings.hosted_abuse_window_seconds == 600 + assert settings.hosted_abuse_block_threshold == 5 + assert settings.hosted_rate_limits_enabled_by_default is True + assert settings.hosted_abuse_controls_enabled_by_default is True + assert settings.magic_link_start_rate_limit_window_seconds == 300 + assert settings.magic_link_start_rate_limit_max_requests == 5 + assert settings.magic_link_verify_rate_limit_window_seconds == 300 + assert settings.magic_link_verify_rate_limit_max_requests == 10 + assert settings.telegram_webhook_rate_limit_window_seconds == 60 + assert settings.telegram_webhook_rate_limit_max_requests == 120 + assert settings.cors_allowed_origins == () + assert settings.cors_allowed_methods == ("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") + assert settings.cors_allowed_headers == ( + "Authorization", + "Content-Type", + "X-AliceBot-User-Id", + "X-Telegram-Bot-Api-Secret-Token", + ) + assert settings.cors_allow_credentials is False + assert settings.cors_preflight_max_age_seconds == 600 + assert settings.security_headers_enabled is True + assert settings.security_headers_hsts_max_age_seconds == 31_536_000 + assert settings.security_headers_hsts_include_subdomains is True + assert settings.trust_proxy_headers is False + assert settings.trusted_proxy_ips == () + assert settings.entrypoint_rate_limit_backend == "redis" + + +def test_settings_honor_environment_overrides(monkeypatch): + monkeypatch.setenv("APP_ENV", "test") + monkeypatch.setenv("APP_PORT", "8100") + monkeypatch.setenv("DATABASE_URL", "postgresql://app:secret@localhost:5432/custom") + monkeypatch.setenv("HEALTHCHECK_TIMEOUT_SECONDS", "9") + monkeypatch.setenv("MODEL_BASE_URL", "https://example.test/v1") + monkeypatch.setenv("MODEL_NAME", "gpt-5") + monkeypatch.setenv("MODEL_TIMEOUT_SECONDS", "45") + monkeypatch.setenv("TASK_WORKSPACE_ROOT", "/tmp/custom-workspaces") + monkeypatch.setenv("GMAIL_SECRET_MANAGER_URL", "file:///tmp/custom-gmail-secrets") + monkeypatch.setenv("CALENDAR_SECRET_MANAGER_URL", "file:///tmp/custom-calendar-secrets") + monkeypatch.setenv("ALICEBOT_AUTH_USER_ID", "00000000-0000-0000-0000-000000000001") + monkeypatch.setenv("RESPONSE_RATE_LIMIT_WINDOW_SECONDS", "120") + monkeypatch.setenv("RESPONSE_RATE_LIMIT_MAX_REQUESTS", "30") + monkeypatch.setenv("TELEGRAM_LINK_TTL_SECONDS", "900") + monkeypatch.setenv("TELEGRAM_BOT_USERNAME", "alicebuilder_bot") + monkeypatch.setenv("TELEGRAM_WEBHOOK_SECRET", "phase10-secret") + monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "test-bot-token") + monkeypatch.setenv("HOSTED_CHAT_RATE_LIMIT_WINDOW_SECONDS", "75") + monkeypatch.setenv("HOSTED_CHAT_RATE_LIMIT_MAX_REQUESTS", "7") + monkeypatch.setenv("HOSTED_SCHEDULER_RATE_LIMIT_WINDOW_SECONDS", "900") + monkeypatch.setenv("HOSTED_SCHEDULER_RATE_LIMIT_MAX_REQUESTS", "12") + monkeypatch.setenv("HOSTED_ABUSE_WINDOW_SECONDS", "1800") + monkeypatch.setenv("HOSTED_ABUSE_BLOCK_THRESHOLD", "6") + monkeypatch.setenv("HOSTED_RATE_LIMITS_ENABLED_BY_DEFAULT", "false") + monkeypatch.setenv("HOSTED_ABUSE_CONTROLS_ENABLED_BY_DEFAULT", "false") + monkeypatch.setenv("MAGIC_LINK_START_RATE_LIMIT_WINDOW_SECONDS", "360") + monkeypatch.setenv("MAGIC_LINK_START_RATE_LIMIT_MAX_REQUESTS", "8") + monkeypatch.setenv("MAGIC_LINK_VERIFY_RATE_LIMIT_WINDOW_SECONDS", "420") + monkeypatch.setenv("MAGIC_LINK_VERIFY_RATE_LIMIT_MAX_REQUESTS", "12") + monkeypatch.setenv("TELEGRAM_WEBHOOK_RATE_LIMIT_WINDOW_SECONDS", "90") + monkeypatch.setenv("TELEGRAM_WEBHOOK_RATE_LIMIT_MAX_REQUESTS", "180") + monkeypatch.setenv( + "CORS_ALLOWED_ORIGINS", + "https://app.example.com, https://staging.example.com", + ) + monkeypatch.setenv("CORS_ALLOWED_METHODS", "GET,POST,OPTIONS") + monkeypatch.setenv("CORS_ALLOWED_HEADERS", "Authorization,Content-Type") + monkeypatch.setenv("CORS_ALLOW_CREDENTIALS", "true") + monkeypatch.setenv("CORS_PREFLIGHT_MAX_AGE_SECONDS", "900") + monkeypatch.setenv("SECURITY_HEADERS_ENABLED", "false") + monkeypatch.setenv("SECURITY_HEADERS_HSTS_MAX_AGE_SECONDS", "86400") + monkeypatch.setenv("SECURITY_HEADERS_HSTS_INCLUDE_SUBDOMAINS", "false") + monkeypatch.setenv("TRUST_PROXY_HEADERS", "true") + monkeypatch.setenv("TRUSTED_PROXY_IPS", "127.0.0.1,10.0.0.2") + monkeypatch.setenv("ENTRYPOINT_RATE_LIMIT_BACKEND", "memory") + + settings = Settings.from_env() + + assert settings.app_env == "test" + assert settings.app_port == 8100 + assert settings.database_url == "postgresql://app:secret@localhost:5432/custom" + assert settings.healthcheck_timeout_seconds == 9 + assert settings.model_base_url == "https://example.test/v1" + assert settings.model_name == "gpt-5" + assert settings.model_timeout_seconds == 45 + assert settings.task_workspace_root == "/tmp/custom-workspaces" + assert settings.gmail_secret_manager_url == "file:///tmp/custom-gmail-secrets" + assert settings.calendar_secret_manager_url == "file:///tmp/custom-calendar-secrets" + assert settings.auth_user_id == "00000000-0000-0000-0000-000000000001" + assert settings.response_rate_limit_window_seconds == 120 + assert settings.response_rate_limit_max_requests == 30 + assert settings.telegram_link_ttl_seconds == 900 + assert settings.telegram_bot_username == "alicebuilder_bot" + assert settings.telegram_webhook_secret == "phase10-secret" + assert settings.telegram_bot_token == "test-bot-token" + assert settings.hosted_chat_rate_limit_window_seconds == 75 + assert settings.hosted_chat_rate_limit_max_requests == 7 + assert settings.hosted_scheduler_rate_limit_window_seconds == 900 + assert settings.hosted_scheduler_rate_limit_max_requests == 12 + assert settings.hosted_abuse_window_seconds == 1800 + assert settings.hosted_abuse_block_threshold == 6 + assert settings.hosted_rate_limits_enabled_by_default is False + assert settings.hosted_abuse_controls_enabled_by_default is False + assert settings.magic_link_start_rate_limit_window_seconds == 360 + assert settings.magic_link_start_rate_limit_max_requests == 8 + assert settings.magic_link_verify_rate_limit_window_seconds == 420 + assert settings.magic_link_verify_rate_limit_max_requests == 12 + assert settings.telegram_webhook_rate_limit_window_seconds == 90 + assert settings.telegram_webhook_rate_limit_max_requests == 180 + assert settings.cors_allowed_origins == ("https://app.example.com", "https://staging.example.com") + assert settings.cors_allowed_methods == ("GET", "POST", "OPTIONS") + assert settings.cors_allowed_headers == ("Authorization", "Content-Type") + assert settings.cors_allow_credentials is True + assert settings.cors_preflight_max_age_seconds == 900 + assert settings.security_headers_enabled is False + assert settings.security_headers_hsts_max_age_seconds == 86400 + assert settings.security_headers_hsts_include_subdomains is False + assert settings.trust_proxy_headers is True + assert settings.trusted_proxy_ips == ("127.0.0.1", "10.0.0.2") + assert settings.entrypoint_rate_limit_backend == "memory" + + +def test_settings_can_be_loaded_from_an_explicit_environment_mapping() -> None: + settings = Settings.from_env( + { + "APP_ENV": "test", + "APP_PORT": "8200", + "DATABASE_URL": "postgresql://app:secret@localhost:5432/mapped", + "MODEL_PROVIDER": "openai_responses", + "MODEL_NAME": "gpt-5-mini", + "TASK_WORKSPACE_ROOT": "/tmp/mapped-workspaces", + "GMAIL_SECRET_MANAGER_URL": "file:///tmp/mapped-gmail-secrets", + "CALENDAR_SECRET_MANAGER_URL": "file:///tmp/mapped-calendar-secrets", + "ALICEBOT_AUTH_USER_ID": "00000000-0000-0000-0000-000000000001", + "RESPONSE_RATE_LIMIT_WINDOW_SECONDS": "75", + "RESPONSE_RATE_LIMIT_MAX_REQUESTS": "10", + "TELEGRAM_LINK_TTL_SECONDS": "700", + "TELEGRAM_BOT_USERNAME": "alicebot_phase10", + "TELEGRAM_WEBHOOK_SECRET": "secret-value", + "TELEGRAM_BOT_TOKEN": "bot-token", + "HOSTED_CHAT_RATE_LIMIT_WINDOW_SECONDS": "90", + "HOSTED_CHAT_RATE_LIMIT_MAX_REQUESTS": "9", + "HOSTED_SCHEDULER_RATE_LIMIT_WINDOW_SECONDS": "600", + "HOSTED_SCHEDULER_RATE_LIMIT_MAX_REQUESTS": "14", + "HOSTED_ABUSE_WINDOW_SECONDS": "1200", + "HOSTED_ABUSE_BLOCK_THRESHOLD": "4", + "HOSTED_RATE_LIMITS_ENABLED_BY_DEFAULT": "true", + "HOSTED_ABUSE_CONTROLS_ENABLED_BY_DEFAULT": "true", + "MAGIC_LINK_START_RATE_LIMIT_WINDOW_SECONDS": "360", + "MAGIC_LINK_START_RATE_LIMIT_MAX_REQUESTS": "8", + "MAGIC_LINK_VERIFY_RATE_LIMIT_WINDOW_SECONDS": "420", + "MAGIC_LINK_VERIFY_RATE_LIMIT_MAX_REQUESTS": "12", + "TELEGRAM_WEBHOOK_RATE_LIMIT_WINDOW_SECONDS": "90", + "TELEGRAM_WEBHOOK_RATE_LIMIT_MAX_REQUESTS": "180", + "CORS_ALLOWED_ORIGINS": "https://app.example.com,https://staging.example.com", + "CORS_ALLOWED_METHODS": "GET,POST,OPTIONS", + "CORS_ALLOWED_HEADERS": "Authorization,Content-Type", + "CORS_ALLOW_CREDENTIALS": "true", + "CORS_PREFLIGHT_MAX_AGE_SECONDS": "900", + "SECURITY_HEADERS_ENABLED": "false", + "SECURITY_HEADERS_HSTS_MAX_AGE_SECONDS": "86400", + "SECURITY_HEADERS_HSTS_INCLUDE_SUBDOMAINS": "false", + "TRUST_PROXY_HEADERS": "true", + "TRUSTED_PROXY_IPS": "127.0.0.1,10.0.0.2", + "ENTRYPOINT_RATE_LIMIT_BACKEND": "memory", + } + ) + + assert settings.app_env == "test" + assert settings.app_port == 8200 + assert settings.database_url == "postgresql://app:secret@localhost:5432/mapped" + assert settings.model_provider == "openai_responses" + assert settings.model_name == "gpt-5-mini" + assert settings.task_workspace_root == "/tmp/mapped-workspaces" + assert settings.gmail_secret_manager_url == "file:///tmp/mapped-gmail-secrets" + assert settings.calendar_secret_manager_url == "file:///tmp/mapped-calendar-secrets" + assert settings.auth_user_id == "00000000-0000-0000-0000-000000000001" + assert settings.response_rate_limit_window_seconds == 75 + assert settings.response_rate_limit_max_requests == 10 + assert settings.telegram_link_ttl_seconds == 700 + assert settings.telegram_bot_username == "alicebot_phase10" + assert settings.telegram_webhook_secret == "secret-value" + assert settings.telegram_bot_token == "bot-token" + assert settings.hosted_chat_rate_limit_window_seconds == 90 + assert settings.hosted_chat_rate_limit_max_requests == 9 + assert settings.hosted_scheduler_rate_limit_window_seconds == 600 + assert settings.hosted_scheduler_rate_limit_max_requests == 14 + assert settings.hosted_abuse_window_seconds == 1200 + assert settings.hosted_abuse_block_threshold == 4 + assert settings.hosted_rate_limits_enabled_by_default is True + assert settings.hosted_abuse_controls_enabled_by_default is True + assert settings.magic_link_start_rate_limit_window_seconds == 360 + assert settings.magic_link_start_rate_limit_max_requests == 8 + assert settings.magic_link_verify_rate_limit_window_seconds == 420 + assert settings.magic_link_verify_rate_limit_max_requests == 12 + assert settings.telegram_webhook_rate_limit_window_seconds == 90 + assert settings.telegram_webhook_rate_limit_max_requests == 180 + assert settings.cors_allowed_origins == ("https://app.example.com", "https://staging.example.com") + assert settings.cors_allowed_methods == ("GET", "POST", "OPTIONS") + assert settings.cors_allowed_headers == ("Authorization", "Content-Type") + assert settings.cors_allow_credentials is True + assert settings.cors_preflight_max_age_seconds == 900 + assert settings.security_headers_enabled is False + assert settings.security_headers_hsts_max_age_seconds == 86400 + assert settings.security_headers_hsts_include_subdomains is False + assert settings.trust_proxy_headers is True + assert settings.trusted_proxy_ips == ("127.0.0.1", "10.0.0.2") + assert settings.entrypoint_rate_limit_backend == "memory" + + +def test_settings_raise_clear_error_for_invalid_integer_values() -> None: + with pytest.raises(ValueError, match="APP_PORT must be an integer"): + Settings.from_env({"APP_PORT": "not-an-integer"}) + + with pytest.raises(ValueError, match="MODEL_TIMEOUT_SECONDS must be an integer"): + Settings.from_env({"MODEL_TIMEOUT_SECONDS": "not-an-integer"}) + + with pytest.raises(ValueError, match="RESPONSE_RATE_LIMIT_MAX_REQUESTS must be an integer"): + Settings.from_env({"RESPONSE_RATE_LIMIT_MAX_REQUESTS": "not-an-integer"}) + + +def test_settings_reject_invalid_auth_user_id() -> None: + with pytest.raises(ValueError, match="ALICEBOT_AUTH_USER_ID must be a valid UUID"): + Settings.from_env({"ALICEBOT_AUTH_USER_ID": "not-a-uuid"}) + + +def test_settings_reject_non_positive_rate_limit_values() -> None: + with pytest.raises( + ValueError, + match="RESPONSE_RATE_LIMIT_WINDOW_SECONDS must be a positive integer", + ): + Settings.from_env({"RESPONSE_RATE_LIMIT_WINDOW_SECONDS": "0"}) + + with pytest.raises( + ValueError, + match="RESPONSE_RATE_LIMIT_MAX_REQUESTS must be a positive integer", + ): + Settings.from_env({"RESPONSE_RATE_LIMIT_MAX_REQUESTS": "0"}) + + with pytest.raises( + ValueError, + match="TELEGRAM_LINK_TTL_SECONDS must be a positive integer", + ): + Settings.from_env({"TELEGRAM_LINK_TTL_SECONDS": "0"}) + + with pytest.raises(ValueError, match="TELEGRAM_BOT_USERNAME must be provided"): + Settings.from_env({"TELEGRAM_BOT_USERNAME": " "}) + + with pytest.raises( + ValueError, + match="HOSTED_CHAT_RATE_LIMIT_WINDOW_SECONDS must be a positive integer", + ): + Settings.from_env({"HOSTED_CHAT_RATE_LIMIT_WINDOW_SECONDS": "0"}) + + with pytest.raises( + ValueError, + match="HOSTED_CHAT_RATE_LIMIT_MAX_REQUESTS must be a positive integer", + ): + Settings.from_env({"HOSTED_CHAT_RATE_LIMIT_MAX_REQUESTS": "0"}) + + with pytest.raises( + ValueError, + match="HOSTED_SCHEDULER_RATE_LIMIT_WINDOW_SECONDS must be a positive integer", + ): + Settings.from_env({"HOSTED_SCHEDULER_RATE_LIMIT_WINDOW_SECONDS": "0"}) + + with pytest.raises( + ValueError, + match="HOSTED_SCHEDULER_RATE_LIMIT_MAX_REQUESTS must be a positive integer", + ): + Settings.from_env({"HOSTED_SCHEDULER_RATE_LIMIT_MAX_REQUESTS": "0"}) + + with pytest.raises( + ValueError, + match="HOSTED_ABUSE_WINDOW_SECONDS must be a positive integer", + ): + Settings.from_env({"HOSTED_ABUSE_WINDOW_SECONDS": "0"}) + + with pytest.raises( + ValueError, + match="HOSTED_ABUSE_BLOCK_THRESHOLD must be a positive integer", + ): + Settings.from_env({"HOSTED_ABUSE_BLOCK_THRESHOLD": "0"}) + + with pytest.raises( + ValueError, + match="MAGIC_LINK_START_RATE_LIMIT_WINDOW_SECONDS must be a positive integer", + ): + Settings.from_env({"MAGIC_LINK_START_RATE_LIMIT_WINDOW_SECONDS": "0"}) + + with pytest.raises( + ValueError, + match="MAGIC_LINK_START_RATE_LIMIT_MAX_REQUESTS must be a positive integer", + ): + Settings.from_env({"MAGIC_LINK_START_RATE_LIMIT_MAX_REQUESTS": "0"}) + + with pytest.raises( + ValueError, + match="MAGIC_LINK_VERIFY_RATE_LIMIT_WINDOW_SECONDS must be a positive integer", + ): + Settings.from_env({"MAGIC_LINK_VERIFY_RATE_LIMIT_WINDOW_SECONDS": "0"}) + + with pytest.raises( + ValueError, + match="MAGIC_LINK_VERIFY_RATE_LIMIT_MAX_REQUESTS must be a positive integer", + ): + Settings.from_env({"MAGIC_LINK_VERIFY_RATE_LIMIT_MAX_REQUESTS": "0"}) + + with pytest.raises( + ValueError, + match="TELEGRAM_WEBHOOK_RATE_LIMIT_WINDOW_SECONDS must be a positive integer", + ): + Settings.from_env({"TELEGRAM_WEBHOOK_RATE_LIMIT_WINDOW_SECONDS": "0"}) + + with pytest.raises( + ValueError, + match="TELEGRAM_WEBHOOK_RATE_LIMIT_MAX_REQUESTS must be a positive integer", + ): + Settings.from_env({"TELEGRAM_WEBHOOK_RATE_LIMIT_MAX_REQUESTS": "0"}) + + with pytest.raises( + ValueError, + match="CORS_PREFLIGHT_MAX_AGE_SECONDS must be a positive integer", + ): + Settings.from_env({"CORS_PREFLIGHT_MAX_AGE_SECONDS": "0"}) + + with pytest.raises( + ValueError, + match="CORS_ALLOWED_METHODS must include at least one method", + ): + Settings.from_env({"CORS_ALLOWED_METHODS": " "}) + + with pytest.raises( + ValueError, + match="SECURITY_HEADERS_HSTS_MAX_AGE_SECONDS must be a positive integer", + ): + Settings.from_env({"SECURITY_HEADERS_HSTS_MAX_AGE_SECONDS": "0"}) + + with pytest.raises( + ValueError, + match="ENTRYPOINT_RATE_LIMIT_BACKEND must be either 'redis' or 'memory'", + ): + Settings.from_env({"ENTRYPOINT_RATE_LIMIT_BACKEND": "invalid"}) + + with pytest.raises( + ValueError, + match="TRUSTED_PROXY_IPS must include at least one IP when TRUST_PROXY_HEADERS is enabled", + ): + Settings.from_env({"TRUST_PROXY_HEADERS": "true"}) + + +def test_settings_require_hardened_non_dev_configuration() -> None: + with pytest.raises( + ValueError, + match="ALICEBOT_AUTH_USER_ID must be configured outside development/test environments", + ): + Settings.from_env({"APP_ENV": "staging"}) + + with pytest.raises(ValueError, match="DATABASE_URL must be overridden outside development/test environments"): + Settings.from_env( + { + "APP_ENV": "staging", + "ALICEBOT_AUTH_USER_ID": "00000000-0000-0000-0000-000000000001", + } + ) + + with pytest.raises( + ValueError, + match="TELEGRAM_WEBHOOK_SECRET must be configured outside development/test environments", + ): + Settings.from_env( + { + "APP_ENV": "staging", + "ALICEBOT_AUTH_USER_ID": "00000000-0000-0000-0000-000000000001", + "DATABASE_URL": "postgresql://secure-app:secret@localhost:5432/alicebot_secure", + "DATABASE_ADMIN_URL": "postgresql://secure-admin:secret@localhost:5432/alicebot_secure", + "S3_ACCESS_KEY": "secure-access", + "S3_SECRET_KEY": "secure-secret", + } + ) + + with pytest.raises( + ValueError, + match="CORS_ALLOWED_ORIGINS cannot include wildcard outside development/test environments", + ): + Settings.from_env( + { + "APP_ENV": "staging", + "ALICEBOT_AUTH_USER_ID": "00000000-0000-0000-0000-000000000001", + "DATABASE_URL": "postgresql://secure-app:secret@localhost:5432/alicebot_secure", + "DATABASE_ADMIN_URL": "postgresql://secure-admin:secret@localhost:5432/alicebot_secure", + "S3_ACCESS_KEY": "secure-access", + "S3_SECRET_KEY": "secure-secret", + "TELEGRAM_WEBHOOK_SECRET": "secure-webhook-secret", + "CORS_ALLOWED_ORIGINS": "*", + } + ) diff --git a/tests/unit/test_continuity_capture.py b/tests/unit/test_continuity_capture.py new file mode 100644 index 0000000..80126f4 --- /dev/null +++ b/tests/unit/test_continuity_capture.py @@ -0,0 +1,228 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import pytest + +from alicebot_api.continuity_capture import ( + ContinuityCaptureNotFoundError, + ContinuityCaptureValidationError, + capture_continuity_input, + get_continuity_capture_detail, + list_continuity_capture_inbox, +) +from alicebot_api.contracts import ContinuityCaptureCreateInput + + +class ContinuityCaptureStoreStub: + def __init__(self) -> None: + self.user_id = UUID("11111111-1111-4111-8111-111111111111") + self.base_time = datetime(2026, 3, 29, 9, 30, tzinfo=UTC) + self.capture_events: dict[UUID, dict[str, object]] = {} + self.capture_event_order: list[UUID] = [] + self.objects_by_capture_event: dict[UUID, dict[str, object]] = {} + + def create_continuity_capture_event( + self, + *, + raw_content: str, + explicit_signal: str | None, + admission_posture: str, + admission_reason: str, + ): + capture_event_id = uuid4() + row = { + "id": capture_event_id, + "user_id": self.user_id, + "raw_content": raw_content, + "explicit_signal": explicit_signal, + "admission_posture": admission_posture, + "admission_reason": admission_reason, + "created_at": self.base_time, + } + self.capture_events[capture_event_id] = row + self.capture_event_order.insert(0, capture_event_id) + return row + + def get_continuity_capture_event_optional(self, capture_event_id: UUID): + return self.capture_events.get(capture_event_id) + + def list_continuity_capture_events(self, *, limit: int): + return [ + self.capture_events[capture_event_id] + for capture_event_id in self.capture_event_order[:limit] + ] + + def count_continuity_capture_events(self) -> int: + return len(self.capture_events) + + def create_continuity_object( + self, + *, + capture_event_id: UUID, + object_type: str, + status: str, + title: str, + body, + provenance, + confidence: float, + is_preserved: bool = True, + is_searchable: bool = True, + is_promotable: bool = True, + ): + row = { + "id": uuid4(), + "user_id": self.user_id, + "capture_event_id": capture_event_id, + "object_type": object_type, + "status": status, + "is_preserved": is_preserved, + "is_searchable": is_searchable, + "is_promotable": is_promotable, + "title": title, + "body": body, + "provenance": provenance, + "confidence": confidence, + "created_at": self.base_time, + "updated_at": self.base_time, + } + self.objects_by_capture_event[capture_event_id] = row + return row + + def get_continuity_object_by_capture_event_optional(self, capture_event_id: UUID): + return self.objects_by_capture_event.get(capture_event_id) + + def list_continuity_objects_for_capture_events(self, capture_event_ids: list[UUID]): + return [ + self.objects_by_capture_event[capture_event_id] + for capture_event_id in capture_event_ids + if capture_event_id in self.objects_by_capture_event + ] + + +def test_capture_continuity_input_maps_explicit_signal_deterministically() -> None: + store = ContinuityCaptureStoreStub() + + payload = capture_continuity_input( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=ContinuityCaptureCreateInput( + raw_content="Call supplier before noon", + explicit_signal="task", + ), + ) + + capture = payload["capture"] + assert capture["capture_event"]["admission_posture"] == "DERIVED" + assert capture["capture_event"]["admission_reason"] == "explicit_signal_task" + assert capture["derived_object"] is not None + assert capture["derived_object"]["object_type"] == "NextAction" + assert capture["derived_object"]["body"]["action_text"] == "Call supplier before noon" + assert capture["derived_object"]["provenance"]["capture_event_id"] == capture["capture_event"]["id"] + + +def test_capture_continuity_input_uses_high_confidence_prefix_when_signal_is_missing() -> None: + store = ContinuityCaptureStoreStub() + + payload = capture_continuity_input( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=ContinuityCaptureCreateInput(raw_content="Decision: ship conservative admission"), + ) + + assert payload["capture"]["capture_event"]["admission_posture"] == "DERIVED" + assert payload["capture"]["capture_event"]["admission_reason"] == "high_confidence_prefix_decision" + assert payload["capture"]["derived_object"]["object_type"] == "Decision" + assert payload["capture"]["derived_object"]["body"]["decision_text"] == "ship conservative admission" + assert payload["capture"]["derived_object"]["confidence"] == 0.95 + + +def test_capture_continuity_input_defaults_to_triage_for_ambiguous_input() -> None: + store = ContinuityCaptureStoreStub() + + payload = capture_continuity_input( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=ContinuityCaptureCreateInput(raw_content="Need to think about this sometime"), + ) + + assert payload["capture"]["capture_event"] == { + "id": payload["capture"]["capture_event"]["id"], + "raw_content": "Need to think about this sometime", + "explicit_signal": None, + "admission_posture": "TRIAGE", + "admission_reason": "ambiguous_capture_requires_triage", + "created_at": "2026-03-29T09:30:00+00:00", + } + assert payload["capture"]["derived_object"] is None + + +def test_continuity_capture_list_and_detail_preserve_triage_visibility() -> None: + store = ContinuityCaptureStoreStub() + + triage_payload = capture_continuity_input( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=ContinuityCaptureCreateInput(raw_content="Uncertain note without prefix"), + ) + derived_payload = capture_continuity_input( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=ContinuityCaptureCreateInput(raw_content="task: send invoice"), + ) + + inbox = list_continuity_capture_inbox( + store, # type: ignore[arg-type] + user_id=store.user_id, + limit=20, + ) + + assert inbox["summary"] == { + "limit": 20, + "returned_count": 2, + "total_count": 2, + "derived_count": 1, + "triage_count": 1, + "order": ["created_at_desc", "id_desc"], + } + assert inbox["items"][0]["capture_event"]["id"] == derived_payload["capture"]["capture_event"]["id"] + assert inbox["items"][1]["capture_event"]["id"] == triage_payload["capture"]["capture_event"]["id"] + + detail = get_continuity_capture_detail( + store, # type: ignore[arg-type] + user_id=store.user_id, + capture_event_id=UUID(derived_payload["capture"]["capture_event"]["id"]), + ) + assert detail["capture"]["derived_object"]["object_type"] == "NextAction" + + +def test_continuity_capture_validation_and_not_found_contracts() -> None: + store = ContinuityCaptureStoreStub() + + with pytest.raises(ContinuityCaptureValidationError, match="raw_content must not be empty"): + capture_continuity_input( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=ContinuityCaptureCreateInput(raw_content=" "), + ) + + with pytest.raises(ContinuityCaptureValidationError, match="explicit_signal must be one of"): + capture_continuity_input( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=ContinuityCaptureCreateInput( + raw_content="Call supplier", + explicit_signal="unknown_signal", # type: ignore[arg-type] + ), + ) + + with pytest.raises( + ContinuityCaptureNotFoundError, + match="continuity capture event .* was not found", + ): + get_continuity_capture_detail( + store, # type: ignore[arg-type] + user_id=store.user_id, + capture_event_id=uuid4(), + ) diff --git a/tests/unit/test_continuity_objects.py b/tests/unit/test_continuity_objects.py new file mode 100644 index 0000000..94bd130 --- /dev/null +++ b/tests/unit/test_continuity_objects.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import pytest + +from alicebot_api.continuity_objects import ( + ContinuityObjectValidationError, + create_continuity_object_record, + get_continuity_object_for_capture_event, + list_continuity_objects_for_capture_events, +) + + +class ContinuityObjectStoreStub: + def __init__(self) -> None: + self.created_payloads: list[dict[str, object]] = [] + self.rows_by_capture_event: dict[UUID, dict[str, object]] = {} + self.base_time = datetime(2026, 3, 29, 9, 0, tzinfo=UTC) + + def create_continuity_object( + self, + *, + capture_event_id: UUID, + object_type: str, + status: str, + title: str, + body, + provenance, + confidence: float, + is_preserved: bool = True, + is_searchable: bool = True, + is_promotable: bool = True, + ): + created = { + "id": uuid4(), + "user_id": uuid4(), + "capture_event_id": capture_event_id, + "object_type": object_type, + "status": status, + "is_preserved": is_preserved, + "is_searchable": is_searchable, + "is_promotable": is_promotable, + "title": title, + "body": body, + "provenance": provenance, + "confidence": confidence, + "created_at": self.base_time, + "updated_at": self.base_time, + } + self.created_payloads.append(created) + self.rows_by_capture_event[capture_event_id] = created + return created + + def get_continuity_object_by_capture_event_optional(self, capture_event_id: UUID): + return self.rows_by_capture_event.get(capture_event_id) + + def list_continuity_objects_for_capture_events(self, capture_event_ids: list[UUID]): + return [ + self.rows_by_capture_event[capture_event_id] + for capture_event_id in capture_event_ids + if capture_event_id in self.rows_by_capture_event + ] + + +def test_create_continuity_object_record_serializes_created_row() -> None: + store = ContinuityObjectStoreStub() + capture_event_id = UUID("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") + user_id = UUID("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb") + + payload = create_continuity_object_record( + store, # type: ignore[arg-type] + user_id=user_id, + capture_event_id=capture_event_id, + object_type="Decision", + title="Decision: Use bounded intake", + body={"decision_text": "Use bounded intake"}, + provenance={"capture_event_id": str(capture_event_id)}, + confidence=1.0, + ) + + assert payload == { + "id": payload["id"], + "capture_event_id": str(capture_event_id), + "object_type": "Decision", + "status": "active", + "lifecycle": { + "is_preserved": True, + "preservation_status": "preserved", + "is_searchable": True, + "searchability_status": "searchable", + "is_promotable": True, + "promotion_status": "promotable", + }, + "title": "Decision: Use bounded intake", + "body": {"decision_text": "Use bounded intake"}, + "provenance": {"capture_event_id": str(capture_event_id)}, + "confidence": 1.0, + "created_at": "2026-03-29T09:00:00+00:00", + "updated_at": "2026-03-29T09:00:00+00:00", + } + + +def test_create_continuity_object_record_rejects_invalid_object_type() -> None: + store = ContinuityObjectStoreStub() + + with pytest.raises( + ContinuityObjectValidationError, + match="object_type must be one of", + ): + create_continuity_object_record( + store, # type: ignore[arg-type] + user_id=uuid4(), + capture_event_id=uuid4(), + object_type="Task", + title="Task: Call supplier", + body={"action_text": "Call supplier"}, + provenance={"capture_event_id": "event-1"}, + confidence=1.0, + ) + + +def test_create_continuity_object_record_rejects_empty_title_and_invalid_confidence() -> None: + store = ContinuityObjectStoreStub() + + with pytest.raises(ContinuityObjectValidationError, match="title must not be empty"): + create_continuity_object_record( + store, # type: ignore[arg-type] + user_id=uuid4(), + capture_event_id=uuid4(), + object_type="Note", + title=" ", + body={"body": "note"}, + provenance={"capture_event_id": "event-1"}, + confidence=1.0, + ) + + with pytest.raises( + ContinuityObjectValidationError, + match="confidence must be between 0.0 and 1.0", + ): + create_continuity_object_record( + store, # type: ignore[arg-type] + user_id=uuid4(), + capture_event_id=uuid4(), + object_type="Note", + title="Valid title", + body={"body": "note"}, + provenance={"capture_event_id": "event-1"}, + confidence=1.01, + ) + + +def test_get_and_list_continuity_objects_for_capture_events_use_capture_event_scope() -> None: + store = ContinuityObjectStoreStub() + capture_event_id = uuid4() + + created = create_continuity_object_record( + store, # type: ignore[arg-type] + user_id=uuid4(), + capture_event_id=capture_event_id, + object_type="MemoryFact", + title="Memory Fact: prefers tea", + body={"fact_text": "prefers tea"}, + provenance={"capture_event_id": str(capture_event_id)}, + confidence=0.95, + ) + + fetched = get_continuity_object_for_capture_event( + store, # type: ignore[arg-type] + user_id=uuid4(), + capture_event_id=capture_event_id, + ) + missing = get_continuity_object_for_capture_event( + store, # type: ignore[arg-type] + user_id=uuid4(), + capture_event_id=uuid4(), + ) + listed = list_continuity_objects_for_capture_events( + store, # type: ignore[arg-type] + user_id=uuid4(), + capture_event_ids=[capture_event_id, uuid4()], + ) + + assert fetched == created + assert missing is None + assert listed == { + str(capture_event_id): created, + } diff --git a/tests/unit/test_continuity_open_loops.py b/tests/unit/test_continuity_open_loops.py new file mode 100644 index 0000000..ab38e1a --- /dev/null +++ b/tests/unit/test_continuity_open_loops.py @@ -0,0 +1,420 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from uuid import UUID, uuid4 + +import pytest + +from alicebot_api.continuity_open_loops import ( + ContinuityOpenLoopValidationError, + apply_continuity_open_loop_review_action, + compile_continuity_daily_brief, + compile_continuity_open_loop_dashboard, + compile_continuity_weekly_review, +) +from alicebot_api.contracts import ( + ContinuityDailyBriefRequestInput, + ContinuityOpenLoopDashboardQueryInput, + ContinuityOpenLoopReviewActionInput, + ContinuityWeeklyReviewRequestInput, +) + + +class ContinuityOpenLoopsStoreStub: + def __init__(self, rows: list[dict[str, object]] | None = None) -> None: + self.base_time = datetime(2026, 3, 30, 9, 0, tzinfo=UTC) + self._recall_rows = list(rows or []) + self.objects: dict[UUID, dict[str, object]] = {} + self.events: list[dict[str, object]] = [] + + def list_continuity_recall_candidates(self): + return list(self._recall_rows) + + def add_object( + self, + *, + object_type: str, + status: str = "active", + title: str, + created_at: datetime | None = None, + last_confirmed_at: datetime | None = None, + ) -> dict[str, object]: + object_id = uuid4() + capture_event_id = uuid4() + row = { + "id": object_id, + "user_id": UUID("11111111-1111-4111-8111-111111111111"), + "capture_event_id": capture_event_id, + "object_type": object_type, + "status": status, + "is_preserved": True, + "is_searchable": True, + "is_promotable": True, + "title": title, + "body": {"text": title}, + "provenance": {"thread_id": "thread-1"}, + "confidence": 0.91, + "last_confirmed_at": last_confirmed_at, + "supersedes_object_id": None, + "superseded_by_object_id": None, + "created_at": created_at or self.base_time, + "updated_at": created_at or self.base_time, + } + self.objects[object_id] = row + return dict(row) + + def get_continuity_object_optional(self, continuity_object_id: UUID): + row = self.objects.get(continuity_object_id) + if row is None: + return None + return dict(row) + + def create_continuity_correction_event( + self, + *, + continuity_object_id: UUID, + action: str, + reason: str | None, + before_snapshot, + after_snapshot, + payload, + ): + event = { + "id": uuid4(), + "user_id": UUID("11111111-1111-4111-8111-111111111111"), + "continuity_object_id": continuity_object_id, + "action": action, + "reason": reason, + "before_snapshot": before_snapshot, + "after_snapshot": after_snapshot, + "payload": payload, + "created_at": self.base_time + timedelta(minutes=len(self.events) + 1), + } + self.events.append(event) + return dict(event) + + def list_continuity_correction_events(self, *, continuity_object_id: UUID, limit: int): + matching = [ + dict(event) + for event in self.events + if event["continuity_object_id"] == continuity_object_id + ] + matching.sort(key=lambda item: (item["created_at"], item["id"]), reverse=True) + return matching[:limit] + + def update_continuity_object_optional( + self, + *, + continuity_object_id: UUID, + status: str, + is_preserved: bool, + is_searchable: bool, + is_promotable: bool, + title: str, + body, + provenance, + confidence: float, + last_confirmed_at: datetime | None, + supersedes_object_id: UUID | None, + superseded_by_object_id: UUID | None, + ): + row = self.objects.get(continuity_object_id) + if row is None: + return None + + updated = { + **row, + "status": status, + "is_preserved": is_preserved, + "is_searchable": is_searchable, + "is_promotable": is_promotable, + "title": title, + "body": body, + "provenance": provenance, + "confidence": confidence, + "last_confirmed_at": last_confirmed_at, + "supersedes_object_id": supersedes_object_id, + "superseded_by_object_id": superseded_by_object_id, + "updated_at": self.base_time + timedelta(minutes=len(self.events) + 1), + } + self.objects[continuity_object_id] = updated + return dict(updated) + + +def make_candidate_row( + *, + title: str, + object_type: str, + status: str, + created_at: datetime, +) -> dict[str, object]: + return { + "id": uuid4(), + "user_id": UUID("11111111-1111-4111-8111-111111111111"), + "capture_event_id": uuid4(), + "object_type": object_type, + "status": status, + "is_preserved": True, + "is_searchable": True, + "is_promotable": object_type in {"Decision", "Commitment", "WaitingFor", "Blocker", "NextAction"}, + "title": title, + "body": {"text": title}, + "provenance": {"thread_id": "thread-1"}, + "confidence": 1.0, + "last_confirmed_at": None, + "supersedes_object_id": None, + "superseded_by_object_id": None, + "object_created_at": created_at, + "object_updated_at": created_at, + "admission_posture": "DERIVED", + "admission_reason": "seeded", + "explicit_signal": None, + "capture_created_at": created_at, + } + + +def test_open_loop_dashboard_groups_and_orders_posture_deterministically() -> None: + rows = [ + make_candidate_row( + title="Waiting For: Vendor quote", + object_type="WaitingFor", + status="active", + created_at=datetime(2026, 3, 30, 10, 1, tzinfo=UTC), + ), + make_candidate_row( + title="Waiting For: Legal signoff", + object_type="WaitingFor", + status="active", + created_at=datetime(2026, 3, 30, 10, 3, tzinfo=UTC), + ), + make_candidate_row( + title="Blocker: Missing API key", + object_type="Blocker", + status="active", + created_at=datetime(2026, 3, 30, 10, 4, tzinfo=UTC), + ), + make_candidate_row( + title="Next Action: Send follow-up", + object_type="NextAction", + status="active", + created_at=datetime(2026, 3, 30, 10, 5, tzinfo=UTC), + ), + make_candidate_row( + title="Waiting For: Stale invoice response", + object_type="WaitingFor", + status="stale", + created_at=datetime(2026, 3, 30, 10, 6, tzinfo=UTC), + ), + make_candidate_row( + title="Note: not part of open-loop posture", + object_type="Note", + status="active", + created_at=datetime(2026, 3, 30, 10, 7, tzinfo=UTC), + ), + ] + + payload = compile_continuity_open_loop_dashboard( + ContinuityOpenLoopsStoreStub(rows), # type: ignore[arg-type] + user_id=UUID("11111111-1111-4111-8111-111111111111"), + request=ContinuityOpenLoopDashboardQueryInput(limit=10), + ) + + dashboard = payload["dashboard"] + assert dashboard["summary"] == { + "limit": 10, + "total_count": 5, + "posture_order": ["waiting_for", "blocker", "stale", "next_action"], + "item_order": ["created_at_desc", "id_desc"], + } + assert [item["title"] for item in dashboard["waiting_for"]["items"]] == [ + "Waiting For: Legal signoff", + "Waiting For: Vendor quote", + ] + assert [item["title"] for item in dashboard["blocker"]["items"]] == [ + "Blocker: Missing API key", + ] + assert [item["title"] for item in dashboard["stale"]["items"]] == [ + "Waiting For: Stale invoice response", + ] + assert [item["title"] for item in dashboard["next_action"]["items"]] == [ + "Next Action: Send follow-up", + ] + + +def test_daily_and_weekly_briefs_emit_explicit_empty_states() -> None: + rows = [ + make_candidate_row( + title="Decision: Keep rollout phased", + object_type="Decision", + status="active", + created_at=datetime(2026, 3, 30, 11, 0, tzinfo=UTC), + ), + ] + + daily = compile_continuity_daily_brief( + ContinuityOpenLoopsStoreStub(rows), # type: ignore[arg-type] + user_id=UUID("11111111-1111-4111-8111-111111111111"), + request=ContinuityDailyBriefRequestInput(limit=3), + )["brief"] + weekly = compile_continuity_weekly_review( + ContinuityOpenLoopsStoreStub(rows), # type: ignore[arg-type] + user_id=UUID("11111111-1111-4111-8111-111111111111"), + request=ContinuityWeeklyReviewRequestInput(limit=3), + )["review"] + + assert daily["waiting_for_highlights"]["empty_state"] == { + "is_empty": True, + "message": "No waiting-for highlights for today in the requested scope.", + } + assert daily["blocker_highlights"]["empty_state"] == { + "is_empty": True, + "message": "No blocker highlights for today in the requested scope.", + } + assert daily["stale_items"]["empty_state"] == { + "is_empty": True, + "message": "No stale items for today in the requested scope.", + } + assert daily["next_suggested_action"] == { + "item": None, + "empty_state": { + "is_empty": True, + "message": "No next suggested action in the requested scope.", + }, + } + + assert weekly["rollup"] == { + "total_count": 0, + "waiting_for_count": 0, + "blocker_count": 0, + "stale_count": 0, + "correction_recurrence_count": 0, + "freshness_drift_count": 0, + "next_action_count": 0, + "posture_order": ["waiting_for", "blocker", "stale", "next_action"], + } + + +def test_weekly_rollup_surfaces_correction_recurrence_and_freshness_drift() -> None: + stale_id = uuid4() + recurring_id = uuid4() + rows = [ + { + **make_candidate_row( + title="Waiting For: stale handoff", + object_type="WaitingFor", + status="stale", + created_at=datetime(2026, 3, 30, 10, 0, tzinfo=UTC), + ), + "id": stale_id, + }, + { + **make_candidate_row( + title="Blocker: recurring correction", + object_type="Blocker", + status="active", + created_at=datetime(2026, 3, 30, 10, 1, tzinfo=UTC), + ), + "id": recurring_id, + }, + ] + store = ContinuityOpenLoopsStoreStub(rows) + store.events.extend( + [ + { + "id": uuid4(), + "continuity_object_id": recurring_id, + "created_at": datetime(2026, 3, 30, 9, 1, tzinfo=UTC), + }, + { + "id": uuid4(), + "continuity_object_id": recurring_id, + "created_at": datetime(2026, 3, 30, 9, 2, tzinfo=UTC), + }, + ] + ) + + weekly = compile_continuity_weekly_review( + store, # type: ignore[arg-type] + user_id=UUID("11111111-1111-4111-8111-111111111111"), + request=ContinuityWeeklyReviewRequestInput(limit=5), + )["review"] + + assert weekly["rollup"]["correction_recurrence_count"] == 1 + assert weekly["rollup"]["freshness_drift_count"] == 1 + + +def test_open_loop_dashboard_rejects_mixed_naive_and_offset_aware_time_window() -> None: + with pytest.raises( + ContinuityOpenLoopValidationError, + match="since and until must both include timezone offsets or both omit timezone offsets", + ): + compile_continuity_open_loop_dashboard( + ContinuityOpenLoopsStoreStub(), # type: ignore[arg-type] + user_id=UUID("11111111-1111-4111-8111-111111111111"), + request=ContinuityOpenLoopDashboardQueryInput( + since=datetime(2026, 3, 30, 10, 0, tzinfo=UTC), + until=datetime(2026, 3, 30, 10, 1), + ), + ) + + +def test_review_actions_transition_deterministically_and_emit_audit_rows() -> None: + store = ContinuityOpenLoopsStoreStub() + row = store.add_object( + object_type="WaitingFor", + status="active", + title="Waiting For: Vendor quote", + ) + + done = apply_continuity_open_loop_review_action( + store, # type: ignore[arg-type] + user_id=UUID("11111111-1111-4111-8111-111111111111"), + continuity_object_id=row["id"], + request=ContinuityOpenLoopReviewActionInput(action="done", note="Closed in standup"), + ) + assert done["review_action"] == "done" + assert done["lifecycle_outcome"] == "completed" + assert done["continuity_object"]["status"] == "completed" + assert done["correction_event"]["action"] == "edit" + assert done["correction_event"]["payload"]["review_action"] == "done" + + deferred = apply_continuity_open_loop_review_action( + store, # type: ignore[arg-type] + user_id=UUID("11111111-1111-4111-8111-111111111111"), + continuity_object_id=row["id"], + request=ContinuityOpenLoopReviewActionInput(action="deferred"), + ) + assert deferred["lifecycle_outcome"] == "stale" + assert deferred["continuity_object"]["status"] == "stale" + assert deferred["correction_event"]["action"] == "mark_stale" + + still_blocked = apply_continuity_open_loop_review_action( + store, # type: ignore[arg-type] + user_id=UUID("11111111-1111-4111-8111-111111111111"), + continuity_object_id=row["id"], + request=ContinuityOpenLoopReviewActionInput(action="still_blocked"), + ) + assert still_blocked["lifecycle_outcome"] == "active" + assert still_blocked["continuity_object"]["status"] == "active" + assert still_blocked["continuity_object"]["last_confirmed_at"] is not None + assert still_blocked["correction_event"]["action"] == "confirm" + + +def test_review_action_rejects_unsupported_status() -> None: + store = ContinuityOpenLoopsStoreStub() + row = store.add_object( + object_type="Blocker", + status="deleted", + title="Blocker: Deprecated", + ) + + with pytest.raises( + ContinuityOpenLoopValidationError, + match="review action cannot be applied when status is deleted", + ): + apply_continuity_open_loop_review_action( + store, # type: ignore[arg-type] + user_id=UUID("11111111-1111-4111-8111-111111111111"), + continuity_object_id=row["id"], + request=ContinuityOpenLoopReviewActionInput(action="still_blocked"), + ) diff --git a/tests/unit/test_continuity_recall.py b/tests/unit/test_continuity_recall.py new file mode 100644 index 0000000..6781d2e --- /dev/null +++ b/tests/unit/test_continuity_recall.py @@ -0,0 +1,411 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import pytest + +from alicebot_api.continuity_recall import ContinuityRecallValidationError, query_continuity_recall +from alicebot_api.contracts import ContinuityRecallQueryInput + + +class ContinuityRecallStoreStub: + def __init__(self, rows: list[dict[str, object]]) -> None: + self._rows = rows + + def list_continuity_recall_candidates(self): + return list(self._rows) + + +def make_candidate_row( + *, + title: str, + object_type: str, + capture_created_at: datetime, + confidence: float, + admission_posture: str = "DERIVED", + provenance: dict[str, object] | None = None, + body: dict[str, object] | None = None, + status: str = "active", + last_confirmed_at: datetime | None = None, + supersedes_object_id: UUID | None = None, + superseded_by_object_id: UUID | None = None, + is_searchable: bool = True, + is_promotable: bool | None = None, +) -> dict[str, object]: + object_id = uuid4() + capture_event_id = uuid4() + created_at = capture_created_at + updated_at = capture_created_at + resolved_is_promotable = ( + object_type in {"Decision", "Commitment", "WaitingFor", "Blocker", "NextAction"} + if is_promotable is None + else is_promotable + ) + return { + "id": object_id, + "user_id": UUID("11111111-1111-4111-8111-111111111111"), + "capture_event_id": capture_event_id, + "object_type": object_type, + "status": status, + "is_preserved": True, + "is_searchable": is_searchable, + "is_promotable": resolved_is_promotable, + "title": title, + "body": body or {}, + "provenance": provenance or {}, + "confidence": confidence, + "last_confirmed_at": last_confirmed_at, + "supersedes_object_id": supersedes_object_id, + "superseded_by_object_id": superseded_by_object_id, + "object_created_at": created_at, + "object_updated_at": updated_at, + "admission_posture": admission_posture, + "admission_reason": "seeded", + "explicit_signal": None, + "capture_created_at": capture_created_at, + } + + +def test_recall_returns_deterministic_order_and_provenance_fields() -> None: + thread_id = UUID("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") + task_id = UUID("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb") + rows = [ + make_candidate_row( + title="Decision: Keep conservative posture", + object_type="Decision", + capture_created_at=datetime(2026, 3, 29, 10, 5, tzinfo=UTC), + confidence=0.8, + provenance={ + "thread_id": str(thread_id), + "task_id": str(task_id), + "confirmation_status": "confirmed", + "source_event_ids": ["event-1"], + }, + body={"decision_text": "Keep conservative posture"}, + ), + make_candidate_row( + title="Decision: Revisit tomorrow", + object_type="Decision", + capture_created_at=datetime(2026, 3, 29, 10, 10, tzinfo=UTC), + confidence=0.9, + provenance={ + "thread_id": str(thread_id), + "task_id": str(task_id), + "confirmation_status": "unconfirmed", + "source_event_ids": ["event-2"], + }, + body={"decision_text": "Revisit tomorrow"}, + ), + ] + + payload = query_continuity_recall( + ContinuityRecallStoreStub(rows), # type: ignore[arg-type] + user_id=UUID("11111111-1111-4111-8111-111111111111"), + request=ContinuityRecallQueryInput( + thread_id=thread_id, + task_id=task_id, + limit=20, + ), + ) + + assert payload["summary"] == { + "query": None, + "filters": { + "thread_id": str(thread_id), + "task_id": str(task_id), + "since": None, + "until": None, + }, + "limit": 20, + "returned_count": 2, + "total_count": 2, + "order": ["relevance_desc", "created_at_desc", "id_desc"], + } + assert payload["items"][0]["title"] == "Decision: Keep conservative posture" + assert payload["items"][0]["confirmation_status"] == "confirmed" + assert payload["items"][0]["admission_posture"] == "DERIVED" + assert payload["items"][0]["scope_matches"] == [ + {"kind": "thread", "value": str(thread_id).lower()}, + {"kind": "task", "value": str(task_id).lower()}, + ] + assert payload["items"][0]["last_confirmed_at"] is None + assert payload["items"][0]["supersedes_object_id"] is None + assert payload["items"][0]["superseded_by_object_id"] is None + assert payload["items"][0]["provenance_references"] == [ + {"source_kind": "continuity_capture_event", "source_id": payload["items"][0]["capture_event_id"]}, + {"source_kind": "source_event", "source_id": "event-1"}, + {"source_kind": "task", "source_id": str(task_id)}, + {"source_kind": "thread", "source_id": str(thread_id)}, + ] + assert payload["items"][0]["ordering"]["freshness_posture"] == "fresh" + assert payload["items"][0]["ordering"]["freshness_rank"] == 4 + assert payload["items"][0]["ordering"]["provenance_posture"] == "strong" + assert payload["items"][0]["ordering"]["provenance_rank"] == 3 + assert payload["items"][0]["ordering"]["supersession_posture"] == "current" + assert payload["items"][0]["ordering"]["supersession_rank"] == 3 + assert payload["items"][0]["ordering"]["lifecycle_rank"] == 4 + assert payload["items"][0]["lifecycle"]["is_promotable"] is True + + +def test_recall_filters_project_person_query_and_time_window() -> None: + rows = [ + make_candidate_row( + title="Next Action: Follow up with Alex on Phoenix", + object_type="NextAction", + capture_created_at=datetime(2026, 3, 29, 10, 30, tzinfo=UTC), + confidence=1.0, + provenance={"project": "Project Phoenix", "person": "Alex"}, + body={"action_text": "Follow up with Alex"}, + ), + make_candidate_row( + title="Next Action: Draft runway notes", + object_type="NextAction", + capture_created_at=datetime(2026, 3, 29, 8, 0, tzinfo=UTC), + confidence=1.0, + provenance={"project": "Project Atlas", "person": "Sam"}, + body={"action_text": "Draft notes"}, + ), + ] + + payload = query_continuity_recall( + ContinuityRecallStoreStub(rows), # type: ignore[arg-type] + user_id=UUID("11111111-1111-4111-8111-111111111111"), + request=ContinuityRecallQueryInput( + query="follow up", + project="Project Phoenix", + person="Alex", + since=datetime(2026, 3, 29, 9, 0, tzinfo=UTC), + until=datetime(2026, 3, 29, 11, 0, tzinfo=UTC), + limit=20, + ), + ) + + assert [item["title"] for item in payload["items"]] == [ + "Next Action: Follow up with Alex on Phoenix", + ] + + +def test_recall_rejects_invalid_limits_and_time_window() -> None: + store = ContinuityRecallStoreStub([]) + + with pytest.raises(ContinuityRecallValidationError, match="limit must be between 1 and"): + query_continuity_recall( + store, # type: ignore[arg-type] + user_id=UUID("11111111-1111-4111-8111-111111111111"), + request=ContinuityRecallQueryInput(limit=0), + ) + + +def test_recall_excludes_deleted_and_ranks_lifecycle_posture_deterministically() -> None: + rows = [ + make_candidate_row( + title="Decision: active item", + object_type="Decision", + capture_created_at=datetime(2026, 3, 29, 10, 0, tzinfo=UTC), + confidence=0.8, + status="active", + body={"decision_text": "active item"}, + ), + make_candidate_row( + title="Decision: stale item", + object_type="Decision", + capture_created_at=datetime(2026, 3, 29, 10, 1, tzinfo=UTC), + confidence=0.99, + status="stale", + body={"decision_text": "stale item"}, + ), + make_candidate_row( + title="Decision: superseded item", + object_type="Decision", + capture_created_at=datetime(2026, 3, 29, 10, 2, tzinfo=UTC), + confidence=1.0, + status="superseded", + body={"decision_text": "superseded item"}, + ), + make_candidate_row( + title="Decision: deleted item", + object_type="Decision", + capture_created_at=datetime(2026, 3, 29, 10, 3, tzinfo=UTC), + confidence=1.0, + status="deleted", + body={"decision_text": "deleted item"}, + ), + make_candidate_row( + title="Note: preserved but hidden", + object_type="Note", + capture_created_at=datetime(2026, 3, 29, 10, 4, tzinfo=UTC), + confidence=1.0, + status="active", + body={"body": "preserved but hidden"}, + is_searchable=False, + is_promotable=False, + ), + ] + + store = ContinuityRecallStoreStub(rows) # type: ignore[arg-type] + payload = query_continuity_recall( + store, # type: ignore[arg-type] + user_id=UUID("11111111-1111-4111-8111-111111111111"), + request=ContinuityRecallQueryInput(limit=20), + ) + + assert [item["title"] for item in payload["items"]] == [ + "Decision: active item", + "Decision: stale item", + "Decision: superseded item", + ] + assert all(item["status"] != "deleted" for item in payload["items"]) + assert all(item["object_type"] != "Note" for item in payload["items"]) + + with pytest.raises(ContinuityRecallValidationError, match="until must be greater than or equal to since"): + query_continuity_recall( + store, # type: ignore[arg-type] + user_id=UUID("11111111-1111-4111-8111-111111111111"), + request=ContinuityRecallQueryInput( + since=datetime(2026, 3, 29, 12, 0, tzinfo=UTC), + until=datetime(2026, 3, 29, 11, 0, tzinfo=UTC), + ), + ) + + +def test_recall_prefers_confirmed_fresh_active_truth_over_stale_and_superseded_candidates() -> None: + confirmed_fresh = make_candidate_row( + title="Decision: Current rollout policy", + object_type="Decision", + capture_created_at=datetime(2026, 3, 29, 10, 0, tzinfo=UTC), + confidence=0.62, + status="active", + body={"decision_text": "rollout policy"}, + provenance={"confirmation_status": "confirmed", "source_event_ids": ["event-current"]}, + last_confirmed_at=datetime(2026, 3, 29, 10, 30, tzinfo=UTC), + ) + stale = make_candidate_row( + title="Decision: Old rollout policy", + object_type="Decision", + capture_created_at=datetime(2026, 3, 20, 9, 0, tzinfo=UTC), + confidence=0.99, + status="stale", + body={"decision_text": "rollout policy"}, + provenance={"confirmation_status": "confirmed"}, + ) + superseded = make_candidate_row( + title="Decision: Superseded rollout policy", + object_type="Decision", + capture_created_at=datetime(2026, 3, 10, 9, 0, tzinfo=UTC), + confidence=1.0, + status="superseded", + body={"decision_text": "rollout policy"}, + provenance={"confirmation_status": "confirmed"}, + superseded_by_object_id=UUID(str(confirmed_fresh["id"])), + ) + + payload = query_continuity_recall( + ContinuityRecallStoreStub([stale, superseded, confirmed_fresh]), # type: ignore[arg-type] + user_id=UUID("11111111-1111-4111-8111-111111111111"), + request=ContinuityRecallQueryInput( + query="rollout policy", + limit=20, + ), + ) + + assert [item["title"] for item in payload["items"]] == [ + "Decision: Current rollout policy", + "Decision: Old rollout policy", + "Decision: Superseded rollout policy", + ] + assert payload["items"][0]["ordering"]["freshness_posture"] == "fresh" + assert payload["items"][0]["ordering"]["supersession_posture"] == "current" + assert payload["items"][1]["ordering"]["freshness_posture"] == "stale" + assert payload["items"][2]["ordering"]["supersession_posture"] == "superseded" + + +def test_recall_uses_provenance_quality_as_tie_breaker() -> None: + rows = [ + make_candidate_row( + title="Decision: pricing guardrail with source event", + object_type="Decision", + capture_created_at=datetime(2026, 3, 29, 10, 5, tzinfo=UTC), + confidence=0.9, + provenance={ + "confirmation_status": "confirmed", + "thread_id": "thread-1", + "source_event_ids": ["event-strong"], + }, + body={"decision_text": "pricing guardrail"}, + ), + make_candidate_row( + title="Decision: pricing guardrail without source event", + object_type="Decision", + capture_created_at=datetime(2026, 3, 29, 10, 5, tzinfo=UTC), + confidence=0.99, + provenance={ + "confirmation_status": "confirmed", + }, + body={"decision_text": "pricing guardrail"}, + ), + ] + + payload = query_continuity_recall( + ContinuityRecallStoreStub(rows), # type: ignore[arg-type] + user_id=UUID("11111111-1111-4111-8111-111111111111"), + request=ContinuityRecallQueryInput(query="pricing", limit=20), + ) + + assert [item["title"] for item in payload["items"]] == [ + "Decision: pricing guardrail with source event", + "Decision: pricing guardrail without source event", + ] + assert payload["items"][0]["ordering"]["provenance_posture"] == "strong" + assert payload["items"][1]["ordering"]["provenance_posture"] in {"weak", "partial"} + + +def test_recall_prefers_provenance_freshness_when_explicit_values_conflict() -> None: + row = make_candidate_row( + title="Decision: rollout policy conflict metadata", + object_type="Decision", + capture_created_at=datetime(2026, 3, 29, 11, 0, tzinfo=UTC), + confidence=0.7, + status="active", + provenance={ + "confirmation_status": "confirmed", + "freshness_posture": "stale", + }, + body={ + "decision_text": "rollout policy", + "freshness_status": "fresh", + }, + ) + + payload = query_continuity_recall( + ContinuityRecallStoreStub([row]), # type: ignore[arg-type] + user_id=UUID("11111111-1111-4111-8111-111111111111"), + request=ContinuityRecallQueryInput(query="rollout policy", limit=20), + ) + + assert payload["items"][0]["ordering"]["freshness_posture"] == "stale" + assert payload["items"][0]["ordering"]["freshness_rank"] == 2 + + +def test_recall_selects_ranked_explicit_values_deterministically_within_source() -> None: + row = make_candidate_row( + title="Decision: rollout policy list metadata", + object_type="Decision", + capture_created_at=datetime(2026, 3, 29, 11, 5, tzinfo=UTC), + confidence=0.7, + status="active", + provenance={ + "confirmation_status": ["contested", "confirmed"], + "freshness_posture": ["stale", "fresh"], + }, + body={"decision_text": "rollout policy"}, + ) + + payload = query_continuity_recall( + ContinuityRecallStoreStub([row]), # type: ignore[arg-type] + user_id=UUID("11111111-1111-4111-8111-111111111111"), + request=ContinuityRecallQueryInput(query="rollout policy", limit=20), + ) + + assert payload["items"][0]["confirmation_status"] == "confirmed" + assert payload["items"][0]["ordering"]["freshness_posture"] == "fresh" diff --git a/tests/unit/test_continuity_resumption.py b/tests/unit/test_continuity_resumption.py new file mode 100644 index 0000000..25d86db --- /dev/null +++ b/tests/unit/test_continuity_resumption.py @@ -0,0 +1,358 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from uuid import UUID, uuid4 + +from alicebot_api.continuity_resumption import compile_continuity_resumption_brief +from alicebot_api.contracts import ContinuityResumptionBriefRequestInput + + +class ContinuityResumptionStoreStub: + def __init__(self, rows: list[dict[str, object]]) -> None: + self._rows = rows + + def list_continuity_recall_candidates(self): + return list(self._rows) + + +def make_candidate_row( + *, + title: str, + object_type: str, + capture_created_at: datetime, + provenance: dict[str, object] | None = None, + confidence: float = 1.0, + status: str = "active", + is_searchable: bool = True, + is_promotable: bool | None = None, +) -> dict[str, object]: + resolved_is_promotable = ( + object_type in {"Decision", "Commitment", "WaitingFor", "Blocker", "NextAction"} + if is_promotable is None + else is_promotable + ) + return { + "id": uuid4(), + "user_id": UUID("11111111-1111-4111-8111-111111111111"), + "capture_event_id": uuid4(), + "object_type": object_type, + "status": status, + "is_preserved": True, + "is_searchable": is_searchable, + "is_promotable": resolved_is_promotable, + "title": title, + "body": {"text": title}, + "provenance": provenance or {}, + "confidence": confidence, + "last_confirmed_at": None, + "supersedes_object_id": None, + "superseded_by_object_id": None, + "object_created_at": capture_created_at, + "object_updated_at": capture_created_at, + "admission_posture": "DERIVED", + "admission_reason": "seeded", + "explicit_signal": None, + "capture_created_at": capture_created_at, + } + + +def test_resumption_brief_includes_required_sections_deterministically() -> None: + thread_id = UUID("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") + rows = [ + make_candidate_row( + title="Decision: Freeze API contract", + object_type="Decision", + capture_created_at=datetime(2026, 3, 29, 10, 0, tzinfo=UTC), + provenance={"thread_id": str(thread_id)}, + ), + make_candidate_row( + title="Waiting For: Vendor quote", + object_type="WaitingFor", + capture_created_at=datetime(2026, 3, 29, 10, 5, tzinfo=UTC), + provenance={"thread_id": str(thread_id)}, + ), + make_candidate_row( + title="Next Action: Send approval email", + object_type="NextAction", + capture_created_at=datetime(2026, 3, 29, 10, 6, tzinfo=UTC), + provenance={"thread_id": str(thread_id)}, + ), + make_candidate_row( + title="Decision: Keep rollout phased", + object_type="Decision", + capture_created_at=datetime(2026, 3, 29, 10, 10, tzinfo=UTC), + provenance={"thread_id": str(thread_id)}, + ), + ] + + payload = compile_continuity_resumption_brief( + ContinuityResumptionStoreStub(rows), # type: ignore[arg-type] + user_id=UUID("11111111-1111-4111-8111-111111111111"), + request=ContinuityResumptionBriefRequestInput( + thread_id=thread_id, + max_recent_changes=3, + max_open_loops=2, + ), + ) + + brief = payload["brief"] + + assert brief["last_decision"]["item"] is not None + assert brief["last_decision"]["item"]["title"] == "Decision: Keep rollout phased" + assert brief["open_loops"]["summary"] == { + "limit": 2, + "returned_count": 1, + "total_count": 1, + "order": ["created_at_desc", "id_desc"], + } + assert [item["title"] for item in brief["open_loops"]["items"]] == [ + "Waiting For: Vendor quote", + ] + assert [item["title"] for item in brief["recent_changes"]["items"]] == [ + "Decision: Keep rollout phased", + "Next Action: Send approval email", + "Waiting For: Vendor quote", + ] + assert brief["next_action"]["item"] is not None + assert brief["next_action"]["item"]["title"] == "Next Action: Send approval email" + + +def test_resumption_brief_uses_explicit_empty_states_when_sections_are_missing() -> None: + task_id = UUID("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb") + rows = [ + make_candidate_row( + title="Note: Context only", + object_type="Note", + capture_created_at=datetime(2026, 3, 29, 9, 0, tzinfo=UTC), + provenance={"task_id": str(task_id)}, + ), + ] + + payload = compile_continuity_resumption_brief( + ContinuityResumptionStoreStub(rows), # type: ignore[arg-type] + user_id=UUID("11111111-1111-4111-8111-111111111111"), + request=ContinuityResumptionBriefRequestInput( + task_id=task_id, + max_recent_changes=2, + max_open_loops=2, + ), + ) + + brief = payload["brief"] + + assert brief["last_decision"] == { + "item": None, + "empty_state": { + "is_empty": True, + "message": "No decision found in the requested scope.", + }, + } + assert brief["open_loops"]["items"] == [] + assert brief["open_loops"]["empty_state"] == { + "is_empty": True, + "message": "No open loops found in the requested scope.", + } + assert brief["next_action"] == { + "item": None, + "empty_state": { + "is_empty": True, + "message": "No next action found in the requested scope.", + }, + } + + +def test_resumption_brief_uses_full_scoped_set_instead_of_recall_limit() -> None: + thread_id = UUID("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") + base_time = datetime(2026, 3, 29, 8, 0, tzinfo=UTC) + rows: list[dict[str, object]] = [] + + for index in range(110): + rows.append( + make_candidate_row( + title=f"Decision: historical {index}", + object_type="Decision", + capture_created_at=base_time + timedelta(minutes=index), + provenance={"thread_id": str(thread_id)}, + confidence=1.0, + ) + ) + + rows.append( + make_candidate_row( + title="Decision: newest low confidence", + object_type="Decision", + capture_created_at=base_time + timedelta(minutes=200), + provenance={"thread_id": str(thread_id)}, + confidence=0.01, + ) + ) + rows.append( + make_candidate_row( + title="Next Action: newest low confidence", + object_type="NextAction", + capture_created_at=base_time + timedelta(minutes=201), + provenance={"thread_id": str(thread_id)}, + confidence=0.01, + ) + ) + + payload = compile_continuity_resumption_brief( + ContinuityResumptionStoreStub(rows), # type: ignore[arg-type] + user_id=UUID("11111111-1111-4111-8111-111111111111"), + request=ContinuityResumptionBriefRequestInput( + thread_id=thread_id, + max_recent_changes=2, + max_open_loops=1, + ), + ) + + brief = payload["brief"] + assert brief["last_decision"]["item"] is not None + assert brief["last_decision"]["item"]["title"] == "Decision: newest low confidence" + assert brief["next_action"]["item"] is not None + assert brief["next_action"]["item"]["title"] == "Next Action: newest low confidence" + assert [item["title"] for item in brief["recent_changes"]["items"]] == [ + "Next Action: newest low confidence", + "Decision: newest low confidence", + ] + + +def test_resumption_brief_ignores_superseded_for_primary_sections_but_keeps_recent_changes() -> None: + thread_id = UUID("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") + rows = [ + make_candidate_row( + title="Decision: superseded old decision", + object_type="Decision", + capture_created_at=datetime(2026, 3, 29, 10, 0, tzinfo=UTC), + provenance={"thread_id": str(thread_id)}, + status="superseded", + ), + make_candidate_row( + title="Decision: active latest decision", + object_type="Decision", + capture_created_at=datetime(2026, 3, 29, 10, 5, tzinfo=UTC), + provenance={"thread_id": str(thread_id)}, + status="active", + ), + ] + + payload = compile_continuity_resumption_brief( + ContinuityResumptionStoreStub(rows), # type: ignore[arg-type] + user_id=UUID("11111111-1111-4111-8111-111111111111"), + request=ContinuityResumptionBriefRequestInput( + thread_id=thread_id, + max_recent_changes=5, + max_open_loops=2, + ), + ) + + brief = payload["brief"] + assert brief["last_decision"]["item"] is not None + assert brief["last_decision"]["item"]["title"] == "Decision: active latest decision" + assert [item["title"] for item in brief["recent_changes"]["items"]] == [ + "Decision: active latest decision", + "Decision: superseded old decision", + ] + + +def test_resumption_brief_excludes_completed_and_stale_from_primary_open_loop_sections() -> None: + thread_id = UUID("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") + rows = [ + make_candidate_row( + title="Waiting For: completed item", + object_type="WaitingFor", + capture_created_at=datetime(2026, 3, 29, 10, 0, tzinfo=UTC), + provenance={"thread_id": str(thread_id)}, + status="completed", + ), + make_candidate_row( + title="Waiting For: deferred stale item", + object_type="WaitingFor", + capture_created_at=datetime(2026, 3, 29, 10, 1, tzinfo=UTC), + provenance={"thread_id": str(thread_id)}, + status="stale", + ), + make_candidate_row( + title="Waiting For: still blocked active item", + object_type="WaitingFor", + capture_created_at=datetime(2026, 3, 29, 10, 2, tzinfo=UTC), + provenance={"thread_id": str(thread_id)}, + status="active", + ), + make_candidate_row( + title="Next Action: done item", + object_type="NextAction", + capture_created_at=datetime(2026, 3, 29, 10, 3, tzinfo=UTC), + provenance={"thread_id": str(thread_id)}, + status="completed", + ), + ] + + payload = compile_continuity_resumption_brief( + ContinuityResumptionStoreStub(rows), # type: ignore[arg-type] + user_id=UUID("11111111-1111-4111-8111-111111111111"), + request=ContinuityResumptionBriefRequestInput( + thread_id=thread_id, + max_recent_changes=5, + max_open_loops=5, + ), + ) + + brief = payload["brief"] + assert [item["title"] for item in brief["open_loops"]["items"]] == [ + "Waiting For: still blocked active item", + ] + assert brief["next_action"]["item"] is None + assert [item["title"] for item in brief["recent_changes"]["items"]] == [ + "Next Action: done item", + "Waiting For: still blocked active item", + "Waiting For: deferred stale item", + "Waiting For: completed item", + ] + + +def test_resumption_brief_excludes_non_promotable_memory_facts_by_default_but_can_override() -> None: + thread_id = UUID("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") + rows = [ + make_candidate_row( + title="Memory Fact: searchable but not promotable", + object_type="MemoryFact", + capture_created_at=datetime(2026, 3, 29, 10, 0, tzinfo=UTC), + provenance={"thread_id": str(thread_id)}, + is_promotable=False, + ), + make_candidate_row( + title="Decision: still visible", + object_type="Decision", + capture_created_at=datetime(2026, 3, 29, 10, 5, tzinfo=UTC), + provenance={"thread_id": str(thread_id)}, + ), + ] + + default_payload = compile_continuity_resumption_brief( + ContinuityResumptionStoreStub(rows), # type: ignore[arg-type] + user_id=UUID("11111111-1111-4111-8111-111111111111"), + request=ContinuityResumptionBriefRequestInput( + thread_id=thread_id, + max_recent_changes=5, + max_open_loops=2, + ), + ) + override_payload = compile_continuity_resumption_brief( + ContinuityResumptionStoreStub(rows), # type: ignore[arg-type] + user_id=UUID("11111111-1111-4111-8111-111111111111"), + request=ContinuityResumptionBriefRequestInput( + thread_id=thread_id, + max_recent_changes=5, + max_open_loops=2, + include_non_promotable_facts=True, + ), + ) + + assert [item["title"] for item in default_payload["brief"]["recent_changes"]["items"]] == [ + "Decision: still visible", + ] + assert [item["title"] for item in override_payload["brief"]["recent_changes"]["items"]] == [ + "Decision: still visible", + "Memory Fact: searchable but not promotable", + ] diff --git a/tests/unit/test_continuity_review.py b/tests/unit/test_continuity_review.py new file mode 100644 index 0000000..c967b55 --- /dev/null +++ b/tests/unit/test_continuity_review.py @@ -0,0 +1,365 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import pytest + +from alicebot_api.continuity_review import ( + ContinuityReviewNotFoundError, + ContinuityReviewValidationError, + apply_continuity_correction, + get_continuity_review_detail, + list_continuity_review_queue, +) +from alicebot_api.contracts import ContinuityCorrectionInput, ContinuityReviewQueueQueryInput + + +class ContinuityReviewStoreStub: + def __init__(self) -> None: + self.base_time = datetime(2026, 3, 30, 9, 0, tzinfo=UTC) + self.objects: dict[UUID, dict[str, object]] = {} + self.events_by_object: dict[UUID, list[dict[str, object]]] = {} + self.call_log: list[str] = [] + + def add_object( + self, + *, + title: str, + status: str = "active", + object_type: str = "Decision", + last_confirmed_at: datetime | None = None, + supersedes_object_id: UUID | None = None, + superseded_by_object_id: UUID | None = None, + ) -> dict[str, object]: + object_id = uuid4() + capture_event_id = uuid4() + row = { + "id": object_id, + "user_id": UUID("11111111-1111-4111-8111-111111111111"), + "capture_event_id": capture_event_id, + "object_type": object_type, + "status": status, + "is_preserved": True, + "is_searchable": True, + "is_promotable": True, + "title": title, + "body": {"text": title}, + "provenance": {"capture_event_id": str(capture_event_id)}, + "confidence": 0.9, + "last_confirmed_at": last_confirmed_at, + "supersedes_object_id": supersedes_object_id, + "superseded_by_object_id": superseded_by_object_id, + "created_at": self.base_time, + "updated_at": self.base_time, + } + self.objects[object_id] = row + self.events_by_object.setdefault(object_id, []) + return row + + def get_continuity_object_optional(self, continuity_object_id: UUID): + row = self.objects.get(continuity_object_id) + if row is None: + return None + return dict(row) + + def list_continuity_review_queue(self, *, statuses: list[str], limit: int): + filtered = [ + dict(row) + for row in self.objects.values() + if row["status"] in statuses + ] + filtered.sort(key=lambda item: (item["updated_at"], item["created_at"], item["id"]), reverse=True) + return filtered[:limit] + + def count_continuity_review_queue(self, *, statuses: list[str]) -> int: + return sum(1 for row in self.objects.values() if row["status"] in statuses) + + def list_continuity_correction_events(self, *, continuity_object_id: UUID, limit: int): + events = [dict(item) for item in self.events_by_object.get(continuity_object_id, [])] + events.sort(key=lambda item: (item["created_at"], item["id"]), reverse=True) + return events[:limit] + + def create_continuity_correction_event( + self, + *, + continuity_object_id: UUID, + action: str, + reason: str | None, + before_snapshot, + after_snapshot, + payload, + ): + self.call_log.append("create_event") + event = { + "id": uuid4(), + "user_id": UUID("11111111-1111-4111-8111-111111111111"), + "continuity_object_id": continuity_object_id, + "action": action, + "reason": reason, + "before_snapshot": before_snapshot, + "after_snapshot": after_snapshot, + "payload": payload, + "created_at": self.base_time, + } + self.events_by_object.setdefault(continuity_object_id, []).append(event) + return dict(event) + + def update_continuity_object_optional( + self, + *, + continuity_object_id: UUID, + status: str, + is_preserved: bool, + is_searchable: bool, + is_promotable: bool, + title: str, + body, + provenance, + confidence: float, + last_confirmed_at: datetime | None, + supersedes_object_id: UUID | None, + superseded_by_object_id: UUID | None, + ): + self.call_log.append("update_object") + row = self.objects.get(continuity_object_id) + if row is None: + return None + row.update( + { + "status": status, + "is_preserved": is_preserved, + "is_searchable": is_searchable, + "is_promotable": is_promotable, + "title": title, + "body": body, + "provenance": provenance, + "confidence": confidence, + "last_confirmed_at": last_confirmed_at, + "supersedes_object_id": supersedes_object_id, + "superseded_by_object_id": superseded_by_object_id, + "updated_at": self.base_time, + } + ) + return dict(row) + + def create_continuity_capture_event( + self, + *, + raw_content: str, + explicit_signal: str | None, + admission_posture: str, + admission_reason: str, + ): + self.call_log.append("create_capture") + return { + "id": uuid4(), + "user_id": UUID("11111111-1111-4111-8111-111111111111"), + "raw_content": raw_content, + "explicit_signal": explicit_signal, + "admission_posture": admission_posture, + "admission_reason": admission_reason, + "created_at": self.base_time, + } + + def create_continuity_object( + self, + *, + capture_event_id: UUID, + object_type: str, + status: str, + title: str, + body, + provenance, + confidence: float, + is_preserved: bool = True, + is_searchable: bool = True, + is_promotable: bool = True, + last_confirmed_at: datetime | None = None, + supersedes_object_id: UUID | None = None, + superseded_by_object_id: UUID | None = None, + ): + self.call_log.append("create_object") + object_id = uuid4() + row = { + "id": object_id, + "user_id": UUID("11111111-1111-4111-8111-111111111111"), + "capture_event_id": capture_event_id, + "object_type": object_type, + "status": status, + "is_preserved": is_preserved, + "is_searchable": is_searchable, + "is_promotable": is_promotable, + "title": title, + "body": body, + "provenance": provenance, + "confidence": confidence, + "last_confirmed_at": last_confirmed_at, + "supersedes_object_id": supersedes_object_id, + "superseded_by_object_id": superseded_by_object_id, + "created_at": self.base_time, + "updated_at": self.base_time, + } + self.objects[object_id] = row + self.events_by_object.setdefault(object_id, []) + return dict(row) + + +def test_review_queue_filters_correction_ready_statuses() -> None: + store = ContinuityReviewStoreStub() + store.add_object(title="Active", status="active") + store.add_object(title="Stale", status="stale") + store.add_object(title="Deleted", status="deleted") + + payload = list_continuity_review_queue( + store, # type: ignore[arg-type] + user_id=UUID("11111111-1111-4111-8111-111111111111"), + request=ContinuityReviewQueueQueryInput(status="correction_ready", limit=20), + ) + + assert sorted(item["status"] for item in payload["items"]) == ["active", "stale"] + assert payload["summary"] == { + "status": "correction_ready", + "limit": 20, + "returned_count": 2, + "total_count": 2, + "order": ["updated_at_desc", "created_at_desc", "id_desc"], + } + + +def test_confirm_records_event_before_lifecycle_mutation() -> None: + store = ContinuityReviewStoreStub() + row = store.add_object(title="Decision: Keep rollout phased", status="active") + + payload = apply_continuity_correction( + store, # type: ignore[arg-type] + user_id=UUID("11111111-1111-4111-8111-111111111111"), + continuity_object_id=row["id"], + request=ContinuityCorrectionInput(action="confirm", reason="Verified in review"), + ) + + assert store.call_log[:2] == ["create_event", "update_object"] + assert payload["continuity_object"]["status"] == "active" + assert payload["continuity_object"]["lifecycle"]["is_promotable"] is True + assert payload["continuity_object"]["last_confirmed_at"] is not None + assert payload["correction_event"]["action"] == "confirm" + assert payload["replacement_object"] is None + + +def test_edit_delete_and_mark_stale_are_deterministic() -> None: + store = ContinuityReviewStoreStub() + edit_row = store.add_object(title="Decision: Old", status="active") + stale_row = store.add_object(title="Decision: Fresh", status="active") + delete_row = store.add_object(title="Decision: Remove", status="stale") + + edited = apply_continuity_correction( + store, # type: ignore[arg-type] + user_id=UUID("11111111-1111-4111-8111-111111111111"), + continuity_object_id=edit_row["id"], + request=ContinuityCorrectionInput( + action="edit", + title="Decision: Updated", + body={"text": "Updated"}, + confidence=0.95, + ), + ) + marked_stale = apply_continuity_correction( + store, # type: ignore[arg-type] + user_id=UUID("11111111-1111-4111-8111-111111111111"), + continuity_object_id=stale_row["id"], + request=ContinuityCorrectionInput(action="mark_stale"), + ) + deleted = apply_continuity_correction( + store, # type: ignore[arg-type] + user_id=UUID("11111111-1111-4111-8111-111111111111"), + continuity_object_id=delete_row["id"], + request=ContinuityCorrectionInput(action="delete", reason="No longer valid"), + ) + + assert edited["continuity_object"]["title"] == "Decision: Updated" + assert edited["continuity_object"]["status"] == "active" + assert marked_stale["continuity_object"]["status"] == "stale" + assert deleted["continuity_object"]["status"] == "deleted" + + +def test_supersede_creates_replacement_and_preserves_chain_links() -> None: + store = ContinuityReviewStoreStub() + row = store.add_object(title="Decision: Legacy truth", status="active") + + payload = apply_continuity_correction( + store, # type: ignore[arg-type] + user_id=UUID("11111111-1111-4111-8111-111111111111"), + continuity_object_id=row["id"], + request=ContinuityCorrectionInput( + action="supersede", + reason="Contradicted by newer evidence", + replacement_title="Decision: New truth", + replacement_body={"decision_text": "New truth"}, + replacement_provenance={"thread_id": "thread-1"}, + replacement_confidence=0.97, + ), + ) + + assert store.call_log[:4] == [ + "create_event", + "create_capture", + "create_object", + "update_object", + ] + assert payload["continuity_object"]["status"] == "superseded" + assert payload["replacement_object"] is not None + assert payload["replacement_object"]["status"] == "active" + assert payload["replacement_object"]["supersedes_object_id"] == str(row["id"]) + assert payload["continuity_object"]["superseded_by_object_id"] == payload["replacement_object"]["id"] + + +def test_review_detail_exposes_supersession_chain_and_event_history() -> None: + store = ContinuityReviewStoreStub() + old_row = store.add_object(title="Decision: Old", status="superseded") + replacement_row = store.add_object( + title="Decision: New", + status="active", + supersedes_object_id=old_row["id"], + ) + old_row["superseded_by_object_id"] = replacement_row["id"] + store.objects[old_row["id"]] = old_row + + store.create_continuity_correction_event( + continuity_object_id=old_row["id"], + action="supersede", + reason="Updated truth", + before_snapshot={"status": "active"}, + after_snapshot={"status": "superseded"}, + payload={"replacement_title": "Decision: New"}, + ) + + detail = get_continuity_review_detail( + store, # type: ignore[arg-type] + user_id=UUID("11111111-1111-4111-8111-111111111111"), + continuity_object_id=old_row["id"], + ) + + assert detail["review"]["continuity_object"]["status"] == "superseded" + assert detail["review"]["supersession_chain"]["superseded_by"] is not None + assert detail["review"]["supersession_chain"]["superseded_by"]["id"] == str(replacement_row["id"]) + assert detail["review"]["correction_events"][0]["action"] == "supersede" + + +def test_review_rejects_invalid_transition_and_missing_rows() -> None: + store = ContinuityReviewStoreStub() + deleted_row = store.add_object(title="Decision: Removed", status="deleted") + + with pytest.raises(ContinuityReviewValidationError, match="confirm requires an active or stale"): + apply_continuity_correction( + store, # type: ignore[arg-type] + user_id=UUID("11111111-1111-4111-8111-111111111111"), + continuity_object_id=deleted_row["id"], + request=ContinuityCorrectionInput(action="confirm"), + ) + + with pytest.raises(ContinuityReviewNotFoundError, match="was not found"): + get_continuity_review_detail( + store, # type: ignore[arg-type] + user_id=UUID("11111111-1111-4111-8111-111111111111"), + continuity_object_id=uuid4(), + ) diff --git a/tests/unit/test_control_doc_truth.py b/tests/unit/test_control_doc_truth.py new file mode 100644 index 0000000..1f4094a --- /dev/null +++ b/tests/unit/test_control_doc_truth.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from pathlib import Path + +import scripts.check_control_doc_truth as control_doc_truth + + +def _seed_truth_docs(tmp_path: Path) -> None: + for rule in control_doc_truth.CONTROL_DOC_TRUTH_RULES: + doc_path = tmp_path / rule.relative_path + doc_path.parent.mkdir(parents=True, exist_ok=True) + doc_path.write_text("\n".join(rule.required_markers) + "\n", encoding="utf-8") + + +def test_control_doc_truth_passes_with_required_markers() -> None: + repo_root = Path(__file__).resolve().parents[2] + + issues = control_doc_truth.run_control_doc_truth_check(root_dir=repo_root) + + assert issues == [] + + +def test_control_doc_truth_fails_when_required_marker_is_missing(tmp_path: Path) -> None: + _seed_truth_docs(tmp_path) + first_rule = control_doc_truth.CONTROL_DOC_TRUTH_RULES[0] + first_doc_path = tmp_path / first_rule.relative_path + first_doc_path.write_text("missing required baseline marker\n", encoding="utf-8") + + issues = control_doc_truth.run_control_doc_truth_check(root_dir=tmp_path) + + assert any( + issue == f"{first_rule.relative_path}: missing required marker '{first_rule.required_markers[0]}'" + for issue in issues + ) + + +def test_control_doc_truth_fails_when_disallowed_marker_is_present(tmp_path: Path) -> None: + _seed_truth_docs(tmp_path) + target_rule = next( + rule + for rule in control_doc_truth.CONTROL_DOC_TRUTH_RULES + if rule.relative_path == "ROADMAP.md" + ) + target_path = tmp_path / target_rule.relative_path + target_path.write_text( + target_path.read_text(encoding="utf-8") + + "\nGate ownership is canonicalized to Phase 4 runner scripts.\n", + encoding="utf-8", + ) + + issues = control_doc_truth.run_control_doc_truth_check(root_dir=tmp_path) + + assert any( + issue == f"{target_rule.relative_path}: contains disallowed marker 'Gate ownership is canonicalized to Phase 4 runner scripts'" + for issue in issues + ) + + +def test_control_doc_truth_fails_when_archive_index_is_missing(tmp_path: Path) -> None: + _seed_truth_docs(tmp_path) + archive_rule = next( + rule + for rule in control_doc_truth.CONTROL_DOC_TRUTH_RULES + if rule.relative_path == "docs/archive/planning/2026-04-08-context-compaction/README.md" + ) + (tmp_path / archive_rule.relative_path).unlink() + + issues = control_doc_truth.run_control_doc_truth_check(root_dir=tmp_path) + + assert any(issue == f"{archive_rule.relative_path}: missing file" for issue in issues) + + +def test_control_doc_truth_fails_when_stale_legacy_marker_is_present(tmp_path: Path) -> None: + _seed_truth_docs(tmp_path) + target_rule = next( + rule + for rule in control_doc_truth.CONTROL_DOC_TRUTH_RULES + if rule.relative_path == "README.md" + ) + target_path = tmp_path / target_rule.relative_path + target_path.write_text( + target_path.read_text(encoding="utf-8") + "\nLegacy Compatibility Markers still apply here.\n", + encoding="utf-8", + ) + + issues = control_doc_truth.run_control_doc_truth_check(root_dir=tmp_path) + + assert any( + issue == f"{target_rule.relative_path}: contains disallowed marker 'Legacy Compatibility Markers'" + for issue in issues + ) diff --git a/tests/unit/test_db.py b/tests/unit/test_db.py new file mode 100644 index 0000000..95559eb --- /dev/null +++ b/tests/unit/test_db.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +from collections.abc import Iterator +from uuid import uuid4 + +import psycopg + +from alicebot_api import db + + +class RecordingCursor: + def __init__(self) -> None: + self.executed: list[tuple[str, tuple[object, ...] | None]] = [] + + def __enter__(self) -> "RecordingCursor": + return self + + def __exit__(self, exc_type, exc, tb) -> None: + return None + + def execute(self, query: str, params: tuple[object, ...] | None = None) -> None: + self.executed.append((query, params)) + + def fetchone(self) -> tuple[int]: + return (1,) + + +class TransactionContext: + def __init__(self) -> None: + self.entered = False + self.exited = False + + def __enter__(self) -> None: + self.entered = True + return None + + def __exit__(self, exc_type, exc, tb) -> None: + self.exited = True + return None + + +class RecordingConnection: + def __init__(self) -> None: + self.cursor_instance = RecordingCursor() + self.transaction_context = TransactionContext() + + def __enter__(self) -> "RecordingConnection": + return self + + def __exit__(self, exc_type, exc, tb) -> None: + return None + + def cursor(self) -> RecordingCursor: + return self.cursor_instance + + def transaction(self) -> TransactionContext: + return self.transaction_context + + +def test_ping_database_returns_true_when_select_succeeds(monkeypatch) -> None: + connection = RecordingConnection() + captured: dict[str, object] = {} + + def fake_connect(database_url: str, **kwargs: object) -> RecordingConnection: + captured["database_url"] = database_url + captured["kwargs"] = kwargs + return connection + + monkeypatch.setattr(db.psycopg, "connect", fake_connect) + + assert db.ping_database("postgresql://example", timeout_seconds=3) is True + assert captured["database_url"] == "postgresql://example" + assert captured["kwargs"] == {"connect_timeout": 3} + assert connection.cursor_instance.executed == [("SELECT 1", None)] + + +def test_ping_database_returns_false_on_psycopg_error(monkeypatch) -> None: + def fake_connect(_database_url: str, **_kwargs: object) -> RecordingConnection: + raise psycopg.Error("boom") + + monkeypatch.setattr(db.psycopg, "connect", fake_connect) + + assert db.ping_database("postgresql://example", timeout_seconds=3) is False + + +def test_set_current_user_sets_database_context() -> None: + connection = RecordingConnection() + user_id = uuid4() + + db.set_current_user(connection, user_id) + + assert connection.cursor_instance.executed == [ + ("SELECT set_config('app.current_user_id', %s, true)", (str(user_id),)), + ] + + +def test_user_connection_sets_current_user_inside_transaction(monkeypatch) -> None: + connection = RecordingConnection() + user_id = uuid4() + captured: dict[str, object] = {} + set_current_user_calls: list[tuple[RecordingConnection, object]] = [] + + def fake_connect(database_url: str, **kwargs: object) -> RecordingConnection: + captured["database_url"] = database_url + captured["kwargs"] = kwargs + return connection + + def fake_set_current_user(conn: RecordingConnection, current_user_id: object) -> None: + set_current_user_calls.append((conn, current_user_id)) + + monkeypatch.setattr(db.psycopg, "connect", fake_connect) + monkeypatch.setattr(db, "set_current_user", fake_set_current_user) + + with db.user_connection("postgresql://example", user_id) as conn: + assert conn is connection + assert connection.transaction_context.entered is True + assert connection.transaction_context.exited is False + + assert captured["database_url"] == "postgresql://example" + assert captured["kwargs"] == {"row_factory": db.dict_row} + assert set_current_user_calls == [(connection, user_id)] + assert connection.transaction_context.exited is True diff --git a/tests/unit/test_embedding.py b/tests/unit/test_embedding.py new file mode 100644 index 0000000..44401d4 --- /dev/null +++ b/tests/unit/test_embedding.py @@ -0,0 +1,437 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from uuid import UUID, uuid4 + +import psycopg +import pytest + +from alicebot_api.contracts import EmbeddingConfigCreateInput, MemoryEmbeddingUpsertInput +from alicebot_api.embedding import ( + EmbeddingConfigValidationError, + MemoryEmbeddingNotFoundError, + MemoryEmbeddingValidationError, + create_embedding_config_record, + get_memory_embedding_record, + list_embedding_config_records, + list_memory_embedding_records, + upsert_memory_embedding_record, +) + + +class EmbeddingStoreStub: + def __init__(self) -> None: + self.base_time = datetime(2026, 3, 12, 9, 0, tzinfo=UTC) + self.memories: dict[UUID, dict[str, object]] = {} + self.configs: list[dict[str, object]] = [] + self.config_by_id: dict[UUID, dict[str, object]] = {} + self.embeddings: list[dict[str, object]] = [] + self.embedding_by_id: dict[UUID, dict[str, object]] = {} + + def create_embedding_config( + self, + *, + provider: str, + model: str, + version: str, + dimensions: int, + status: str, + metadata: dict[str, object], + ) -> dict[str, object]: + config_id = uuid4() + record = { + "id": config_id, + "user_id": uuid4(), + "provider": provider, + "model": model, + "version": version, + "dimensions": dimensions, + "status": status, + "metadata": metadata, + "created_at": self.base_time + timedelta(minutes=len(self.configs)), + } + self.configs.append(record) + self.config_by_id[config_id] = record + return record + + def list_embedding_configs(self) -> list[dict[str, object]]: + return list(self.configs) + + def get_embedding_config_optional(self, embedding_config_id: UUID) -> dict[str, object] | None: + return self.config_by_id.get(embedding_config_id) + + def get_embedding_config_by_identity_optional( + self, + *, + provider: str, + model: str, + version: str, + ) -> dict[str, object] | None: + for config in self.configs: + if ( + config["provider"] == provider + and config["model"] == model + and config["version"] == version + ): + return config + return None + + def get_memory_optional(self, memory_id: UUID) -> dict[str, object] | None: + return self.memories.get(memory_id) + + def get_memory_embedding_by_memory_and_config_optional( + self, + *, + memory_id: UUID, + embedding_config_id: UUID, + ) -> dict[str, object] | None: + for embedding in self.embeddings: + if ( + embedding["memory_id"] == memory_id + and embedding["embedding_config_id"] == embedding_config_id + ): + return embedding + return None + + def create_memory_embedding( + self, + *, + memory_id: UUID, + embedding_config_id: UUID, + dimensions: int, + vector: list[float], + ) -> dict[str, object]: + embedding_id = uuid4() + record = { + "id": embedding_id, + "user_id": uuid4(), + "memory_id": memory_id, + "embedding_config_id": embedding_config_id, + "dimensions": dimensions, + "vector": vector, + "created_at": self.base_time + timedelta(minutes=len(self.embeddings)), + "updated_at": self.base_time + timedelta(minutes=len(self.embeddings)), + } + self.embeddings.append(record) + self.embedding_by_id[embedding_id] = record + return record + + def update_memory_embedding( + self, + *, + memory_embedding_id: UUID, + dimensions: int, + vector: list[float], + ) -> dict[str, object]: + record = self.embedding_by_id[memory_embedding_id] + updated = { + **record, + "dimensions": dimensions, + "vector": vector, + "updated_at": self.base_time + timedelta(minutes=10), + } + self.embedding_by_id[memory_embedding_id] = updated + for index, existing in enumerate(self.embeddings): + if existing["id"] == memory_embedding_id: + self.embeddings[index] = updated + return updated + + def get_memory_embedding_optional(self, memory_embedding_id: UUID) -> dict[str, object] | None: + return self.embedding_by_id.get(memory_embedding_id) + + def list_memory_embeddings_for_memory(self, memory_id: UUID) -> list[dict[str, object]]: + return [embedding for embedding in self.embeddings if embedding["memory_id"] == memory_id] + + +def seed_memory(store: EmbeddingStoreStub) -> UUID: + memory_id = uuid4() + store.memories[memory_id] = { + "id": memory_id, + "memory_key": "user.preference.coffee", + } + return memory_id + + +def seed_config(store: EmbeddingStoreStub, *, dimensions: int = 3) -> UUID: + created = store.create_embedding_config( + provider="openai", + model="text-embedding-3-large", + version="2026-03-12", + dimensions=dimensions, + status="active", + metadata={"task": "memory_retrieval"}, + ) + return created["id"] # type: ignore[return-value] + + +def test_create_and_list_embedding_configs_return_deterministic_shape() -> None: + store = EmbeddingStoreStub() + first = create_embedding_config_record( + store, # type: ignore[arg-type] + user_id=uuid4(), + config=EmbeddingConfigCreateInput( + provider="openai", + model="text-embedding-3-small", + version="2026-03-11", + dimensions=1536, + status="active", + metadata={"task": "memory_retrieval"}, + ), + ) + second = create_embedding_config_record( + store, # type: ignore[arg-type] + user_id=uuid4(), + config=EmbeddingConfigCreateInput( + provider="openai", + model="text-embedding-3-large", + version="2026-03-12", + dimensions=3072, + status="deprecated", + metadata={"task": "memory_retrieval"}, + ), + ) + + payload = list_embedding_config_records( + store, # type: ignore[arg-type] + user_id=uuid4(), + ) + + assert first["embedding_config"]["provider"] == "openai" + assert second["embedding_config"]["status"] == "deprecated" + assert payload == { + "items": [ + first["embedding_config"], + second["embedding_config"], + ], + "summary": { + "total_count": 2, + "order": ["created_at_asc", "id_asc"], + }, + } + + +def test_create_embedding_config_rejects_duplicate_provider_model_version() -> None: + store = EmbeddingStoreStub() + create_embedding_config_record( + store, # type: ignore[arg-type] + user_id=uuid4(), + config=EmbeddingConfigCreateInput( + provider="openai", + model="text-embedding-3-large", + version="2026-03-12", + dimensions=3072, + status="active", + metadata={"task": "memory_retrieval"}, + ), + ) + + with pytest.raises( + EmbeddingConfigValidationError, + match="embedding config already exists for provider/model/version under the user scope", + ): + create_embedding_config_record( + store, # type: ignore[arg-type] + user_id=uuid4(), + config=EmbeddingConfigCreateInput( + provider="openai", + model="text-embedding-3-large", + version="2026-03-12", + dimensions=3072, + status="active", + metadata={"task": "memory_retrieval"}, + ), + ) + + +def test_create_embedding_config_translates_database_unique_violation_into_validation_error() -> None: + class DuplicateConfigStoreStub(EmbeddingStoreStub): + def get_embedding_config_by_identity_optional( + self, + *, + provider: str, + model: str, + version: str, + ) -> dict[str, object] | None: + return None + + def create_embedding_config( + self, + *, + provider: str, + model: str, + version: str, + dimensions: int, + status: str, + metadata: dict[str, object], + ) -> dict[str, object]: + raise psycopg.errors.UniqueViolation("duplicate key value violates unique constraint") + + with pytest.raises( + EmbeddingConfigValidationError, + match="embedding config already exists for provider/model/version under the user scope", + ): + create_embedding_config_record( + DuplicateConfigStoreStub(), # type: ignore[arg-type] + user_id=uuid4(), + config=EmbeddingConfigCreateInput( + provider="openai", + model="text-embedding-3-large", + version="2026-03-12", + dimensions=3072, + status="active", + metadata={"task": "memory_retrieval"}, + ), + ) + + +def test_upsert_memory_embedding_creates_then_updates_existing_record() -> None: + store = EmbeddingStoreStub() + memory_id = seed_memory(store) + config_id = seed_config(store, dimensions=3) + + created = upsert_memory_embedding_record( + store, # type: ignore[arg-type] + user_id=uuid4(), + request=MemoryEmbeddingUpsertInput( + memory_id=memory_id, + embedding_config_id=config_id, + vector=(0.1, 0.2, 0.3), + ), + ) + updated = upsert_memory_embedding_record( + store, # type: ignore[arg-type] + user_id=uuid4(), + request=MemoryEmbeddingUpsertInput( + memory_id=memory_id, + embedding_config_id=config_id, + vector=(0.3, 0.2, 0.1), + ), + ) + + assert created["write_mode"] == "created" + assert created["embedding"]["vector"] == [0.1, 0.2, 0.3] + assert updated["write_mode"] == "updated" + assert updated["embedding"]["id"] == created["embedding"]["id"] + assert updated["embedding"]["vector"] == [0.3, 0.2, 0.1] + + +def test_upsert_memory_embedding_rejects_missing_memory() -> None: + store = EmbeddingStoreStub() + config_id = seed_config(store) + + with pytest.raises( + MemoryEmbeddingValidationError, + match="memory_id must reference an existing memory owned by the user", + ): + upsert_memory_embedding_record( + store, # type: ignore[arg-type] + user_id=uuid4(), + request=MemoryEmbeddingUpsertInput( + memory_id=uuid4(), + embedding_config_id=config_id, + vector=(0.1, 0.2, 0.3), + ), + ) + + +def test_upsert_memory_embedding_rejects_missing_embedding_config() -> None: + store = EmbeddingStoreStub() + memory_id = seed_memory(store) + + with pytest.raises( + MemoryEmbeddingValidationError, + match="embedding_config_id must reference an existing embedding config owned by the user", + ): + upsert_memory_embedding_record( + store, # type: ignore[arg-type] + user_id=uuid4(), + request=MemoryEmbeddingUpsertInput( + memory_id=memory_id, + embedding_config_id=uuid4(), + vector=(0.1, 0.2, 0.3), + ), + ) + + +def test_upsert_memory_embedding_rejects_dimension_mismatch_and_non_finite_values() -> None: + store = EmbeddingStoreStub() + memory_id = seed_memory(store) + config_id = seed_config(store, dimensions=2) + + with pytest.raises( + MemoryEmbeddingValidationError, + match="vector length must match embedding config dimensions", + ): + upsert_memory_embedding_record( + store, # type: ignore[arg-type] + user_id=uuid4(), + request=MemoryEmbeddingUpsertInput( + memory_id=memory_id, + embedding_config_id=config_id, + vector=(0.1, 0.2, 0.3), + ), + ) + + with pytest.raises( + MemoryEmbeddingValidationError, + match="vector must contain only finite numeric values", + ): + upsert_memory_embedding_record( + store, # type: ignore[arg-type] + user_id=uuid4(), + request=MemoryEmbeddingUpsertInput( + memory_id=memory_id, + embedding_config_id=config_id, + vector=(0.1, float("inf")), + ), + ) + + +def test_memory_embedding_reads_return_deterministic_shape_and_not_found() -> None: + store = EmbeddingStoreStub() + memory_id = seed_memory(store) + config_id = seed_config(store, dimensions=3) + created = upsert_memory_embedding_record( + store, # type: ignore[arg-type] + user_id=uuid4(), + request=MemoryEmbeddingUpsertInput( + memory_id=memory_id, + embedding_config_id=config_id, + vector=(0.1, 0.2, 0.3), + ), + ) + + listed = list_memory_embedding_records( + store, # type: ignore[arg-type] + user_id=uuid4(), + memory_id=memory_id, + ) + detail = get_memory_embedding_record( + store, # type: ignore[arg-type] + user_id=uuid4(), + memory_embedding_id=UUID(created["embedding"]["id"]), + ) + + assert listed == { + "items": [created["embedding"]], + "summary": { + "memory_id": str(memory_id), + "total_count": 1, + "order": ["created_at_asc", "id_asc"], + }, + } + assert detail == {"embedding": created["embedding"]} + + with pytest.raises(MemoryEmbeddingNotFoundError, match="memory .* was not found"): + list_memory_embedding_records( + store, # type: ignore[arg-type] + user_id=uuid4(), + memory_id=uuid4(), + ) + + with pytest.raises(MemoryEmbeddingNotFoundError, match="memory embedding .* was not found"): + get_memory_embedding_record( + store, # type: ignore[arg-type] + user_id=uuid4(), + memory_embedding_id=uuid4(), + ) diff --git a/tests/unit/test_embedding_store.py b/tests/unit/test_embedding_store.py new file mode 100644 index 0000000..5a2b695 --- /dev/null +++ b/tests/unit/test_embedding_store.py @@ -0,0 +1,223 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any +from uuid import uuid4 + +from psycopg.types.json import Jsonb + +from alicebot_api.store import ContinuityStore + + +class RecordingCursor: + def __init__( + self, + fetchone_results: list[dict[str, Any]], + fetchall_results: list[list[dict[str, Any]]] | None = None, + ) -> None: + self.executed: list[tuple[str, tuple[object, ...] | None]] = [] + self.fetchone_results = list(fetchone_results) + self.fetchall_results = list(fetchall_results or []) + + def __enter__(self) -> "RecordingCursor": + return self + + def __exit__(self, exc_type, exc, tb) -> None: + return None + + def execute(self, query: str, params: tuple[object, ...] | None = None) -> None: + self.executed.append((query, params)) + + def fetchone(self) -> dict[str, Any] | None: + if not self.fetchone_results: + return None + return self.fetchone_results.pop(0) + + def fetchall(self) -> list[dict[str, Any]]: + if not self.fetchall_results: + return [] + return self.fetchall_results.pop(0) + + +class RecordingConnection: + def __init__(self, cursor: RecordingCursor) -> None: + self.cursor_instance = cursor + + def cursor(self) -> RecordingCursor: + return self.cursor_instance + + +def test_embedding_store_methods_use_expected_queries_and_serialization() -> None: + config_id = uuid4() + memory_id = uuid4() + embedding_id = uuid4() + created_at = datetime(2026, 3, 12, 9, 0, tzinfo=UTC) + updated_at = datetime(2026, 3, 12, 9, 5, tzinfo=UTC) + cursor = RecordingCursor( + fetchone_results=[ + { + "id": config_id, + "user_id": uuid4(), + "provider": "openai", + "model": "text-embedding-3-large", + "version": "2026-03-12", + "dimensions": 3, + "status": "active", + "metadata": {"task": "memory_retrieval"}, + "created_at": created_at, + }, + { + "id": embedding_id, + "user_id": uuid4(), + "memory_id": memory_id, + "embedding_config_id": config_id, + "dimensions": 3, + "vector": [0.1, 0.2, 0.3], + "created_at": created_at, + "updated_at": created_at, + }, + { + "id": embedding_id, + "user_id": uuid4(), + "memory_id": memory_id, + "embedding_config_id": config_id, + "dimensions": 3, + "vector": [0.3, 0.2, 0.1], + "created_at": created_at, + "updated_at": updated_at, + }, + ], + fetchall_results=[ + [ + { + "id": config_id, + "provider": "openai", + "version": "2026-03-12", + } + ], + [ + { + "id": embedding_id, + "memory_id": memory_id, + "embedding_config_id": config_id, + } + ], + [ + { + "id": memory_id, + "user_id": uuid4(), + "memory_key": "user.preference.coffee", + "value": {"likes": "oat milk"}, + "status": "active", + "source_event_ids": [str(uuid4())], + "created_at": created_at, + "updated_at": updated_at, + "deleted_at": None, + "score": 1.0, + } + ], + ], + ) + store = ContinuityStore(RecordingConnection(cursor)) + + created_config = store.create_embedding_config( + provider="openai", + model="text-embedding-3-large", + version="2026-03-12", + dimensions=3, + status="active", + metadata={"task": "memory_retrieval"}, + ) + listed_configs = store.list_embedding_configs() + created_embedding = store.create_memory_embedding( + memory_id=memory_id, + embedding_config_id=config_id, + dimensions=3, + vector=[0.1, 0.2, 0.3], + ) + updated_embedding = store.update_memory_embedding( + memory_embedding_id=embedding_id, + dimensions=3, + vector=[0.3, 0.2, 0.1], + ) + listed_embeddings = store.list_memory_embeddings_for_memory(memory_id) + retrieval_matches = store.retrieve_semantic_memory_matches( + embedding_config_id=config_id, + query_vector=[0.1, 0.2, 0.3], + limit=5, + ) + + assert created_config["id"] == config_id + assert listed_configs == [{"id": config_id, "provider": "openai", "version": "2026-03-12"}] + assert created_embedding["id"] == embedding_id + assert updated_embedding["updated_at"] == updated_at + assert listed_embeddings == [ + {"id": embedding_id, "memory_id": memory_id, "embedding_config_id": config_id} + ] + assert len(retrieval_matches) == 1 + assert retrieval_matches[0]["id"] == memory_id + assert retrieval_matches[0]["memory_key"] == "user.preference.coffee" + assert retrieval_matches[0]["status"] == "active" + assert retrieval_matches[0]["score"] == 1.0 + + create_config_query, create_config_params = cursor.executed[0] + assert "INSERT INTO embedding_configs" in create_config_query + assert create_config_params is not None + assert create_config_params[:5] == ( + "openai", + "text-embedding-3-large", + "2026-03-12", + 3, + "active", + ) + assert isinstance(create_config_params[5], Jsonb) + assert create_config_params[5].obj == {"task": "memory_retrieval"} + + list_config_query, list_config_params = cursor.executed[1] + assert "FROM embedding_configs" in list_config_query + assert "ORDER BY created_at ASC, id ASC" in list_config_query + assert list_config_params is None + + create_embedding_query, create_embedding_params = cursor.executed[2] + assert "INSERT INTO memory_embeddings" in create_embedding_query + assert create_embedding_params is not None + assert create_embedding_params[:3] == (memory_id, config_id, 3) + assert isinstance(create_embedding_params[3], Jsonb) + assert create_embedding_params[3].obj == [0.1, 0.2, 0.3] + + update_embedding_query, update_embedding_params = cursor.executed[3] + assert "UPDATE memory_embeddings" in update_embedding_query + assert update_embedding_params is not None + assert update_embedding_params[0] == 3 + assert isinstance(update_embedding_params[1], Jsonb) + assert update_embedding_params[1].obj == [0.3, 0.2, 0.1] + assert update_embedding_params[2] == embedding_id + + list_embedding_query, list_embedding_params = cursor.executed[4] + assert "FROM memory_embeddings" in list_embedding_query + assert "ORDER BY created_at ASC, id ASC" in list_embedding_query + assert list_embedding_params == (memory_id,) + + retrieval_query, retrieval_params = cursor.executed[5] + assert "replace(memory_embeddings.vector::text, ' ', '')::vector <=> %s::vector" in retrieval_query + assert "JOIN memories" in retrieval_query + assert "memories.status = 'active'" in retrieval_query + assert "ORDER BY score DESC, memories.created_at ASC, memories.id ASC" in retrieval_query + assert retrieval_params == ("[0.1,0.2,0.3]", config_id, 3, 5) + + +def test_embedding_store_optional_reads_return_none_when_row_is_missing() -> None: + cursor = RecordingCursor(fetchone_results=[]) + store = ContinuityStore(RecordingConnection(cursor)) + + assert store.get_embedding_config_optional(uuid4()) is None + assert store.get_embedding_config_by_identity_optional( + provider="openai", + model="text-embedding-3-large", + version="2026-03-12", + ) is None + assert store.get_memory_embedding_optional(uuid4()) is None + assert store.get_memory_embedding_by_memory_and_config_optional( + memory_id=uuid4(), + embedding_config_id=uuid4(), + ) is None diff --git a/tests/unit/test_entity.py b/tests/unit/test_entity.py new file mode 100644 index 0000000..711ac0a --- /dev/null +++ b/tests/unit/test_entity.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from uuid import UUID, uuid4 + +import pytest + +from alicebot_api.contracts import EntityCreateInput +from alicebot_api.entity import ( + EntityNotFoundError, + EntityValidationError, + create_entity_record, + get_entity_record, + list_entity_records, +) + + +class EntityStoreStub: + def __init__(self) -> None: + self.base_time = datetime(2026, 3, 12, 9, 0, tzinfo=UTC) + self.memories: dict[UUID, dict[str, object]] = {} + self.created_entities: list[dict[str, object]] = [] + self.entity_by_id: dict[UUID, dict[str, object]] = {} + + def list_memories_by_ids(self, memory_ids: list[UUID]) -> list[dict[str, object]]: + return [self.memories[memory_id] for memory_id in memory_ids if memory_id in self.memories] + + def create_entity( + self, + *, + entity_type: str, + name: str, + source_memory_ids: list[str], + ) -> dict[str, object]: + entity_id = uuid4() + entity = { + "id": entity_id, + "user_id": uuid4(), + "entity_type": entity_type, + "name": name, + "source_memory_ids": source_memory_ids, + "created_at": self.base_time + timedelta(minutes=len(self.created_entities)), + } + self.created_entities.append(entity) + self.entity_by_id[entity_id] = entity + return entity + + def list_entities(self) -> list[dict[str, object]]: + return list(self.created_entities) + + def get_entity_optional(self, entity_id: UUID) -> dict[str, object] | None: + return self.entity_by_id.get(entity_id) + + +def seed_memory(store: EntityStoreStub) -> UUID: + memory_id = uuid4() + store.memories[memory_id] = { + "id": memory_id, + "memory_key": "user.preference.coffee", + } + return memory_id + + +def test_create_entity_record_rejects_empty_source_memory_ids() -> None: + store = EntityStoreStub() + + with pytest.raises( + EntityValidationError, + match="source_memory_ids must include at least one existing memory owned by the user", + ): + create_entity_record( + store, # type: ignore[arg-type] + user_id=uuid4(), + entity=EntityCreateInput( + entity_type="person", + name="Alex", + source_memory_ids=(), + ), + ) + + +def test_create_entity_record_rejects_missing_source_memories() -> None: + store = EntityStoreStub() + + with pytest.raises( + EntityValidationError, + match="source_memory_ids must all reference existing memories owned by the user", + ): + create_entity_record( + store, # type: ignore[arg-type] + user_id=uuid4(), + entity=EntityCreateInput( + entity_type="project", + name="AliceBot", + source_memory_ids=(uuid4(),), + ), + ) + + +def test_create_entity_record_creates_entity_with_deduped_source_memories() -> None: + store = EntityStoreStub() + first_memory_id = seed_memory(store) + second_memory_id = seed_memory(store) + + payload = create_entity_record( + store, # type: ignore[arg-type] + user_id=uuid4(), + entity=EntityCreateInput( + entity_type="project", + name="AliceBot", + source_memory_ids=(first_memory_id, first_memory_id, second_memory_id), + ), + ) + + assert payload["entity"]["entity_type"] == "project" + assert payload["entity"]["name"] == "AliceBot" + assert payload["entity"]["source_memory_ids"] == [str(first_memory_id), str(second_memory_id)] + + +def test_list_entity_records_returns_deterministic_shape() -> None: + store = EntityStoreStub() + first_memory_id = seed_memory(store) + second_memory_id = seed_memory(store) + first_entity = store.create_entity( + entity_type="person", + name="Alex", + source_memory_ids=[str(first_memory_id)], + ) + second_entity = store.create_entity( + entity_type="project", + name="AliceBot", + source_memory_ids=[str(second_memory_id)], + ) + + payload = list_entity_records( + store, # type: ignore[arg-type] + user_id=uuid4(), + ) + + assert payload == { + "items": [ + { + "id": str(first_entity["id"]), + "entity_type": "person", + "name": "Alex", + "source_memory_ids": [str(first_memory_id)], + "created_at": first_entity["created_at"].isoformat(), + }, + { + "id": str(second_entity["id"]), + "entity_type": "project", + "name": "AliceBot", + "source_memory_ids": [str(second_memory_id)], + "created_at": second_entity["created_at"].isoformat(), + }, + ], + "summary": { + "total_count": 2, + "order": ["created_at_asc", "id_asc"], + }, + } + + +def test_get_entity_record_raises_not_found_for_inaccessible_entity() -> None: + with pytest.raises(EntityNotFoundError, match="entity .* was not found"): + get_entity_record( + EntityStoreStub(), # type: ignore[arg-type] + user_id=uuid4(), + entity_id=uuid4(), + ) diff --git a/tests/unit/test_entity_edge.py b/tests/unit/test_entity_edge.py new file mode 100644 index 0000000..d30f376 --- /dev/null +++ b/tests/unit/test_entity_edge.py @@ -0,0 +1,231 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from uuid import UUID, uuid4 + +import pytest + +from alicebot_api.contracts import EntityEdgeCreateInput +from alicebot_api.entity import EntityNotFoundError +from alicebot_api.entity_edge import ( + EntityEdgeValidationError, + create_entity_edge_record, + list_entity_edge_records, +) + + +class EntityEdgeStoreStub: + def __init__(self) -> None: + self.base_time = datetime(2026, 3, 12, 9, 0, tzinfo=UTC) + self.memories: dict[UUID, dict[str, object]] = {} + self.entities: dict[UUID, dict[str, object]] = {} + self.created_edges: list[dict[str, object]] = [] + + def list_memories_by_ids(self, memory_ids: list[UUID]) -> list[dict[str, object]]: + return [self.memories[memory_id] for memory_id in memory_ids if memory_id in self.memories] + + def get_entity_optional(self, entity_id: UUID) -> dict[str, object] | None: + return self.entities.get(entity_id) + + def create_entity_edge( + self, + *, + from_entity_id: UUID, + to_entity_id: UUID, + relationship_type: str, + valid_from: datetime | None, + valid_to: datetime | None, + source_memory_ids: list[str], + ) -> dict[str, object]: + edge_id = uuid4() + edge = { + "id": edge_id, + "user_id": uuid4(), + "from_entity_id": from_entity_id, + "to_entity_id": to_entity_id, + "relationship_type": relationship_type, + "valid_from": valid_from, + "valid_to": valid_to, + "source_memory_ids": source_memory_ids, + "created_at": self.base_time + timedelta(minutes=len(self.created_edges)), + } + self.created_edges.append(edge) + return edge + + def list_entity_edges_for_entity(self, entity_id: UUID) -> list[dict[str, object]]: + return [ + edge + for edge in self.created_edges + if edge["from_entity_id"] == entity_id or edge["to_entity_id"] == entity_id + ] + + +def seed_memory(store: EntityEdgeStoreStub) -> UUID: + memory_id = uuid4() + store.memories[memory_id] = { + "id": memory_id, + "memory_key": "user.project.current", + } + return memory_id + + +def seed_entity(store: EntityEdgeStoreStub) -> UUID: + entity_id = uuid4() + store.entities[entity_id] = { + "id": entity_id, + "name": "entity", + } + return entity_id + + +def test_create_entity_edge_record_rejects_missing_entities() -> None: + store = EntityEdgeStoreStub() + memory_id = seed_memory(store) + + with pytest.raises( + EntityEdgeValidationError, + match="from_entity_id must reference an existing entity owned by the user", + ): + create_entity_edge_record( + store, # type: ignore[arg-type] + user_id=uuid4(), + edge=EntityEdgeCreateInput( + from_entity_id=uuid4(), + to_entity_id=uuid4(), + relationship_type="works_on", + valid_from=None, + valid_to=None, + source_memory_ids=(memory_id,), + ), + ) + + +def test_create_entity_edge_record_rejects_invalid_temporal_range() -> None: + store = EntityEdgeStoreStub() + from_entity_id = seed_entity(store) + to_entity_id = seed_entity(store) + memory_id = seed_memory(store) + + with pytest.raises( + EntityEdgeValidationError, + match="valid_to must be greater than or equal to valid_from", + ): + create_entity_edge_record( + store, # type: ignore[arg-type] + user_id=uuid4(), + edge=EntityEdgeCreateInput( + from_entity_id=from_entity_id, + to_entity_id=to_entity_id, + relationship_type="works_on", + valid_from=datetime(2026, 3, 12, 11, 0, tzinfo=UTC), + valid_to=datetime(2026, 3, 12, 10, 0, tzinfo=UTC), + source_memory_ids=(memory_id,), + ), + ) + + +def test_create_entity_edge_record_creates_edge_with_deduped_source_memories() -> None: + store = EntityEdgeStoreStub() + from_entity_id = seed_entity(store) + to_entity_id = seed_entity(store) + first_memory_id = seed_memory(store) + second_memory_id = seed_memory(store) + valid_from = datetime(2026, 3, 12, 9, 0, tzinfo=UTC) + valid_to = datetime(2026, 3, 12, 10, 0, tzinfo=UTC) + + payload = create_entity_edge_record( + store, # type: ignore[arg-type] + user_id=uuid4(), + edge=EntityEdgeCreateInput( + from_entity_id=from_entity_id, + to_entity_id=to_entity_id, + relationship_type="works_on", + valid_from=valid_from, + valid_to=valid_to, + source_memory_ids=(first_memory_id, first_memory_id, second_memory_id), + ), + ) + + assert payload == { + "edge": { + "id": payload["edge"]["id"], + "from_entity_id": str(from_entity_id), + "to_entity_id": str(to_entity_id), + "relationship_type": "works_on", + "valid_from": valid_from.isoformat(), + "valid_to": valid_to.isoformat(), + "source_memory_ids": [str(first_memory_id), str(second_memory_id)], + "created_at": store.created_edges[0]["created_at"].isoformat(), + } + } + + +def test_list_entity_edge_records_returns_deterministic_shape() -> None: + store = EntityEdgeStoreStub() + primary_entity_id = seed_entity(store) + secondary_entity_id = seed_entity(store) + tertiary_entity_id = seed_entity(store) + first_memory_id = seed_memory(store) + second_memory_id = seed_memory(store) + + first_edge = store.create_entity_edge( + from_entity_id=primary_entity_id, + to_entity_id=secondary_entity_id, + relationship_type="works_on", + valid_from=None, + valid_to=None, + source_memory_ids=[str(first_memory_id)], + ) + second_edge = store.create_entity_edge( + from_entity_id=tertiary_entity_id, + to_entity_id=primary_entity_id, + relationship_type="references", + valid_from=None, + valid_to=None, + source_memory_ids=[str(second_memory_id)], + ) + + payload = list_entity_edge_records( + store, # type: ignore[arg-type] + user_id=uuid4(), + entity_id=primary_entity_id, + ) + + assert payload == { + "items": [ + { + "id": str(first_edge["id"]), + "from_entity_id": str(primary_entity_id), + "to_entity_id": str(secondary_entity_id), + "relationship_type": "works_on", + "valid_from": None, + "valid_to": None, + "source_memory_ids": [str(first_memory_id)], + "created_at": first_edge["created_at"].isoformat(), + }, + { + "id": str(second_edge["id"]), + "from_entity_id": str(tertiary_entity_id), + "to_entity_id": str(primary_entity_id), + "relationship_type": "references", + "valid_from": None, + "valid_to": None, + "source_memory_ids": [str(second_memory_id)], + "created_at": second_edge["created_at"].isoformat(), + }, + ], + "summary": { + "entity_id": str(primary_entity_id), + "total_count": 2, + "order": ["created_at_asc", "id_asc"], + }, + } + + +def test_list_entity_edge_records_raises_not_found_for_inaccessible_entity() -> None: + with pytest.raises(EntityNotFoundError, match="entity .* was not found"): + list_entity_edge_records( + EntityEdgeStoreStub(), # type: ignore[arg-type] + user_id=uuid4(), + entity_id=uuid4(), + ) diff --git a/tests/unit/test_entity_store.py b/tests/unit/test_entity_store.py new file mode 100644 index 0000000..1e82c3e --- /dev/null +++ b/tests/unit/test_entity_store.py @@ -0,0 +1,224 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any +from uuid import uuid4 + +from psycopg.types.json import Jsonb + +from alicebot_api.store import ContinuityStore + + +class RecordingCursor: + def __init__( + self, + fetchone_results: list[dict[str, Any]], + fetchall_results: list[list[dict[str, Any]]] | None = None, + ) -> None: + self.executed: list[tuple[str, tuple[object, ...] | None]] = [] + self.fetchone_results = list(fetchone_results) + self.fetchall_results = list(fetchall_results or []) + + def __enter__(self) -> "RecordingCursor": + return self + + def __exit__(self, exc_type, exc, tb) -> None: + return None + + def execute(self, query: str, params: tuple[object, ...] | None = None) -> None: + self.executed.append((query, params)) + + def fetchone(self) -> dict[str, Any] | None: + if not self.fetchone_results: + return None + return self.fetchone_results.pop(0) + + def fetchall(self) -> list[dict[str, Any]]: + if not self.fetchall_results: + return [] + return self.fetchall_results.pop(0) + + +class RecordingConnection: + def __init__(self, cursor: RecordingCursor) -> None: + self.cursor_instance = cursor + + def cursor(self) -> RecordingCursor: + return self.cursor_instance + + +def test_entity_methods_use_expected_queries_and_deterministic_order() -> None: + entity_id = uuid4() + first_memory_id = uuid4() + second_memory_id = uuid4() + cursor = RecordingCursor( + fetchone_results=[ + { + "id": entity_id, + "user_id": uuid4(), + "entity_type": "project", + "name": "AliceBot", + "source_memory_ids": [str(first_memory_id), str(second_memory_id)], + "created_at": "ignored", + } + ], + fetchall_results=[ + [{"id": first_memory_id}, {"id": second_memory_id}], + [{"id": entity_id, "name": "AliceBot"}], + ], + ) + store = ContinuityStore(RecordingConnection(cursor)) + + created = store.create_entity( + entity_type="project", + name="AliceBot", + source_memory_ids=[str(first_memory_id), str(second_memory_id)], + ) + listed_memories = store.list_memories_by_ids([first_memory_id, second_memory_id]) + listed_entities = store.list_entities() + + assert created["id"] == entity_id + assert listed_memories == [{"id": first_memory_id}, {"id": second_memory_id}] + assert listed_entities == [{"id": entity_id, "name": "AliceBot"}] + + create_query, create_params = cursor.executed[0] + assert "INSERT INTO entities" in create_query + assert create_params is not None + assert create_params[0] == "project" + assert create_params[1] == "AliceBot" + assert isinstance(create_params[2], Jsonb) + assert create_params[2].obj == [str(first_memory_id), str(second_memory_id)] + + assert cursor.executed[1] == ( + """ + SELECT + id, + user_id, + agent_profile_id, + memory_key, + value, + status, + source_event_ids, + memory_type, + confidence, + salience, + confirmation_status, + valid_from, + valid_to, + last_confirmed_at, + created_at, + updated_at, + deleted_at + FROM memories + WHERE id = ANY(%s) + ORDER BY created_at ASC, id ASC + """, + ([first_memory_id, second_memory_id],), + ) + assert cursor.executed[2] == ( + """ + SELECT id, user_id, entity_type, name, source_memory_ids, created_at + FROM entities + ORDER BY created_at ASC, id ASC + """, + None, + ) + + +def test_get_entity_optional_returns_none_when_row_is_missing() -> None: + cursor = RecordingCursor(fetchone_results=[]) + store = ContinuityStore(RecordingConnection(cursor)) + + assert store.get_entity_optional(uuid4()) is None + + +def test_entity_edge_methods_use_expected_queries_and_deterministic_order() -> None: + edge_id = uuid4() + from_entity_id = uuid4() + to_entity_id = uuid4() + related_entity_id = uuid4() + source_memory_id = uuid4() + valid_from = datetime(2026, 3, 12, 10, 0, tzinfo=UTC) + cursor = RecordingCursor( + fetchone_results=[ + { + "id": edge_id, + "user_id": uuid4(), + "from_entity_id": from_entity_id, + "to_entity_id": to_entity_id, + "relationship_type": "works_on", + "valid_from": valid_from, + "valid_to": None, + "source_memory_ids": [str(source_memory_id)], + "created_at": "ignored", + } + ], + fetchall_results=[ + [{"id": edge_id, "relationship_type": "works_on"}], + [{"id": edge_id, "relationship_type": "works_on"}], + ], + ) + store = ContinuityStore(RecordingConnection(cursor)) + + created = store.create_entity_edge( + from_entity_id=from_entity_id, + to_entity_id=to_entity_id, + relationship_type="works_on", + valid_from=valid_from, + valid_to=None, + source_memory_ids=[str(source_memory_id)], + ) + listed_edges = store.list_entity_edges_for_entity(from_entity_id) + listed_edges_for_entities = store.list_entity_edges_for_entities([from_entity_id, related_entity_id]) + + assert created["id"] == edge_id + assert listed_edges == [{"id": edge_id, "relationship_type": "works_on"}] + assert listed_edges_for_entities == [{"id": edge_id, "relationship_type": "works_on"}] + + create_query, create_params = cursor.executed[0] + assert "INSERT INTO entity_edges" in create_query + assert create_params is not None + assert create_params[0] == from_entity_id + assert create_params[1] == to_entity_id + assert create_params[2] == "works_on" + assert create_params[3] == valid_from + assert create_params[4] is None + assert isinstance(create_params[5], Jsonb) + assert create_params[5].obj == [str(source_memory_id)] + + assert cursor.executed[1] == ( + """ + SELECT + id, + user_id, + from_entity_id, + to_entity_id, + relationship_type, + valid_from, + valid_to, + source_memory_ids, + created_at + FROM entity_edges + WHERE from_entity_id = %s OR to_entity_id = %s + ORDER BY created_at ASC, id ASC + """, + (from_entity_id, from_entity_id), + ) + assert cursor.executed[2] == ( + """ + SELECT + id, + user_id, + from_entity_id, + to_entity_id, + relationship_type, + valid_from, + valid_to, + source_memory_ids, + created_at + FROM entity_edges + WHERE from_entity_id = ANY(%s) OR to_entity_id = ANY(%s) + ORDER BY created_at ASC, id ASC + """, + ([from_entity_id, related_entity_id], [from_entity_id, related_entity_id]), + ) diff --git a/tests/unit/test_env.py b/tests/unit/test_env.py new file mode 100644 index 0000000..b0fdb49 --- /dev/null +++ b/tests/unit/test_env.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +from contextlib import contextmanager +import importlib +import sys +from typing import Any + + +MODULE_NAME = "apps.api.alembic.env" + + +class FakeAlembicConfig: + def __init__(self, sqlalchemy_url: str, section: dict[str, Any] | None = None) -> None: + self.config_file_name = "alembic.ini" + self.config_ini_section = "alembic" + self.sqlalchemy_url = sqlalchemy_url + self.section = section or {} + + def get_main_option(self, option: str) -> str: + assert option == "sqlalchemy.url" + return self.sqlalchemy_url + + def get_section(self, section_name: str, default: dict[str, Any] | None = None) -> dict[str, Any]: + assert section_name == self.config_ini_section + base = dict(default or {}) + base.update(self.section) + return base + + +class RecordingConnectable: + def __init__(self) -> None: + self.connection = object() + self.connected = False + + @contextmanager + def connect(self): + self.connected = True + yield self.connection + + +def load_env_module( + monkeypatch, + *, + offline_mode: bool, + admin_url: str | None = None, + app_url: str | None = None, + config_url: str = "postgresql://config-user:secret@localhost:5432/configdb", + config_section: dict[str, Any] | None = None, +) -> tuple[Any, dict[str, Any]]: + records: dict[str, Any] = { + "file_config_calls": [], + "configure_calls": [], + "run_migrations_calls": 0, + "begin_calls": 0, + "engine_calls": [], + } + fake_config = FakeAlembicConfig(config_url, config_section) + connectable = RecordingConnectable() + + if admin_url is None: + monkeypatch.delenv("DATABASE_ADMIN_URL", raising=False) + else: + monkeypatch.setenv("DATABASE_ADMIN_URL", admin_url) + if app_url is None: + monkeypatch.delenv("DATABASE_URL", raising=False) + else: + monkeypatch.setenv("DATABASE_URL", app_url) + + monkeypatch.setattr("logging.config.fileConfig", records["file_config_calls"].append) + monkeypatch.setattr("alembic.context.config", fake_config, raising=False) + monkeypatch.setattr("alembic.context.is_offline_mode", lambda: offline_mode, raising=False) + monkeypatch.setattr( + "alembic.context.configure", + lambda **kwargs: records["configure_calls"].append(kwargs), + raising=False, + ) + + @contextmanager + def begin_transaction(): + records["begin_calls"] += 1 + yield + + monkeypatch.setattr("alembic.context.begin_transaction", begin_transaction, raising=False) + monkeypatch.setattr( + "alembic.context.run_migrations", + lambda: records.__setitem__("run_migrations_calls", records["run_migrations_calls"] + 1), + raising=False, + ) + + def fake_engine_from_config(configuration: dict[str, Any], **kwargs: Any) -> RecordingConnectable: + records["engine_calls"].append((dict(configuration), kwargs)) + return connectable + + monkeypatch.setattr("sqlalchemy.engine_from_config", fake_engine_from_config) + + sys.modules.pop(MODULE_NAME, None) + module = importlib.import_module(MODULE_NAME) + records["connectable"] = connectable + return module, records + + +def test_normalize_sqlalchemy_url_rewrites_postgresql_scheme(monkeypatch) -> None: + module, _records = load_env_module(monkeypatch, offline_mode=True) + + assert module.normalize_sqlalchemy_url("postgresql://user:pw@localhost/db") == ( + "postgresql+psycopg://user:pw@localhost/db" + ) + assert module.normalize_sqlalchemy_url("sqlite:///tmp/test.db") == "sqlite:///tmp/test.db" + + +def test_get_url_prefers_admin_env_then_database_env_then_config(monkeypatch) -> None: + module, _records = load_env_module( + monkeypatch, + offline_mode=True, + admin_url="postgresql://admin-user:secret@localhost:5432/admin_db", + app_url="postgresql://app-user:secret@localhost:5432/app_db", + ) + + assert module.get_url() == "postgresql+psycopg://admin-user:secret@localhost:5432/admin_db" + + module, _records = load_env_module( + monkeypatch, + offline_mode=True, + admin_url=None, + app_url="postgresql://app-user:secret@localhost:5432/app_db", + ) + + assert module.get_url() == "postgresql+psycopg://app-user:secret@localhost:5432/app_db" + + module, _records = load_env_module(monkeypatch, offline_mode=True, admin_url=None, app_url=None) + + assert module.get_url() == "postgresql+psycopg://config-user:secret@localhost:5432/configdb" + + +def test_run_migrations_offline_configures_context_with_normalized_url(monkeypatch) -> None: + _module, records = load_env_module( + monkeypatch, + offline_mode=True, + admin_url="postgresql://admin-user:secret@localhost:5432/admin_db", + ) + + assert records["file_config_calls"] == ["alembic.ini"] + assert records["begin_calls"] == 1 + assert records["run_migrations_calls"] == 1 + assert records["configure_calls"] == [ + { + "url": "postgresql+psycopg://admin-user:secret@localhost:5432/admin_db", + "target_metadata": None, + "literal_binds": True, + "dialect_opts": {"paramstyle": "named"}, + } + ] + assert records["engine_calls"] == [] + + +def test_run_migrations_online_builds_engine_configuration(monkeypatch) -> None: + _module, records = load_env_module( + monkeypatch, + offline_mode=False, + app_url="postgresql://app-user:secret@localhost:5432/app_db", + config_section={"sqlalchemy.echo": "false"}, + ) + + configuration, engine_kwargs = records["engine_calls"][0] + + assert records["file_config_calls"] == ["alembic.ini"] + assert configuration == { + "sqlalchemy.echo": "false", + "sqlalchemy.url": "postgresql+psycopg://app-user:secret@localhost:5432/app_db", + } + assert engine_kwargs["prefix"] == "sqlalchemy." + assert engine_kwargs["poolclass"].__name__ == "NullPool" + assert records["connectable"].connected is True + assert records["configure_calls"] == [ + {"connection": records["connectable"].connection, "target_metadata": None} + ] + assert records["begin_calls"] == 1 + assert records["run_migrations_calls"] == 1 diff --git a/tests/unit/test_events.py b/tests/unit/test_events.py new file mode 100644 index 0000000..ec8d352 --- /dev/null +++ b/tests/unit/test_events.py @@ -0,0 +1,389 @@ +from __future__ import annotations + +from contextlib import contextmanager +from datetime import UTC, datetime, timedelta +import json +from uuid import UUID, uuid4 + +import pytest + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.store import AppendOnlyViolation, ContinuityStore + + +class ContinuityApiStoreStub: + def __init__(self, *, current_user_id: UUID) -> None: + self.current_user_id = current_user_id + self.base_time = datetime(2026, 3, 17, 9, 0, tzinfo=UTC) + self.agent_profiles = [ + { + "id": "assistant_default", + "name": "Assistant Default", + "description": "Default profile for tests", + "model_provider": None, + "model_name": None, + } + ] + self.threads: list[dict[str, object]] = [] + self.sessions: list[dict[str, object]] = [] + self.events: list[dict[str, object]] = [] + + def add_thread( + self, + *, + thread_id: UUID, + user_id: UUID, + title: str, + agent_profile_id: str = "assistant_default", + created_at: datetime | None = None, + updated_at: datetime | None = None, + ) -> dict[str, object]: + thread = { + "id": thread_id, + "user_id": user_id, + "title": title, + "agent_profile_id": agent_profile_id, + "created_at": created_at or self.base_time, + "updated_at": updated_at or created_at or self.base_time, + } + self.threads.append(thread) + return thread + + def add_session( + self, + *, + session_id: UUID, + user_id: UUID, + thread_id: UUID, + status: str, + started_at: datetime | None = None, + ended_at: datetime | None = None, + created_at: datetime | None = None, + ) -> dict[str, object]: + session = { + "id": session_id, + "user_id": user_id, + "thread_id": thread_id, + "status": status, + "started_at": started_at or self.base_time, + "ended_at": ended_at, + "created_at": created_at or started_at or self.base_time, + } + self.sessions.append(session) + return session + + def add_event( + self, + *, + event_id: UUID, + user_id: UUID, + thread_id: UUID, + session_id: UUID | None, + sequence_no: int, + kind: str, + payload: dict[str, object], + created_at: datetime | None = None, + ) -> dict[str, object]: + event = { + "id": event_id, + "user_id": user_id, + "thread_id": thread_id, + "session_id": session_id, + "sequence_no": sequence_no, + "kind": kind, + "payload": payload, + "created_at": created_at or self.base_time, + } + self.events.append(event) + return event + + def create_thread(self, title: str, agent_profile_id: str = "assistant_default") -> dict[str, object]: + created_at = self.base_time + timedelta(minutes=len(self.threads)) + return self.add_thread( + thread_id=uuid4(), + user_id=self.current_user_id, + title=title, + agent_profile_id=agent_profile_id, + created_at=created_at, + updated_at=created_at, + ) + + def list_threads(self) -> list[dict[str, object]]: + visible_threads = [ + thread for thread in self.threads if thread["user_id"] == self.current_user_id + ] + return sorted(visible_threads, key=lambda thread: (thread["created_at"], thread["id"]), reverse=True) + + def get_thread_optional(self, thread_id: UUID) -> dict[str, object] | None: + return next((thread for thread in self.list_threads() if thread["id"] == thread_id), None) + + def list_agent_profiles(self) -> list[dict[str, object]]: + return list(self.agent_profiles) + + def get_agent_profile_optional(self, profile_id: str) -> dict[str, object] | None: + return next( + (profile for profile in self.agent_profiles if profile["id"] == profile_id), + None, + ) + + def list_thread_sessions(self, thread_id: UUID) -> list[dict[str, object]]: + visible_sessions = [ + session + for session in self.sessions + if session["user_id"] == self.current_user_id and session["thread_id"] == thread_id + ] + return sorted( + visible_sessions, + key=lambda session: (session["started_at"], session["created_at"], session["id"]), + ) + + def list_thread_events(self, thread_id: UUID) -> list[dict[str, object]]: + visible_events = [ + event + for event in self.events + if event["user_id"] == self.current_user_id and event["thread_id"] == thread_id + ] + return sorted(visible_events, key=lambda event: (event["sequence_no"], event["id"])) + + +def install_continuity_api_stubs( + monkeypatch: pytest.MonkeyPatch, + stores: dict[UUID, ContinuityApiStoreStub], +) -> None: + settings = Settings(database_url="postgresql://app") + + class FakeConnection: + def __init__(self, current_user_id: UUID) -> None: + self.current_user_id = current_user_id + + @contextmanager + def fake_user_connection(database_url: str, current_user_id: UUID): + assert database_url == settings.database_url + yield FakeConnection(current_user_id) + + def fake_store_factory(conn: FakeConnection) -> ContinuityApiStoreStub: + return stores.setdefault( + conn.current_user_id, + ContinuityApiStoreStub(current_user_id=conn.current_user_id), + ) + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "ContinuityStore", fake_store_factory) + + +def test_event_updates_are_rejected_by_contract(): + store = ContinuityStore(conn=None) # type: ignore[arg-type] + + with pytest.raises(AppendOnlyViolation, match="append-only"): + store.update_event("event-id", {"text": "mutated"}) + + +def test_event_deletes_are_rejected_by_contract(): + store = ContinuityStore(conn=None) # type: ignore[arg-type] + + with pytest.raises(AppendOnlyViolation, match="append-only"): + store.delete_event("event-id") + + +def test_thread_create_endpoint_persists_one_visible_thread(monkeypatch: pytest.MonkeyPatch) -> None: + owner_id = uuid4() + stores: dict[UUID, ContinuityApiStoreStub] = {} + install_continuity_api_stubs(monkeypatch, stores) + + response = main_module.create_thread( + main_module.CreateThreadRequest(user_id=owner_id, title="Operator Inbox") + ) + + assert response.status_code == 201 + assert json.loads(response.body) == { + "thread": { + "id": json.loads(response.body)["thread"]["id"], + "title": "Operator Inbox", + "agent_profile_id": "assistant_default", + "created_at": "2026-03-17T09:00:00+00:00", + "updated_at": "2026-03-17T09:00:00+00:00", + } + } + assert [thread["title"] for thread in stores[owner_id].threads] == ["Operator Inbox"] + + +def test_thread_review_endpoints_preserve_shape_order_and_user_isolation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + owner_id = uuid4() + intruder_id = uuid4() + owner_store = ContinuityApiStoreStub(current_user_id=owner_id) + intruder_store = ContinuityApiStoreStub(current_user_id=intruder_id) + stores = { + owner_id: owner_store, + intruder_id: intruder_store, + } + install_continuity_api_stubs(monkeypatch, stores) + + shared_created_at = owner_store.base_time + first_thread = owner_store.add_thread( + thread_id=UUID("00000000-0000-4000-8000-000000000001"), + user_id=owner_id, + title="Alpha thread", + created_at=shared_created_at, + updated_at=shared_created_at, + ) + second_thread = owner_store.add_thread( + thread_id=UUID("00000000-0000-4000-8000-000000000002"), + user_id=owner_id, + title="Beta thread", + created_at=shared_created_at, + updated_at=shared_created_at, + ) + first_session = owner_store.add_session( + session_id=UUID("10000000-0000-4000-8000-000000000001"), + user_id=owner_id, + thread_id=second_thread["id"], + status="completed", + started_at=shared_created_at, + ended_at=shared_created_at + timedelta(minutes=5), + created_at=shared_created_at, + ) + second_session = owner_store.add_session( + session_id=UUID("10000000-0000-4000-8000-000000000002"), + user_id=owner_id, + thread_id=second_thread["id"], + status="active", + started_at=shared_created_at + timedelta(hours=1), + ended_at=None, + created_at=shared_created_at + timedelta(hours=1), + ) + first_event = owner_store.add_event( + event_id=UUID("20000000-0000-4000-8000-000000000001"), + user_id=owner_id, + thread_id=second_thread["id"], + session_id=second_session["id"], + sequence_no=2, + kind="message.assistant", + payload={"text": "Hello back"}, + created_at=shared_created_at + timedelta(hours=1, minutes=1), + ) + second_event = owner_store.add_event( + event_id=UUID("20000000-0000-4000-8000-000000000002"), + user_id=owner_id, + thread_id=second_thread["id"], + session_id=second_session["id"], + sequence_no=1, + kind="message.user", + payload={"text": "Hello"}, + created_at=shared_created_at + timedelta(hours=1), + ) + + list_response = main_module.list_threads(owner_id) + detail_response = main_module.get_thread(second_thread["id"], owner_id) + sessions_response = main_module.list_thread_sessions(second_thread["id"], owner_id) + events_response = main_module.list_thread_events(second_thread["id"], owner_id) + intruder_list_response = main_module.list_threads(intruder_id) + intruder_detail_response = main_module.get_thread(second_thread["id"], intruder_id) + intruder_sessions_response = main_module.list_thread_sessions(second_thread["id"], intruder_id) + intruder_events_response = main_module.list_thread_events(second_thread["id"], intruder_id) + + assert json.loads(list_response.body) == { + "items": [ + { + "id": str(second_thread["id"]), + "title": "Beta thread", + "agent_profile_id": "assistant_default", + "created_at": shared_created_at.isoformat(), + "updated_at": shared_created_at.isoformat(), + }, + { + "id": str(first_thread["id"]), + "title": "Alpha thread", + "agent_profile_id": "assistant_default", + "created_at": shared_created_at.isoformat(), + "updated_at": shared_created_at.isoformat(), + }, + ], + "summary": { + "total_count": 2, + "order": ["created_at_desc", "id_desc"], + }, + } + assert json.loads(detail_response.body) == { + "thread": { + "id": str(second_thread["id"]), + "title": "Beta thread", + "agent_profile_id": "assistant_default", + "created_at": shared_created_at.isoformat(), + "updated_at": shared_created_at.isoformat(), + } + } + assert json.loads(sessions_response.body) == { + "items": [ + { + "id": str(first_session["id"]), + "thread_id": str(second_thread["id"]), + "status": "completed", + "started_at": shared_created_at.isoformat(), + "ended_at": (shared_created_at + timedelta(minutes=5)).isoformat(), + "created_at": shared_created_at.isoformat(), + }, + { + "id": str(second_session["id"]), + "thread_id": str(second_thread["id"]), + "status": "active", + "started_at": (shared_created_at + timedelta(hours=1)).isoformat(), + "ended_at": None, + "created_at": (shared_created_at + timedelta(hours=1)).isoformat(), + }, + ], + "summary": { + "thread_id": str(second_thread["id"]), + "total_count": 2, + "order": ["started_at_asc", "created_at_asc", "id_asc"], + }, + } + assert json.loads(events_response.body) == { + "items": [ + { + "id": str(second_event["id"]), + "thread_id": str(second_thread["id"]), + "session_id": str(second_session["id"]), + "sequence_no": 1, + "kind": "message.user", + "payload": {"text": "Hello"}, + "created_at": (shared_created_at + timedelta(hours=1)).isoformat(), + }, + { + "id": str(first_event["id"]), + "thread_id": str(second_thread["id"]), + "session_id": str(second_session["id"]), + "sequence_no": 2, + "kind": "message.assistant", + "payload": {"text": "Hello back"}, + "created_at": (shared_created_at + timedelta(hours=1, minutes=1)).isoformat(), + }, + ], + "summary": { + "thread_id": str(second_thread["id"]), + "total_count": 2, + "order": ["sequence_no_asc"], + }, + } + assert json.loads(intruder_list_response.body) == { + "items": [], + "summary": { + "total_count": 0, + "order": ["created_at_desc", "id_desc"], + }, + } + assert intruder_detail_response.status_code == 404 + assert intruder_sessions_response.status_code == 404 + assert intruder_events_response.status_code == 404 + assert json.loads(intruder_detail_response.body) == { + "detail": f"thread {second_thread['id']} was not found" + } + assert json.loads(intruder_sessions_response.body) == { + "detail": f"thread {second_thread['id']} was not found" + } + assert json.loads(intruder_events_response.body) == { + "detail": f"thread {second_thread['id']} was not found" + } diff --git a/tests/unit/test_execution_budget_store.py b/tests/unit/test_execution_budget_store.py new file mode 100644 index 0000000..469ffbf --- /dev/null +++ b/tests/unit/test_execution_budget_store.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from typing import Any +from uuid import uuid4 + +from alicebot_api.store import ContinuityStore + + +class RecordingCursor: + def __init__(self, fetchone_results: list[dict[str, Any]], fetchall_result: list[dict[str, Any]] | None = None) -> None: + self.executed: list[tuple[str, tuple[object, ...] | None]] = [] + self.fetchone_results = list(fetchone_results) + self.fetchall_result = fetchall_result or [] + + def __enter__(self) -> "RecordingCursor": + return self + + def __exit__(self, exc_type, exc, tb) -> None: + return None + + def execute(self, query: str, params: tuple[object, ...] | None = None) -> None: + self.executed.append((query, params)) + + def fetchone(self) -> dict[str, Any] | None: + if not self.fetchone_results: + return None + return self.fetchone_results.pop(0) + + def fetchall(self) -> list[dict[str, Any]]: + return self.fetchall_result + + +class RecordingConnection: + def __init__(self, cursor: RecordingCursor) -> None: + self.cursor_instance = cursor + + def cursor(self) -> RecordingCursor: + return self.cursor_instance + + +def test_execution_budget_store_methods_use_expected_queries_and_parameters() -> None: + execution_budget_id = uuid4() + replacement_budget_id = uuid4() + row = { + "id": execution_budget_id, + "agent_profile_id": None, + "tool_key": "proxy.echo", + "domain_hint": "docs", + "max_completed_executions": 2, + "rolling_window_seconds": 3600, + "status": "active", + "deactivated_at": None, + "superseded_by_budget_id": None, + "supersedes_budget_id": None, + "created_at": "2026-03-13T11:00:00+00:00", + } + cursor = RecordingCursor( + fetchone_results=[ + row, + row, + {**row, "status": "inactive"}, + {**row, "status": "superseded", "superseded_by_budget_id": replacement_budget_id}, + ], + fetchall_result=[row], + ) + store = ContinuityStore(RecordingConnection(cursor)) + + created = store.create_execution_budget( + agent_profile_id=None, + tool_key="proxy.echo", + domain_hint="docs", + max_completed_executions=2, + rolling_window_seconds=3600, + ) + fetched = store.get_execution_budget_optional(execution_budget_id) + listed = store.list_execution_budgets() + deactivated = store.deactivate_execution_budget_optional(execution_budget_id) + superseded = store.supersede_execution_budget_optional( + execution_budget_id=execution_budget_id, + superseded_by_budget_id=replacement_budget_id, + ) + + assert created["id"] == execution_budget_id + assert fetched is not None + assert fetched["id"] == execution_budget_id + assert listed[0]["id"] == execution_budget_id + assert deactivated is not None + assert deactivated["status"] == "inactive" + assert superseded is not None + assert superseded["superseded_by_budget_id"] == replacement_budget_id + + create_query, create_params = cursor.executed[0] + assert "INSERT INTO execution_budgets" in create_query + assert create_params == (None, None, "proxy.echo", "docs", 2, 3600, None) + assert "FROM execution_budgets" in cursor.executed[1][0] + assert "ORDER BY created_at ASC, id ASC" in cursor.executed[2][0] + assert "UPDATE execution_budgets" in cursor.executed[3][0] + assert cursor.executed[4][1] == (replacement_budget_id, execution_budget_id) diff --git a/tests/unit/test_execution_budgets.py b/tests/unit/test_execution_budgets.py new file mode 100644 index 0000000..79427fe --- /dev/null +++ b/tests/unit/test_execution_budgets.py @@ -0,0 +1,1007 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from uuid import UUID, uuid4 + +import pytest + +from alicebot_api.contracts import ( + DEFAULT_AGENT_PROFILE_ID, + ExecutionBudgetCreateInput, + ExecutionBudgetDeactivateInput, + ExecutionBudgetSupersedeInput, +) +from alicebot_api.execution_budgets import ( + ExecutionBudgetLifecycleError, + ExecutionBudgetNotFoundError, + ExecutionBudgetValidationError, + create_execution_budget_record, + deactivate_execution_budget_record, + evaluate_execution_budget, + get_execution_budget_record, + list_execution_budget_records, + supersede_execution_budget_record, +) + + +class _SavepointConnection: + def __init__(self, store: "ExecutionBudgetStoreStub") -> None: + self.store = store + + def transaction(self) -> "_Savepoint": + return _Savepoint(self.store) + + +class _Savepoint: + def __init__(self, store: "ExecutionBudgetStoreStub") -> None: + self.store = store + self.snapshot: list[dict[str, object]] | None = None + + def __enter__(self) -> "_Savepoint": + self.snapshot = [dict(row) for row in self.store.budgets] + return self + + def __exit__(self, exc_type, exc, tb) -> bool: + if exc_type is not None and self.snapshot is not None: + self.store.budgets = [dict(row) for row in self.snapshot] + return False + + +class ExecutionBudgetStoreStub: + def __init__(self) -> None: + self.base_time = datetime(2026, 3, 13, 11, 0, tzinfo=UTC) + self.user_id = uuid4() + self.thread_id = uuid4() + self.agent_profiles = {DEFAULT_AGENT_PROFILE_ID, "coach_default"} + self.thread_profiles: dict[UUID, str] = { + self.thread_id: DEFAULT_AGENT_PROFILE_ID, + } + self.budgets: list[dict[str, object]] = [] + self.executions: list[dict[str, object]] = [] + self.traces: list[dict[str, object]] = [] + self.trace_events: list[dict[str, object]] = [] + self.fail_next_supersede_update = False + self.conn = _SavepointConnection(self) + + def current_time(self) -> datetime: + return self.base_time + timedelta(minutes=len(self.executions)) + + def get_thread_optional(self, thread_id: UUID) -> dict[str, object] | None: + if thread_id not in self.thread_profiles: + return None + return { + "id": thread_id, + "user_id": self.user_id, + "title": "Budget lifecycle thread", + "agent_profile_id": self.thread_profiles[thread_id], + "created_at": self.base_time, + "updated_at": self.base_time, + } + + def create_thread(self, *, agent_profile_id: str) -> UUID: + thread_id = uuid4() + self.thread_profiles[thread_id] = agent_profile_id + return thread_id + + def get_agent_profile_optional(self, profile_id: str) -> dict[str, object] | None: + if profile_id not in self.agent_profiles: + return None + return { + "id": profile_id, + "name": profile_id, + "description": "", + "model_provider": None, + "model_name": None, + } + + def create_trace( + self, + *, + user_id: UUID, + thread_id: UUID, + kind: str, + compiler_version: str, + status: str, + limits: dict[str, object], + ) -> dict[str, object]: + trace = { + "id": uuid4(), + "user_id": user_id, + "thread_id": thread_id, + "kind": kind, + "compiler_version": compiler_version, + "status": status, + "limits": limits, + "created_at": self.base_time + timedelta(minutes=len(self.traces)), + } + self.traces.append(trace) + return trace + + def append_trace_event( + self, + *, + trace_id: UUID, + sequence_no: int, + kind: str, + payload: dict[str, object], + ) -> dict[str, object]: + event = { + "id": uuid4(), + "trace_id": trace_id, + "sequence_no": sequence_no, + "kind": kind, + "payload": payload, + "created_at": self.base_time + timedelta(minutes=len(self.trace_events)), + } + self.trace_events.append(event) + return event + + def create_execution_budget( + self, + *, + budget_id: UUID | None = None, + agent_profile_id: str | None = None, + tool_key: str | None, + domain_hint: str | None, + max_completed_executions: int, + rolling_window_seconds: int | None = None, + supersedes_budget_id: UUID | None = None, + ) -> dict[str, object]: + row = { + "id": uuid4() if budget_id is None else budget_id, + "user_id": self.user_id, + "agent_profile_id": agent_profile_id, + "tool_key": tool_key, + "domain_hint": domain_hint, + "max_completed_executions": max_completed_executions, + "rolling_window_seconds": rolling_window_seconds, + "status": "active", + "deactivated_at": None, + "superseded_by_budget_id": None, + "supersedes_budget_id": supersedes_budget_id, + "created_at": self.base_time + timedelta(minutes=len(self.budgets)), + } + self.budgets.append(row) + self.budgets.sort(key=lambda item: (item["created_at"], item["id"])) + return row + + def deactivate_execution_budget_optional( + self, + execution_budget_id: UUID, + ) -> dict[str, object] | None: + row = self.get_execution_budget_optional(execution_budget_id) + if row is None or row["status"] != "active": + return None + row["status"] = "inactive" + row["deactivated_at"] = self.base_time + timedelta(hours=1, minutes=len(self.trace_events)) + return row + + def supersede_execution_budget_optional( + self, + *, + execution_budget_id: UUID, + superseded_by_budget_id: UUID, + ) -> dict[str, object] | None: + if self.fail_next_supersede_update: + self.fail_next_supersede_update = False + return None + row = self.get_execution_budget_optional(execution_budget_id) + if row is None or row["status"] != "active": + return None + row["status"] = "superseded" + row["deactivated_at"] = self.base_time + timedelta(hours=1, minutes=len(self.trace_events)) + row["superseded_by_budget_id"] = superseded_by_budget_id + return row + + def get_execution_budget_optional(self, execution_budget_id: UUID) -> dict[str, object] | None: + return next((row for row in self.budgets if row["id"] == execution_budget_id), None) + + def list_execution_budgets(self) -> list[dict[str, object]]: + return list(self.budgets) + + def seed_execution( + self, + *, + tool_key: str, + domain_hint: str | None, + status: str, + offset_minutes: int, + thread_id: UUID | None = None, + ) -> None: + execution_thread_id = self.thread_id if thread_id is None else thread_id + tool_id = uuid4() + self.executions.append( + { + "id": uuid4(), + "user_id": self.user_id, + "approval_id": uuid4(), + "thread_id": execution_thread_id, + "tool_id": tool_id, + "trace_id": uuid4(), + "request_event_id": None, + "result_event_id": None, + "status": status, + "handler_key": None if status == "blocked" else tool_key, + "request": { + "thread_id": str(execution_thread_id), + "tool_id": str(tool_id), + "action": "tool.run", + "scope": "workspace", + "domain_hint": domain_hint, + "risk_hint": None, + "attributes": {}, + }, + "tool": { + "id": str(tool_id), + "tool_key": tool_key, + }, + "result": { + "handler_key": None if status == "blocked" else tool_key, + "status": status, + "output": None, + "reason": None, + }, + "executed_at": self.base_time + timedelta(minutes=offset_minutes), + } + ) + + def list_tool_executions(self) -> list[dict[str, object]]: + return list(self.executions) + + +def test_create_execution_budget_requires_at_least_one_selector() -> None: + store = ExecutionBudgetStoreStub() + + with pytest.raises( + ExecutionBudgetValidationError, + match="execution budget requires at least one selector: tool_key or domain_hint", + ): + create_execution_budget_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=ExecutionBudgetCreateInput( + tool_key=None, + domain_hint=None, + max_completed_executions=1, + ), + ) + + +def test_create_execution_budget_rejects_duplicate_active_scope() -> None: + store = ExecutionBudgetStoreStub() + create_execution_budget_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=ExecutionBudgetCreateInput( + tool_key="proxy.echo", + domain_hint="docs", + max_completed_executions=1, + ), + ) + + with pytest.raises( + ExecutionBudgetValidationError, + match="active execution budget already exists for selector scope", + ): + create_execution_budget_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=ExecutionBudgetCreateInput( + tool_key="proxy.echo", + domain_hint="docs", + max_completed_executions=2, + ), + ) + + +def test_create_execution_budget_allows_same_selector_across_profile_scopes() -> None: + store = ExecutionBudgetStoreStub() + default_budget = create_execution_budget_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=ExecutionBudgetCreateInput( + agent_profile_id=DEFAULT_AGENT_PROFILE_ID, + tool_key="proxy.echo", + domain_hint="docs", + max_completed_executions=1, + ), + ) + global_budget = create_execution_budget_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=ExecutionBudgetCreateInput( + agent_profile_id=None, + tool_key="proxy.echo", + domain_hint="docs", + max_completed_executions=2, + ), + ) + + assert default_budget["execution_budget"]["agent_profile_id"] == DEFAULT_AGENT_PROFILE_ID + assert global_budget["execution_budget"]["agent_profile_id"] is None + assert len(store.budgets) == 2 + + +def test_create_execution_budget_rejects_unknown_agent_profile_id() -> None: + store = ExecutionBudgetStoreStub() + + with pytest.raises( + ExecutionBudgetValidationError, + match="agent_profile_id must reference an existing profile in the registry", + ): + create_execution_budget_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=ExecutionBudgetCreateInput( + agent_profile_id="profile_missing", + tool_key="proxy.echo", + domain_hint=None, + max_completed_executions=1, + ), + ) + + +def test_create_execution_budget_includes_optional_rolling_window_seconds() -> None: + store = ExecutionBudgetStoreStub() + + payload = create_execution_budget_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=ExecutionBudgetCreateInput( + tool_key="proxy.echo", + domain_hint=None, + max_completed_executions=2, + rolling_window_seconds=3600, + ), + ) + + assert payload["execution_budget"]["rolling_window_seconds"] == 3600 + assert payload["execution_budget"]["agent_profile_id"] is None + assert store.budgets[0]["rolling_window_seconds"] == 3600 + + +def test_create_list_and_get_execution_budget_records_are_deterministic() -> None: + store = ExecutionBudgetStoreStub() + second = create_execution_budget_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=ExecutionBudgetCreateInput( + tool_key="proxy.echo", + domain_hint=None, + max_completed_executions=2, + ), + ) + first = create_execution_budget_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=ExecutionBudgetCreateInput( + tool_key=None, + domain_hint="docs", + max_completed_executions=1, + ), + ) + + listed = list_execution_budget_records( + store, # type: ignore[arg-type] + user_id=store.user_id, + ) + detail = get_execution_budget_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + execution_budget_id=UUID(second["execution_budget"]["id"]), + ) + + assert [item["id"] for item in listed["items"]] == [ + second["execution_budget"]["id"], + first["execution_budget"]["id"], + ] + assert listed["summary"] == { + "total_count": 2, + "order": ["created_at_asc", "id_asc"], + } + assert detail == {"execution_budget": second["execution_budget"]} + assert detail["execution_budget"]["status"] == "active" + assert detail["execution_budget"]["deactivated_at"] is None + assert detail["execution_budget"]["rolling_window_seconds"] is None + + +def test_deactivate_execution_budget_marks_row_inactive_and_records_trace() -> None: + store = ExecutionBudgetStoreStub() + created = create_execution_budget_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=ExecutionBudgetCreateInput( + tool_key="proxy.echo", + domain_hint=None, + max_completed_executions=1, + ), + ) + + payload = deactivate_execution_budget_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=ExecutionBudgetDeactivateInput( + thread_id=store.thread_id, + execution_budget_id=UUID(created["execution_budget"]["id"]), + ), + ) + + assert payload["execution_budget"]["status"] == "inactive" + assert payload["execution_budget"]["deactivated_at"] == "2026-03-13T12:00:00+00:00" + assert payload["trace"]["trace_event_count"] == 3 + assert store.traces[0]["kind"] == "execution_budget.lifecycle" + assert store.traces[0]["compiler_version"] == "execution_budget_lifecycle_v0" + assert [event["kind"] for event in store.trace_events] == [ + "execution_budget.lifecycle.request", + "execution_budget.lifecycle.state", + "execution_budget.lifecycle.summary", + ] + assert store.trace_events[1]["payload"] == { + "execution_budget_id": created["execution_budget"]["id"], + "requested_action": "deactivate", + "previous_status": "active", + "current_status": "inactive", + "tool_key": "proxy.echo", + "domain_hint": None, + "max_completed_executions": 1, + "rolling_window_seconds": None, + "deactivated_at": "2026-03-13T12:00:00+00:00", + "superseded_by_budget_id": None, + "supersedes_budget_id": None, + "replacement_budget_id": None, + "replacement_status": None, + "replacement_max_completed_executions": None, + "replacement_rolling_window_seconds": None, + "rejection_reason": None, + } + + +def test_supersede_execution_budget_replaces_active_budget_and_records_trace() -> None: + store = ExecutionBudgetStoreStub() + created = create_execution_budget_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=ExecutionBudgetCreateInput( + tool_key="proxy.echo", + domain_hint="docs", + max_completed_executions=1, + ), + ) + + payload = supersede_execution_budget_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=ExecutionBudgetSupersedeInput( + thread_id=store.thread_id, + execution_budget_id=UUID(created["execution_budget"]["id"]), + max_completed_executions=3, + ), + ) + + assert payload["superseded_budget"]["status"] == "superseded" + assert payload["replacement_budget"]["status"] == "active" + assert payload["replacement_budget"]["max_completed_executions"] == 3 + assert payload["replacement_budget"]["tool_key"] == "proxy.echo" + assert payload["replacement_budget"]["domain_hint"] == "docs" + assert payload["replacement_budget"]["rolling_window_seconds"] is None + assert payload["replacement_budget"]["supersedes_budget_id"] == created["execution_budget"]["id"] + assert payload["superseded_budget"]["superseded_by_budget_id"] == payload["replacement_budget"]["id"] + assert payload["trace"]["trace_event_count"] == 3 + assert store.trace_events[1]["payload"]["replacement_budget_id"] == payload["replacement_budget"]["id"] + assert store.trace_events[2]["payload"] == { + "execution_budget_id": created["execution_budget"]["id"], + "requested_action": "supersede", + "outcome": "superseded", + "replacement_budget_id": payload["replacement_budget"]["id"], + "active_budget_id": payload["replacement_budget"]["id"], + } + + +def test_lifecycle_rejects_invalid_transition_and_records_trace() -> None: + store = ExecutionBudgetStoreStub() + created = create_execution_budget_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=ExecutionBudgetCreateInput( + tool_key="proxy.echo", + domain_hint=None, + max_completed_executions=1, + ), + ) + deactivate_execution_budget_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=ExecutionBudgetDeactivateInput( + thread_id=store.thread_id, + execution_budget_id=UUID(created["execution_budget"]["id"]), + ), + ) + + with pytest.raises( + ExecutionBudgetLifecycleError, + match=f"execution budget {created['execution_budget']['id']} is inactive and cannot be deactivated", + ): + deactivate_execution_budget_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=ExecutionBudgetDeactivateInput( + thread_id=store.thread_id, + execution_budget_id=UUID(created["execution_budget"]["id"]), + ), + ) + + assert store.trace_events[-2]["payload"]["current_status"] == "inactive" + assert store.trace_events[-2]["payload"]["rejection_reason"] == ( + f"execution budget {created['execution_budget']['id']} is inactive and cannot be deactivated" + ) + assert store.trace_events[-1]["payload"]["outcome"] == "rejected" + + +def test_supersede_execution_budget_rolls_back_replacement_when_source_update_fails() -> None: + store = ExecutionBudgetStoreStub() + created = create_execution_budget_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=ExecutionBudgetCreateInput( + tool_key="proxy.echo", + domain_hint=None, + max_completed_executions=1, + ), + ) + store.fail_next_supersede_update = True + + with pytest.raises( + ExecutionBudgetLifecycleError, + match=f"execution budget {created['execution_budget']['id']} could not be superseded", + ): + supersede_execution_budget_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=ExecutionBudgetSupersedeInput( + thread_id=store.thread_id, + execution_budget_id=UUID(created["execution_budget"]["id"]), + max_completed_executions=3, + ), + ) + + assert len(store.budgets) == 1 + assert store.budgets[0]["id"] == UUID(created["execution_budget"]["id"]) + assert store.budgets[0]["status"] == "active" + assert store.budgets[0]["superseded_by_budget_id"] is None + assert store.trace_events[-1]["payload"]["outcome"] == "rejected" + + +def test_get_execution_budget_record_raises_clear_error_when_missing() -> None: + store = ExecutionBudgetStoreStub() + + with pytest.raises(ExecutionBudgetNotFoundError, match="execution budget .* was not found"): + get_execution_budget_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + execution_budget_id=uuid4(), + ) + + +def test_evaluate_execution_budget_fail_closed_when_request_thread_context_is_malformed() -> None: + store = ExecutionBudgetStoreStub() + store.create_execution_budget( + tool_key="proxy.echo", + domain_hint=None, + max_completed_executions=10, + ) + + decision = evaluate_execution_budget( + store, # type: ignore[arg-type] + tool={"id": str(uuid4()), "tool_key": "proxy.echo"}, + request={ + "thread_id": "not-a-uuid", + "tool_id": str(uuid4()), + "action": "tool.run", + "scope": "workspace", + "domain_hint": None, + "risk_hint": None, + "attributes": {}, + }, + ) + + assert decision.record == { + "matched_budget_id": None, + "tool_key": "proxy.echo", + "domain_hint": None, + "budget_tool_key": None, + "budget_domain_hint": None, + "max_completed_executions": None, + "rolling_window_seconds": None, + "count_scope": "lifetime", + "window_started_at": None, + "completed_execution_count": 0, + "projected_completed_execution_count": 1, + "decision": "block", + "reason": "invalid_request_context", + "order": ["specificity_desc", "created_at_asc", "id_asc"], + "history_order": ["executed_at_asc", "id_asc"], + "request_thread_id": "not-a-uuid", + "context_resolution": "invalid", + "context_reason": "request.thread_id 'not-a-uuid' is not a valid UUID", + } + assert decision.blocked_result == { + "handler_key": None, + "status": "blocked", + "output": None, + "reason": ( + "execution budget invariance blocks execution: invalid request " + "thread/profile context: request.thread_id 'not-a-uuid' is not a valid UUID" + ), + "budget_decision": decision.record, + } + + +def test_evaluate_execution_budget_fail_closed_when_request_thread_profile_is_unresolvable() -> None: + store = ExecutionBudgetStoreStub() + broken_thread_id = store.create_thread(agent_profile_id="profile_missing") + store.create_execution_budget( + tool_key="proxy.echo", + domain_hint=None, + max_completed_executions=10, + ) + + decision = evaluate_execution_budget( + store, # type: ignore[arg-type] + tool={"id": str(uuid4()), "tool_key": "proxy.echo"}, + request={ + "thread_id": str(broken_thread_id), + "tool_id": str(uuid4()), + "action": "tool.run", + "scope": "workspace", + "domain_hint": None, + "risk_hint": None, + "attributes": {}, + }, + ) + + assert decision.record["decision"] == "block" + assert decision.record["reason"] == "invalid_request_context" + assert decision.record["request_thread_id"] == str(broken_thread_id) + assert decision.record["context_resolution"] == "invalid" + assert decision.record["context_reason"] == ( + f"request.thread_id '{broken_thread_id}' did not resolve to a visible " + "thread/profile context" + ) + assert decision.blocked_result is not None + assert decision.blocked_result["status"] == "blocked" + assert decision.blocked_result["reason"] == ( + "execution budget invariance blocks execution: invalid request thread/profile context: " + f"request.thread_id '{broken_thread_id}' did not resolve to a visible thread/profile context" + ) + + +def test_evaluate_execution_budget_excludes_malformed_history_rows_from_profile_scoped_counts() -> None: + store = ExecutionBudgetStoreStub() + matched = store.create_execution_budget( + tool_key="proxy.echo", + domain_hint=None, + max_completed_executions=2, + ) + store.seed_execution(tool_key="proxy.echo", domain_hint=None, status="completed", offset_minutes=0) + store.seed_execution(tool_key="proxy.echo", domain_hint=None, status="completed", offset_minutes=1) + store.seed_execution(tool_key="proxy.echo", domain_hint=None, status="completed", offset_minutes=2) + malformed_thread_id_row = store.executions[1] + malformed_thread_id_row["request"]["thread_id"] = "not-a-uuid" # type: ignore[index] + missing_thread_id_row = store.executions[2] + missing_thread_id_row["request"].pop("thread_id") # type: ignore[index] + + decision = evaluate_execution_budget( + store, # type: ignore[arg-type] + tool={"id": str(uuid4()), "tool_key": "proxy.echo"}, + request={ + "thread_id": str(store.thread_id), + "tool_id": str(uuid4()), + "action": "tool.run", + "scope": "workspace", + "domain_hint": None, + "risk_hint": None, + "attributes": {}, + }, + ) + + assert decision.record == { + "matched_budget_id": str(matched["id"]), + "tool_key": "proxy.echo", + "domain_hint": None, + "budget_tool_key": "proxy.echo", + "budget_domain_hint": None, + "max_completed_executions": 2, + "rolling_window_seconds": None, + "count_scope": "lifetime", + "window_started_at": None, + "completed_execution_count": 1, + "projected_completed_execution_count": 2, + "decision": "allow", + "reason": "within_budget", + "order": ["specificity_desc", "created_at_asc", "id_asc"], + "history_order": ["executed_at_asc", "id_asc"], + } + assert decision.blocked_result is None + + +def test_evaluate_execution_budget_prefers_more_specific_active_match_and_ignores_inactive_rows() -> None: + store = ExecutionBudgetStoreStub() + inactive = store.create_execution_budget( + tool_key="proxy.echo", + domain_hint="docs", + max_completed_executions=1, + ) + store.deactivate_execution_budget_optional(inactive["id"]) + store.create_execution_budget(tool_key=None, domain_hint="docs", max_completed_executions=1) + matched = store.create_execution_budget( + tool_key="proxy.echo", + domain_hint="docs", + max_completed_executions=2, + ) + store.seed_execution(tool_key="proxy.echo", domain_hint="docs", status="completed", offset_minutes=0) + store.seed_execution(tool_key="proxy.echo", domain_hint="docs", status="blocked", offset_minutes=1) + + decision = evaluate_execution_budget( + store, # type: ignore[arg-type] + tool={"id": str(uuid4()), "tool_key": "proxy.echo"}, + request={ + "thread_id": str(store.thread_id), + "tool_id": str(uuid4()), + "action": "tool.run", + "scope": "workspace", + "domain_hint": "docs", + "risk_hint": None, + "attributes": {}, + }, + ) + + assert decision.record == { + "matched_budget_id": str(matched["id"]), + "tool_key": "proxy.echo", + "domain_hint": "docs", + "budget_tool_key": "proxy.echo", + "budget_domain_hint": "docs", + "max_completed_executions": 2, + "rolling_window_seconds": None, + "count_scope": "lifetime", + "window_started_at": None, + "completed_execution_count": 1, + "projected_completed_execution_count": 2, + "decision": "allow", + "reason": "within_budget", + "order": ["specificity_desc", "created_at_asc", "id_asc"], + "history_order": ["executed_at_asc", "id_asc"], + } + assert decision.blocked_result is None + + +def test_evaluate_execution_budget_prefers_profile_scoped_budget_before_global_fallback() -> None: + store = ExecutionBudgetStoreStub() + global_budget = store.create_execution_budget( + agent_profile_id=None, + tool_key="proxy.echo", + domain_hint=None, + max_completed_executions=1, + ) + profile_budget = store.create_execution_budget( + agent_profile_id=DEFAULT_AGENT_PROFILE_ID, + tool_key="proxy.echo", + domain_hint=None, + max_completed_executions=2, + ) + store.seed_execution( + tool_key="proxy.echo", + domain_hint=None, + status="completed", + offset_minutes=0, + thread_id=store.thread_id, + ) + + decision = evaluate_execution_budget( + store, # type: ignore[arg-type] + tool={"id": str(uuid4()), "tool_key": "proxy.echo"}, + request={ + "thread_id": str(store.thread_id), + "tool_id": str(uuid4()), + "action": "tool.run", + "scope": "workspace", + "domain_hint": None, + "risk_hint": None, + "attributes": {}, + }, + ) + + assert decision.record["matched_budget_id"] == str(profile_budget["id"]) + assert decision.record["completed_execution_count"] == 1 + assert decision.record["projected_completed_execution_count"] == 2 + assert decision.record["reason"] == "within_budget" + assert decision.blocked_result is None + assert str(global_budget["id"]) != decision.record["matched_budget_id"] + + +def test_evaluate_execution_budget_global_fallback_counts_only_active_thread_profile_history() -> None: + store = ExecutionBudgetStoreStub() + coach_thread_id = store.create_thread(agent_profile_id="coach_default") + global_budget = store.create_execution_budget( + agent_profile_id=None, + tool_key="proxy.echo", + domain_hint=None, + max_completed_executions=1, + ) + store.seed_execution( + tool_key="proxy.echo", + domain_hint=None, + status="completed", + offset_minutes=0, + thread_id=store.thread_id, + ) + + decision = evaluate_execution_budget( + store, # type: ignore[arg-type] + tool={"id": str(uuid4()), "tool_key": "proxy.echo"}, + request={ + "thread_id": str(coach_thread_id), + "tool_id": str(uuid4()), + "action": "tool.run", + "scope": "workspace", + "domain_hint": None, + "risk_hint": None, + "attributes": {}, + }, + ) + + assert decision.record["matched_budget_id"] == str(global_budget["id"]) + assert decision.record["completed_execution_count"] == 0 + assert decision.record["projected_completed_execution_count"] == 1 + assert decision.record["reason"] == "within_budget" + assert decision.blocked_result is None + + +def test_evaluate_execution_budget_blocks_when_projected_completed_count_would_exceed_limit() -> None: + store = ExecutionBudgetStoreStub() + matched = store.create_execution_budget( + tool_key="proxy.echo", + domain_hint=None, + max_completed_executions=1, + ) + store.seed_execution(tool_key="proxy.echo", domain_hint=None, status="completed", offset_minutes=0) + + decision = evaluate_execution_budget( + store, # type: ignore[arg-type] + tool={"id": str(uuid4()), "tool_key": "proxy.echo"}, + request={ + "thread_id": str(store.thread_id), + "tool_id": str(uuid4()), + "action": "tool.run", + "scope": "workspace", + "domain_hint": None, + "risk_hint": None, + "attributes": {}, + }, + ) + + assert decision.record == { + "matched_budget_id": str(matched["id"]), + "tool_key": "proxy.echo", + "domain_hint": None, + "budget_tool_key": "proxy.echo", + "budget_domain_hint": None, + "max_completed_executions": 1, + "rolling_window_seconds": None, + "count_scope": "lifetime", + "window_started_at": None, + "completed_execution_count": 1, + "projected_completed_execution_count": 2, + "decision": "block", + "reason": "budget_exceeded", + "order": ["specificity_desc", "created_at_asc", "id_asc"], + "history_order": ["executed_at_asc", "id_asc"], + } + assert decision.blocked_result == { + "handler_key": None, + "status": "blocked", + "output": None, + "reason": ( + f"execution budget {matched['id']} blocks execution: projected completed executions " + "2 would exceed limit 1" + ), + "budget_decision": decision.record, + } + + +def test_evaluate_execution_budget_uses_only_recent_completed_history_inside_window() -> None: + store = ExecutionBudgetStoreStub() + matched = store.create_execution_budget( + tool_key="proxy.echo", + domain_hint=None, + max_completed_executions=2, + rolling_window_seconds=3600, + ) + store.seed_execution(tool_key="proxy.echo", domain_hint=None, status="completed", offset_minutes=-120) + store.seed_execution(tool_key="proxy.echo", domain_hint=None, status="completed", offset_minutes=-10) + + decision = evaluate_execution_budget( + store, # type: ignore[arg-type] + tool={"id": str(uuid4()), "tool_key": "proxy.echo"}, + request={ + "thread_id": str(store.thread_id), + "tool_id": str(uuid4()), + "action": "tool.run", + "scope": "workspace", + "domain_hint": None, + "risk_hint": None, + "attributes": {}, + }, + ) + + assert decision.record == { + "matched_budget_id": str(matched["id"]), + "tool_key": "proxy.echo", + "domain_hint": None, + "budget_tool_key": "proxy.echo", + "budget_domain_hint": None, + "max_completed_executions": 2, + "rolling_window_seconds": 3600, + "count_scope": "rolling_window", + "window_started_at": "2026-03-13T10:02:00+00:00", + "completed_execution_count": 1, + "projected_completed_execution_count": 2, + "decision": "allow", + "reason": "within_budget", + "order": ["specificity_desc", "created_at_asc", "id_asc"], + "history_order": ["executed_at_asc", "id_asc"], + } + assert decision.blocked_result is None + + +def test_evaluate_execution_budget_blocks_when_recent_window_history_exceeds_limit() -> None: + store = ExecutionBudgetStoreStub() + matched = store.create_execution_budget( + tool_key="proxy.echo", + domain_hint=None, + max_completed_executions=1, + rolling_window_seconds=900, + ) + store.seed_execution(tool_key="proxy.echo", domain_hint=None, status="completed", offset_minutes=-5) + + decision = evaluate_execution_budget( + store, # type: ignore[arg-type] + tool={"id": str(uuid4()), "tool_key": "proxy.echo"}, + request={ + "thread_id": str(store.thread_id), + "tool_id": str(uuid4()), + "action": "tool.run", + "scope": "workspace", + "domain_hint": None, + "risk_hint": None, + "attributes": {}, + }, + ) + + assert decision.record == { + "matched_budget_id": str(matched["id"]), + "tool_key": "proxy.echo", + "domain_hint": None, + "budget_tool_key": "proxy.echo", + "budget_domain_hint": None, + "max_completed_executions": 1, + "rolling_window_seconds": 900, + "count_scope": "rolling_window", + "window_started_at": "2026-03-13T10:46:00+00:00", + "completed_execution_count": 1, + "projected_completed_execution_count": 2, + "decision": "block", + "reason": "budget_exceeded", + "order": ["specificity_desc", "created_at_asc", "id_asc"], + "history_order": ["executed_at_asc", "id_asc"], + } + assert decision.blocked_result == { + "handler_key": None, + "status": "blocked", + "output": None, + "reason": ( + f"execution budget {matched['id']} blocks execution: projected completed executions " + "2 within rolling window 900 seconds would exceed limit 1" + ), + "budget_decision": decision.record, + } diff --git a/tests/unit/test_execution_budgets_main.py b/tests/unit/test_execution_budgets_main.py new file mode 100644 index 0000000..c32162d --- /dev/null +++ b/tests/unit/test_execution_budgets_main.py @@ -0,0 +1,381 @@ +from __future__ import annotations + +import json +from contextlib import contextmanager +from uuid import uuid4 + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.execution_budgets import ( + ExecutionBudgetLifecycleError, + ExecutionBudgetNotFoundError, + ExecutionBudgetValidationError, +) + + +def test_create_execution_budget_endpoint_returns_payload(monkeypatch) -> None: + user_id = uuid4() + settings = Settings(database_url="postgresql://app") + captured: dict[str, object] = {} + + @contextmanager + def fake_user_connection(database_url: str, current_user_id): + captured["database_url"] = database_url + captured["current_user_id"] = current_user_id + yield object() + + def fake_create_execution_budget_record(store, *, user_id, request): + captured["store_type"] = type(store).__name__ + captured["user_id"] = user_id + captured["request"] = request + return { + "execution_budget": { + "id": "budget-123", + "agent_profile_id": "assistant_default", + "tool_key": "proxy.echo", + "domain_hint": None, + "max_completed_executions": 1, + "rolling_window_seconds": 3600, + "status": "active", + "deactivated_at": None, + "superseded_by_budget_id": None, + "supersedes_budget_id": None, + "created_at": "2026-03-13T11:00:00+00:00", + } + } + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "create_execution_budget_record", fake_create_execution_budget_record) + + response = main_module.create_execution_budget( + main_module.CreateExecutionBudgetRequest( + user_id=user_id, + agent_profile_id="assistant_default", + tool_key="proxy.echo", + domain_hint=None, + max_completed_executions=1, + rolling_window_seconds=3600, + ) + ) + + assert response.status_code == 201 + assert json.loads(response.body)["execution_budget"]["id"] == "budget-123" + assert captured["database_url"] == "postgresql://app" + assert captured["current_user_id"] == user_id + assert captured["store_type"] == "ContinuityStore" + assert captured["user_id"] == user_id + assert captured["request"].agent_profile_id == "assistant_default" + assert captured["request"].tool_key == "proxy.echo" + assert captured["request"].rolling_window_seconds == 3600 + + +def test_create_execution_budget_endpoint_maps_validation_error_to_400(monkeypatch) -> None: + user_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + def fake_create_execution_budget_record(*_args, **_kwargs): + raise ExecutionBudgetValidationError( + "execution budget requires at least one selector: tool_key or domain_hint" + ) + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "create_execution_budget_record", fake_create_execution_budget_record) + + response = main_module.create_execution_budget( + main_module.CreateExecutionBudgetRequest( + user_id=user_id, + tool_key=None, + domain_hint="docs", + max_completed_executions=1, + ) + ) + + assert response.status_code == 400 + assert json.loads(response.body) == { + "detail": "execution budget requires at least one selector: tool_key or domain_hint" + } + + +def test_list_execution_budgets_endpoint_returns_payload(monkeypatch) -> None: + user_id = uuid4() + settings = Settings(database_url="postgresql://app") + captured: dict[str, object] = {} + + @contextmanager + def fake_user_connection(database_url: str, current_user_id): + captured["database_url"] = database_url + captured["current_user_id"] = current_user_id + yield object() + + def fake_list_execution_budget_records(store, *, user_id): + captured["store_type"] = type(store).__name__ + captured["user_id"] = user_id + return { + "items": [ + { + "id": "budget-123", + "agent_profile_id": None, + "tool_key": "proxy.echo", + "domain_hint": None, + "max_completed_executions": 1, + "rolling_window_seconds": None, + "status": "active", + "deactivated_at": None, + "superseded_by_budget_id": None, + "supersedes_budget_id": None, + "created_at": "2026-03-13T11:00:00+00:00", + } + ], + "summary": {"total_count": 1, "order": ["created_at_asc", "id_asc"]}, + } + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "list_execution_budget_records", fake_list_execution_budget_records) + + response = main_module.list_execution_budgets(user_id) + + assert response.status_code == 200 + assert json.loads(response.body)["summary"] == { + "total_count": 1, + "order": ["created_at_asc", "id_asc"], + } + assert captured == { + "database_url": "postgresql://app", + "current_user_id": user_id, + "store_type": "ContinuityStore", + "user_id": user_id, + } + + +def test_get_execution_budget_endpoint_returns_payload(monkeypatch) -> None: + user_id = uuid4() + execution_budget_id = uuid4() + settings = Settings(database_url="postgresql://app") + captured: dict[str, object] = {} + + @contextmanager + def fake_user_connection(database_url: str, current_user_id): + captured["database_url"] = database_url + captured["current_user_id"] = current_user_id + yield object() + + def fake_get_execution_budget_record(store, *, user_id, execution_budget_id): + captured["store_type"] = type(store).__name__ + captured["user_id"] = user_id + captured["execution_budget_id"] = execution_budget_id + return { + "execution_budget": { + "id": str(execution_budget_id), + "agent_profile_id": None, + "tool_key": "proxy.echo", + "domain_hint": None, + "max_completed_executions": 1, + "rolling_window_seconds": None, + "status": "active", + "deactivated_at": None, + "superseded_by_budget_id": None, + "supersedes_budget_id": None, + "created_at": "2026-03-13T11:00:00+00:00", + } + } + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "get_execution_budget_record", fake_get_execution_budget_record) + + response = main_module.get_execution_budget(execution_budget_id, user_id) + + assert response.status_code == 200 + assert json.loads(response.body)["execution_budget"]["id"] == str(execution_budget_id) + assert captured == { + "database_url": "postgresql://app", + "current_user_id": user_id, + "store_type": "ContinuityStore", + "user_id": user_id, + "execution_budget_id": execution_budget_id, + } + + +def test_get_execution_budget_endpoint_maps_missing_record_to_404(monkeypatch) -> None: + user_id = uuid4() + execution_budget_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + def fake_get_execution_budget_record(*_args, **_kwargs): + raise ExecutionBudgetNotFoundError(f"execution budget {execution_budget_id} was not found") + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "get_execution_budget_record", fake_get_execution_budget_record) + + response = main_module.get_execution_budget(execution_budget_id, user_id) + + assert response.status_code == 404 + assert json.loads(response.body) == { + "detail": f"execution budget {execution_budget_id} was not found" + } + + +def test_deactivate_execution_budget_endpoint_returns_payload(monkeypatch) -> None: + user_id = uuid4() + thread_id = uuid4() + execution_budget_id = uuid4() + settings = Settings(database_url="postgresql://app") + captured: dict[str, object] = {} + + @contextmanager + def fake_user_connection(database_url: str, current_user_id): + captured["database_url"] = database_url + captured["current_user_id"] = current_user_id + yield object() + + def fake_deactivate_execution_budget_record(store, *, user_id, request): + captured["store_type"] = type(store).__name__ + captured["user_id"] = user_id + captured["request"] = request + return { + "execution_budget": { + "id": str(execution_budget_id), + "agent_profile_id": None, + "tool_key": "proxy.echo", + "domain_hint": None, + "max_completed_executions": 1, + "rolling_window_seconds": None, + "status": "inactive", + "deactivated_at": "2026-03-13T12:00:00+00:00", + "superseded_by_budget_id": None, + "supersedes_budget_id": None, + "created_at": "2026-03-13T11:00:00+00:00", + }, + "trace": {"trace_id": "trace-123", "trace_event_count": 3}, + } + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "deactivate_execution_budget_record", fake_deactivate_execution_budget_record) + + response = main_module.deactivate_execution_budget( + execution_budget_id, + main_module.DeactivateExecutionBudgetRequest( + user_id=user_id, + thread_id=thread_id, + ), + ) + + assert response.status_code == 200 + assert json.loads(response.body)["execution_budget"]["status"] == "inactive" + assert captured["request"].thread_id == thread_id + assert captured["request"].execution_budget_id == execution_budget_id + + +def test_deactivate_execution_budget_endpoint_maps_lifecycle_error_to_409(monkeypatch) -> None: + user_id = uuid4() + thread_id = uuid4() + execution_budget_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + def fake_deactivate_execution_budget_record(*_args, **_kwargs): + raise ExecutionBudgetLifecycleError( + f"execution budget {execution_budget_id} is inactive and cannot be deactivated" + ) + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "deactivate_execution_budget_record", fake_deactivate_execution_budget_record) + + response = main_module.deactivate_execution_budget( + execution_budget_id, + main_module.DeactivateExecutionBudgetRequest( + user_id=user_id, + thread_id=thread_id, + ), + ) + + assert response.status_code == 409 + assert json.loads(response.body) == { + "detail": f"execution budget {execution_budget_id} is inactive and cannot be deactivated" + } + + +def test_supersede_execution_budget_endpoint_returns_payload(monkeypatch) -> None: + user_id = uuid4() + thread_id = uuid4() + execution_budget_id = uuid4() + settings = Settings(database_url="postgresql://app") + captured: dict[str, object] = {} + + @contextmanager + def fake_user_connection(database_url: str, current_user_id): + captured["database_url"] = database_url + captured["current_user_id"] = current_user_id + yield object() + + def fake_supersede_execution_budget_record(store, *, user_id, request): + captured["store_type"] = type(store).__name__ + captured["user_id"] = user_id + captured["request"] = request + return { + "superseded_budget": { + "id": str(execution_budget_id), + "agent_profile_id": None, + "tool_key": "proxy.echo", + "domain_hint": None, + "max_completed_executions": 1, + "rolling_window_seconds": 1800, + "status": "superseded", + "deactivated_at": "2026-03-13T12:00:00+00:00", + "superseded_by_budget_id": "budget-456", + "supersedes_budget_id": None, + "created_at": "2026-03-13T11:00:00+00:00", + }, + "replacement_budget": { + "id": "budget-456", + "agent_profile_id": None, + "tool_key": "proxy.echo", + "domain_hint": None, + "max_completed_executions": 3, + "rolling_window_seconds": 1800, + "status": "active", + "deactivated_at": None, + "superseded_by_budget_id": None, + "supersedes_budget_id": str(execution_budget_id), + "created_at": "2026-03-13T11:01:00+00:00", + }, + "trace": {"trace_id": "trace-456", "trace_event_count": 3}, + } + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "supersede_execution_budget_record", fake_supersede_execution_budget_record) + + response = main_module.supersede_execution_budget( + execution_budget_id, + main_module.SupersedeExecutionBudgetRequest( + user_id=user_id, + thread_id=thread_id, + max_completed_executions=3, + ), + ) + + assert response.status_code == 200 + body = json.loads(response.body) + assert body["superseded_budget"]["status"] == "superseded" + assert body["replacement_budget"]["status"] == "active" + assert captured["request"].thread_id == thread_id + assert captured["request"].execution_budget_id == execution_budget_id + assert captured["request"].max_completed_executions == 3 diff --git a/tests/unit/test_executions.py b/tests/unit/test_executions.py new file mode 100644 index 0000000..01dac78 --- /dev/null +++ b/tests/unit/test_executions.py @@ -0,0 +1,251 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from uuid import UUID, uuid4 + +import pytest + +from alicebot_api.executions import ( + ToolExecutionNotFoundError, + get_tool_execution_record, + list_tool_execution_records, +) + + +class ToolExecutionStoreStub: + def __init__(self) -> None: + self.base_time = datetime(2026, 3, 13, 10, 0, tzinfo=UTC) + self.user_id = uuid4() + self.thread_id = uuid4() + self.executions: list[dict[str, object]] = [] + + def seed_execution(self, *, tool_key: str, offset_minutes: int) -> dict[str, object]: + tool_id = uuid4() + execution = { + "id": uuid4(), + "user_id": self.user_id, + "approval_id": uuid4(), + "task_step_id": uuid4(), + "thread_id": self.thread_id, + "tool_id": tool_id, + "trace_id": uuid4(), + "request_event_id": uuid4(), + "result_event_id": uuid4(), + "status": "completed", + "handler_key": tool_key, + "request": { + "thread_id": str(self.thread_id), + "tool_id": str(tool_id), + "action": "tool.run", + "scope": "workspace", + "domain_hint": None, + "risk_hint": None, + "attributes": {"message": tool_key}, + }, + "tool": { + "id": str(tool_id), + "tool_key": tool_key, + "name": "Proxy Echo", + "description": "Deterministic proxy handler.", + "version": "1.0.0", + "metadata_version": "tool_metadata_v0", + "active": True, + "tags": ["proxy"], + "action_hints": ["tool.run"], + "scope_hints": ["workspace"], + "domain_hints": [], + "risk_hints": [], + "metadata": {"transport": "proxy"}, + "created_at": (self.base_time + timedelta(minutes=offset_minutes)).isoformat(), + }, + "result": { + "handler_key": tool_key, + "status": "completed", + "output": {"mode": "no_side_effect", "tool_key": tool_key}, + "reason": None, + }, + "executed_at": self.base_time + timedelta(minutes=offset_minutes), + } + self.executions.append(execution) + self.executions.sort(key=lambda row: (row["executed_at"], row["id"])) + return execution + + def seed_blocked_execution(self, *, tool_key: str, offset_minutes: int) -> dict[str, object]: + tool_id = uuid4() + execution = { + "id": uuid4(), + "user_id": self.user_id, + "approval_id": uuid4(), + "task_step_id": uuid4(), + "thread_id": self.thread_id, + "tool_id": tool_id, + "trace_id": uuid4(), + "request_event_id": None, + "result_event_id": None, + "status": "blocked", + "handler_key": None, + "request": { + "thread_id": str(self.thread_id), + "tool_id": str(tool_id), + "action": "tool.run", + "scope": "workspace", + "domain_hint": None, + "risk_hint": None, + "attributes": {"message": tool_key}, + }, + "tool": { + "id": str(tool_id), + "tool_key": tool_key, + "name": "Missing Proxy", + "description": "Missing handler.", + "version": "1.0.0", + "metadata_version": "tool_metadata_v0", + "active": True, + "tags": ["proxy"], + "action_hints": ["tool.run"], + "scope_hints": ["workspace"], + "domain_hints": [], + "risk_hints": [], + "metadata": {"transport": "proxy"}, + "created_at": (self.base_time + timedelta(minutes=offset_minutes)).isoformat(), + }, + "result": { + "handler_key": None, + "status": "blocked", + "output": None, + "reason": f"tool '{tool_key}' has no registered proxy handler", + }, + "executed_at": self.base_time + timedelta(minutes=offset_minutes), + } + self.executions.append(execution) + self.executions.sort(key=lambda row: (row["executed_at"], row["id"])) + return execution + + def list_tool_executions(self) -> list[dict[str, object]]: + return list(self.executions) + + def get_tool_execution_optional(self, execution_id: UUID) -> dict[str, object] | None: + return next((row for row in self.executions if row["id"] == execution_id), None) + + +def test_list_tool_execution_records_uses_explicit_order_and_summary() -> None: + store = ToolExecutionStoreStub() + first = store.seed_execution(tool_key="proxy.echo", offset_minutes=0) + second = store.seed_execution(tool_key="proxy.echo", offset_minutes=5) + + payload = list_tool_execution_records( + store, # type: ignore[arg-type] + user_id=store.user_id, + ) + + assert [item["id"] for item in payload["items"]] == [str(first["id"]), str(second["id"])] + assert payload["summary"] == { + "total_count": 2, + "order": ["executed_at_asc", "id_asc"], + } + + +def test_get_tool_execution_record_returns_detail_shape() -> None: + store = ToolExecutionStoreStub() + execution = store.seed_execution(tool_key="proxy.echo", offset_minutes=0) + + payload = get_tool_execution_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + execution_id=execution["id"], + ) + + assert payload["execution"]["id"] == str(execution["id"]) + assert payload["execution"]["approval_id"] == str(execution["approval_id"]) + assert payload["execution"]["task_step_id"] == str(execution["task_step_id"]) + assert payload["execution"]["status"] == "completed" + assert payload["execution"]["tool"]["tool_key"] == "proxy.echo" + assert payload["execution"]["result"]["output"] == { + "mode": "no_side_effect", + "tool_key": "proxy.echo", + } + + +def test_get_tool_execution_record_preserves_blocked_attempt_shape() -> None: + store = ToolExecutionStoreStub() + execution = store.seed_blocked_execution(tool_key="proxy.missing", offset_minutes=0) + + payload = get_tool_execution_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + execution_id=execution["id"], + ) + + assert payload["execution"]["status"] == "blocked" + assert payload["execution"]["handler_key"] is None + assert payload["execution"]["request_event_id"] is None + assert payload["execution"]["result_event_id"] is None + assert payload["execution"]["result"] == { + "handler_key": None, + "status": "blocked", + "output": None, + "reason": "tool 'proxy.missing' has no registered proxy handler", + } + + +def test_get_tool_execution_record_preserves_budget_blocked_attempt_shape() -> None: + store = ToolExecutionStoreStub() + execution = store.seed_blocked_execution(tool_key="proxy.echo", offset_minutes=0) + execution["result"] = { + "handler_key": None, + "status": "blocked", + "output": None, + "reason": "execution budget budget-123 blocks execution: projected completed executions 2 would exceed limit 1", + "budget_decision": { + "matched_budget_id": "budget-123", + "tool_key": "proxy.echo", + "domain_hint": None, + "budget_tool_key": "proxy.echo", + "budget_domain_hint": None, + "max_completed_executions": 1, + "rolling_window_seconds": None, + "count_scope": "lifetime", + "window_started_at": None, + "completed_execution_count": 1, + "projected_completed_execution_count": 2, + "decision": "block", + "reason": "budget_exceeded", + "order": ["specificity_desc", "created_at_asc", "id_asc"], + "history_order": ["executed_at_asc", "id_asc"], + }, + } + + payload = get_tool_execution_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + execution_id=execution["id"], + ) + + assert payload["execution"]["result"]["budget_decision"] == { + "matched_budget_id": "budget-123", + "tool_key": "proxy.echo", + "domain_hint": None, + "budget_tool_key": "proxy.echo", + "budget_domain_hint": None, + "max_completed_executions": 1, + "rolling_window_seconds": None, + "count_scope": "lifetime", + "window_started_at": None, + "completed_execution_count": 1, + "projected_completed_execution_count": 2, + "decision": "block", + "reason": "budget_exceeded", + "order": ["specificity_desc", "created_at_asc", "id_asc"], + "history_order": ["executed_at_asc", "id_asc"], + } + + +def test_get_tool_execution_record_raises_clear_error_when_missing() -> None: + store = ToolExecutionStoreStub() + + with pytest.raises(ToolExecutionNotFoundError, match="tool execution .* was not found"): + get_tool_execution_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + execution_id=uuid4(), + ) diff --git a/tests/unit/test_executions_main.py b/tests/unit/test_executions_main.py new file mode 100644 index 0000000..9070c0e --- /dev/null +++ b/tests/unit/test_executions_main.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +import json +from contextlib import contextmanager +from uuid import uuid4 + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.executions import ToolExecutionNotFoundError + + +def test_list_tool_executions_endpoint_returns_payload(monkeypatch) -> None: + user_id = uuid4() + settings = Settings(database_url="postgresql://app") + captured: dict[str, object] = {} + + @contextmanager + def fake_user_connection(database_url: str, current_user_id): + captured["database_url"] = database_url + captured["current_user_id"] = current_user_id + yield object() + + def fake_list_tool_execution_records(store, *, user_id): + captured["store_type"] = type(store).__name__ + captured["user_id"] = user_id + return { + "items": [ + { + "id": "execution-123", + "approval_id": "approval-123", + "task_step_id": "task-step-123", + "thread_id": "thread-123", + "tool_id": "tool-123", + "trace_id": "trace-123", + "request_event_id": "event-1", + "result_event_id": "event-2", + "status": "completed", + "handler_key": "proxy.echo", + "request": { + "thread_id": "thread-123", + "tool_id": "tool-123", + "action": "tool.run", + "scope": "workspace", + "domain_hint": None, + "risk_hint": None, + "attributes": {"message": "hello"}, + }, + "tool": {"id": "tool-123", "tool_key": "proxy.echo"}, + "result": { + "handler_key": "proxy.echo", + "status": "completed", + "output": {"mode": "no_side_effect"}, + "reason": None, + }, + "executed_at": "2026-03-13T10:00:00+00:00", + } + ], + "summary": {"total_count": 1, "order": ["executed_at_asc", "id_asc"]}, + } + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "list_tool_execution_records", fake_list_tool_execution_records) + + response = main_module.list_tool_executions(user_id) + + assert response.status_code == 200 + assert json.loads(response.body)["summary"] == { + "total_count": 1, + "order": ["executed_at_asc", "id_asc"], + } + assert captured == { + "database_url": "postgresql://app", + "current_user_id": user_id, + "store_type": "ContinuityStore", + "user_id": user_id, + } + + +def test_get_tool_execution_endpoint_returns_payload(monkeypatch) -> None: + user_id = uuid4() + execution_id = uuid4() + settings = Settings(database_url="postgresql://app") + captured: dict[str, object] = {} + + @contextmanager + def fake_user_connection(database_url: str, current_user_id): + captured["database_url"] = database_url + captured["current_user_id"] = current_user_id + yield object() + + def fake_get_tool_execution_record(store, *, user_id, execution_id): + captured["store_type"] = type(store).__name__ + captured["user_id"] = user_id + captured["execution_id"] = execution_id + return { + "execution": { + "id": str(execution_id), + "approval_id": "approval-123", + "task_step_id": "task-step-123", + "thread_id": "thread-123", + "tool_id": "tool-123", + "trace_id": "trace-123", + "request_event_id": "event-1", + "result_event_id": "event-2", + "status": "completed", + "handler_key": "proxy.echo", + "request": { + "thread_id": "thread-123", + "tool_id": "tool-123", + "action": "tool.run", + "scope": "workspace", + "domain_hint": None, + "risk_hint": None, + "attributes": {"message": "hello"}, + }, + "tool": {"id": "tool-123", "tool_key": "proxy.echo"}, + "result": { + "handler_key": "proxy.echo", + "status": "completed", + "output": {"mode": "no_side_effect"}, + "reason": None, + }, + "executed_at": "2026-03-13T10:00:00+00:00", + } + } + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "get_tool_execution_record", fake_get_tool_execution_record) + + response = main_module.get_tool_execution(execution_id, user_id) + + assert response.status_code == 200 + assert json.loads(response.body)["execution"]["id"] == str(execution_id) + assert captured == { + "database_url": "postgresql://app", + "current_user_id": user_id, + "store_type": "ContinuityStore", + "user_id": user_id, + "execution_id": execution_id, + } + + +def test_get_tool_execution_endpoint_maps_missing_record_to_404(monkeypatch) -> None: + user_id = uuid4() + execution_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + def fake_get_tool_execution_record(*_args, **_kwargs): + raise ToolExecutionNotFoundError(f"tool execution {execution_id} was not found") + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "get_tool_execution_record", fake_get_tool_execution_record) + + response = main_module.get_tool_execution(execution_id, user_id) + + assert response.status_code == 404 + assert json.loads(response.body) == { + "detail": f"tool execution {execution_id} was not found" + } diff --git a/tests/unit/test_explicit_commitments.py b/tests/unit/test_explicit_commitments.py new file mode 100644 index 0000000..fa0ad3f --- /dev/null +++ b/tests/unit/test_explicit_commitments.py @@ -0,0 +1,281 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import pytest + +from alicebot_api.contracts import AdmissionDecisionOutput, ExplicitCommitmentExtractionRequestInput +from alicebot_api.explicit_commitments import ( + ExplicitCommitmentExtractionValidationError, + _build_memory_key, + extract_and_admit_explicit_commitments, + extract_explicit_commitment_candidates, +) + + +class ExplicitCommitmentStoreStub: + def __init__(self) -> None: + self.base_time = datetime(2026, 3, 23, 9, 0, tzinfo=UTC) + self.events: dict[UUID, dict[str, object]] = {} + self.open_loops: dict[UUID, dict[str, object]] = {} + self.create_open_loop_calls = 0 + + def list_events_by_ids(self, event_ids: list[UUID]) -> list[dict[str, object]]: + return [self.events[event_id] for event_id in event_ids if event_id in self.events] + + def list_open_loops(self, *, status: str | None = None, limit: int | None = None) -> list[dict[str, object]]: + items = list(self.open_loops.values()) + if status is not None: + items = [item for item in items if item["status"] == status] + if limit is not None: + items = items[:limit] + return items + + def create_open_loop( + self, + *, + memory_id: UUID | None, + title: str, + status: str, + opened_at: datetime | None, + due_at: datetime | None, + resolved_at: datetime | None, + resolution_note: str | None, + ) -> dict[str, object]: + del opened_at, due_at, resolved_at + + self.create_open_loop_calls += 1 + open_loop_id = uuid4() + created = { + "id": open_loop_id, + "memory_id": memory_id, + "title": title, + "status": status, + "opened_at": self.base_time, + "due_at": None, + "resolved_at": None, + "resolution_note": resolution_note, + "created_at": self.base_time, + "updated_at": self.base_time, + } + self.open_loops[open_loop_id] = created + return created + + +def seed_event( + store: ExplicitCommitmentStoreStub, + *, + kind: str = "message.user", + text: str = "Remind me to submit tax forms.", +) -> UUID: + event_id = uuid4() + store.events[event_id] = { + "id": event_id, + "sequence_no": 1, + "kind": kind, + "payload": {"text": text}, + "created_at": store.base_time, + } + return event_id + + +def test_extract_explicit_commitment_candidates_returns_supported_candidate_shape() -> None: + event_id = UUID("11111111-1111-1111-1111-111111111111") + memory_key = _build_memory_key("submit tax forms") + + payload = extract_explicit_commitment_candidates( + source_event_id=event_id, + text="Remind me to submit tax forms.", + ) + + assert payload == [ + { + "memory_key": memory_key, + "value": { + "kind": "explicit_commitment", + "text": "submit tax forms", + }, + "source_event_ids": [str(event_id)], + "delete_requested": False, + "pattern": "remind_me_to", + "commitment_text": "submit tax forms", + "open_loop_title": "Remember to submit tax forms", + } + ] + + +def test_extract_explicit_commitment_candidates_supports_dont_let_me_forget_pattern() -> None: + event_id = UUID("22222222-2222-2222-2222-222222222222") + + payload = extract_explicit_commitment_candidates( + source_event_id=event_id, + text="Don't let me forget to call the clinic!", + ) + + assert payload[0]["pattern"] == "dont_let_me_forget_to" + assert payload[0]["commitment_text"] == "call the clinic" + + +def test_extract_explicit_commitment_candidates_returns_empty_for_unsupported_text() -> None: + assert extract_explicit_commitment_candidates( + source_event_id=uuid4(), + text="I had coffee yesterday.", + ) == [] + + +def test_extract_explicit_commitment_candidates_rejects_clause_style_text() -> None: + assert extract_explicit_commitment_candidates( + source_event_id=uuid4(), + text="Remember to if we can reschedule.", + ) == [] + + +def test_build_memory_key_is_case_insensitive_for_the_same_commitment() -> None: + assert _build_memory_key("Submit Tax Forms") == _build_memory_key("submit tax forms") + + +def test_extract_and_admit_explicit_commitments_rejects_invalid_source_event() -> None: + store = ExplicitCommitmentStoreStub() + event_id = seed_event(store, kind="message.assistant", text="Remind me to submit tax forms.") + + with pytest.raises( + ExplicitCommitmentExtractionValidationError, + match="source_event_id must reference an existing message.user event owned by the user", + ): + extract_and_admit_explicit_commitments( + store, # type: ignore[arg-type] + user_id=uuid4(), + request=ExplicitCommitmentExtractionRequestInput(source_event_id=event_id), + ) + + +def test_extract_and_admit_explicit_commitments_routes_candidate_through_memory_admission_and_creates_open_loop( + monkeypatch, +) -> None: + store = ExplicitCommitmentStoreStub() + user_id = uuid4() + event_id = seed_event(store, text="I need to submit tax forms.") + memory_id = UUID("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") + memory_key = _build_memory_key("submit tax forms") + captured: dict[str, object] = {} + + def fake_admit_memory_candidate(store_arg, *, user_id, candidate): + captured["store"] = store_arg + captured["user_id"] = user_id + captured["candidate"] = candidate + return AdmissionDecisionOutput( + action="ADD", + reason="source_backed_add", + memory={ + "id": str(memory_id), + "user_id": str(user_id), + "memory_key": candidate.memory_key, + "value": candidate.value, + "status": "active", + "source_event_ids": [str(event_id)], + "memory_type": "commitment", + "created_at": "2026-03-23T09:00:00+00:00", + "updated_at": "2026-03-23T09:00:00+00:00", + "deleted_at": None, + }, + revision={ + "id": "revision-123", + "user_id": str(user_id), + "memory_id": str(memory_id), + "sequence_no": 1, + "action": "ADD", + "memory_key": candidate.memory_key, + "previous_value": None, + "new_value": candidate.value, + "source_event_ids": [str(event_id)], + "candidate": candidate.as_payload(), + "created_at": "2026-03-23T09:00:00+00:00", + }, + ) + + monkeypatch.setattr( + "alicebot_api.explicit_commitments.admit_memory_candidate", + fake_admit_memory_candidate, + ) + + payload = extract_and_admit_explicit_commitments( + store, # type: ignore[arg-type] + user_id=user_id, + request=ExplicitCommitmentExtractionRequestInput(source_event_id=event_id), + ) + + assert captured["store"] is store + assert captured["user_id"] == user_id + assert captured["candidate"].memory_key == memory_key + assert captured["candidate"].memory_type == "commitment" + assert payload["admissions"][0]["open_loop"]["decision"] == "CREATED" + assert payload["admissions"][0]["open_loop"]["open_loop"]["memory_id"] == str(memory_id) + assert payload["summary"] == { + "source_event_id": str(event_id), + "source_event_kind": "message.user", + "candidate_count": 1, + "admission_count": 1, + "persisted_change_count": 1, + "noop_count": 0, + "open_loop_created_count": 1, + "open_loop_noop_count": 0, + } + assert store.create_open_loop_calls == 1 + + +def test_extract_and_admit_explicit_commitments_keeps_existing_active_open_loop_without_duplicate( + monkeypatch, +) -> None: + store = ExplicitCommitmentStoreStub() + user_id = uuid4() + event_id = seed_event(store, text="Remember to submit tax forms.") + memory_id = UUID("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb") + existing_open_loop_id = UUID("cccccccc-cccc-4ccc-8ccc-cccccccccccc") + + store.open_loops[existing_open_loop_id] = { + "id": existing_open_loop_id, + "memory_id": memory_id, + "title": "Remember to submit tax forms", + "status": "open", + "opened_at": store.base_time, + "due_at": None, + "resolved_at": None, + "resolution_note": None, + "created_at": store.base_time, + "updated_at": store.base_time, + } + + def fake_admit_memory_candidate(_store_arg, *, user_id, candidate): + return AdmissionDecisionOutput( + action="NOOP", + reason="memory_unchanged", + memory={ + "id": str(memory_id), + "user_id": str(user_id), + "memory_key": candidate.memory_key, + "value": candidate.value, + "status": "active", + "source_event_ids": [str(event_id)], + "memory_type": "commitment", + "created_at": "2026-03-23T09:00:00+00:00", + "updated_at": "2026-03-23T09:00:00+00:00", + "deleted_at": None, + }, + revision=None, + ) + + monkeypatch.setattr( + "alicebot_api.explicit_commitments.admit_memory_candidate", + fake_admit_memory_candidate, + ) + + payload = extract_and_admit_explicit_commitments( + store, # type: ignore[arg-type] + user_id=user_id, + request=ExplicitCommitmentExtractionRequestInput(source_event_id=event_id), + ) + + assert payload["admissions"][0]["open_loop"]["decision"] == "NOOP_ACTIVE_EXISTS" + assert payload["admissions"][0]["open_loop"]["open_loop"]["id"] == str(existing_open_loop_id) + assert store.create_open_loop_calls == 0 diff --git a/tests/unit/test_explicit_preferences.py b/tests/unit/test_explicit_preferences.py new file mode 100644 index 0000000..7fb5a31 --- /dev/null +++ b/tests/unit/test_explicit_preferences.py @@ -0,0 +1,266 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import pytest + +from alicebot_api.contracts import AdmissionDecisionOutput, ExplicitPreferenceExtractionRequestInput +from alicebot_api.explicit_preferences import ( + ExplicitPreferenceExtractionValidationError, + _build_memory_key, + extract_and_admit_explicit_preferences, + extract_explicit_preference_candidates, +) + + +class ExplicitPreferenceStoreStub: + def __init__(self) -> None: + self.base_time = datetime(2026, 3, 12, 9, 0, tzinfo=UTC) + self.events: dict[UUID, dict[str, object]] = {} + + def list_events_by_ids(self, event_ids: list[UUID]) -> list[dict[str, object]]: + return [self.events[event_id] for event_id in event_ids if event_id in self.events] + + +def seed_event( + store: ExplicitPreferenceStoreStub, + *, + kind: str = "message.user", + text: str = "I like black coffee.", +) -> UUID: + event_id = uuid4() + store.events[event_id] = { + "id": event_id, + "sequence_no": 1, + "kind": kind, + "payload": {"text": text}, + "created_at": store.base_time, + } + return event_id + + +def test_extract_explicit_preference_candidates_returns_supported_candidate_shape() -> None: + event_id = UUID("11111111-1111-1111-1111-111111111111") + memory_key = _build_memory_key("black coffee") + + payload = extract_explicit_preference_candidates( + source_event_id=event_id, + text="I like black coffee.", + ) + + assert payload == [ + { + "memory_key": memory_key, + "value": { + "kind": "explicit_preference", + "preference": "like", + "text": "black coffee", + }, + "source_event_ids": [str(event_id)], + "delete_requested": False, + "pattern": "i_like", + "subject_text": "black coffee", + } + ] + + +def test_extract_explicit_preference_candidates_keeps_remember_pattern_deterministic() -> None: + event_id = UUID("22222222-2222-2222-2222-222222222222") + memory_key = _build_memory_key("oat milk") + + payload = extract_explicit_preference_candidates( + source_event_id=event_id, + text=" remember that I prefer oat milk!! ", + ) + + assert payload == [ + { + "memory_key": memory_key, + "value": { + "kind": "explicit_preference", + "preference": "prefer", + "text": "oat milk", + }, + "source_event_ids": [str(event_id)], + "delete_requested": False, + "pattern": "remember_that_i_prefer", + "subject_text": "oat milk", + } + ] + + +def test_extract_explicit_preference_candidates_returns_empty_for_unsupported_text() -> None: + assert extract_explicit_preference_candidates( + source_event_id=uuid4(), + text="I had coffee yesterday.", + ) == [] + + +def test_extract_explicit_preference_candidates_rejects_clause_style_text() -> None: + assert extract_explicit_preference_candidates( + source_event_id=uuid4(), + text="I prefer that we meet tomorrow.", + ) == [] + + +def test_build_memory_key_keeps_symbol_bearing_subjects_distinct() -> None: + c_plus_plus_key = _build_memory_key("C++") + c_hash_key = _build_memory_key("C#") + + assert c_plus_plus_key != c_hash_key + assert c_plus_plus_key.startswith("user.preference.c__") + assert c_hash_key.startswith("user.preference.c__") + + +def test_build_memory_key_is_case_insensitive_for_the_same_subject() -> None: + assert _build_memory_key("Black Coffee") == _build_memory_key("black coffee") + + +def test_extract_and_admit_explicit_preferences_rejects_invalid_source_event() -> None: + store = ExplicitPreferenceStoreStub() + event_id = seed_event(store, kind="message.assistant", text="I like black coffee.") + + with pytest.raises( + ExplicitPreferenceExtractionValidationError, + match="source_event_id must reference an existing message.user event owned by the user", + ): + extract_and_admit_explicit_preferences( + store, # type: ignore[arg-type] + user_id=uuid4(), + request=ExplicitPreferenceExtractionRequestInput(source_event_id=event_id), + ) + + +def test_extract_and_admit_explicit_preferences_routes_candidate_through_memory_admission( + monkeypatch, +) -> None: + store = ExplicitPreferenceStoreStub() + user_id = uuid4() + event_id = seed_event(store, text="I don't like black coffee.") + memory_key = _build_memory_key("black coffee") + captured: dict[str, object] = {} + + def fake_admit_memory_candidate(store_arg, *, user_id, candidate): + captured["store"] = store_arg + captured["user_id"] = user_id + captured["candidate"] = candidate + return AdmissionDecisionOutput( + action="ADD", + reason="source_backed_add", + memory={ + "id": "memory-123", + "user_id": str(user_id), + "memory_key": candidate.memory_key, + "value": candidate.value, + "status": "active", + "source_event_ids": [str(event_id)], + "created_at": "2026-03-12T09:00:00+00:00", + "updated_at": "2026-03-12T09:00:00+00:00", + "deleted_at": None, + }, + revision={ + "id": "revision-123", + "user_id": str(user_id), + "memory_id": "memory-123", + "sequence_no": 1, + "action": "ADD", + "memory_key": candidate.memory_key, + "previous_value": None, + "new_value": candidate.value, + "source_event_ids": [str(event_id)], + "candidate": candidate.as_payload(), + "created_at": "2026-03-12T09:00:00+00:00", + }, + ) + + monkeypatch.setattr( + "alicebot_api.explicit_preferences.admit_memory_candidate", + fake_admit_memory_candidate, + ) + + payload = extract_and_admit_explicit_preferences( + store, # type: ignore[arg-type] + user_id=user_id, + request=ExplicitPreferenceExtractionRequestInput(source_event_id=event_id), + ) + + assert captured["store"] is store + assert captured["user_id"] == user_id + assert captured["candidate"].memory_key == memory_key + assert captured["candidate"].value == { + "kind": "explicit_preference", + "preference": "dislike", + "text": "black coffee", + } + assert payload == { + "candidates": [ + { + "memory_key": memory_key, + "value": { + "kind": "explicit_preference", + "preference": "dislike", + "text": "black coffee", + }, + "source_event_ids": [str(event_id)], + "delete_requested": False, + "pattern": "i_dont_like", + "subject_text": "black coffee", + } + ], + "admissions": [ + { + "decision": "ADD", + "reason": "source_backed_add", + "memory": { + "id": "memory-123", + "user_id": str(user_id), + "memory_key": memory_key, + "value": { + "kind": "explicit_preference", + "preference": "dislike", + "text": "black coffee", + }, + "status": "active", + "source_event_ids": [str(event_id)], + "created_at": "2026-03-12T09:00:00+00:00", + "updated_at": "2026-03-12T09:00:00+00:00", + "deleted_at": None, + }, + "revision": { + "id": "revision-123", + "user_id": str(user_id), + "memory_id": "memory-123", + "sequence_no": 1, + "action": "ADD", + "memory_key": memory_key, + "previous_value": None, + "new_value": { + "kind": "explicit_preference", + "preference": "dislike", + "text": "black coffee", + }, + "source_event_ids": [str(event_id)], + "candidate": { + "memory_key": memory_key, + "value": { + "kind": "explicit_preference", + "preference": "dislike", + "text": "black coffee", + }, + "source_event_ids": [str(event_id)], + "delete_requested": False, + }, + "created_at": "2026-03-12T09:00:00+00:00", + }, + } + ], + "summary": { + "source_event_id": str(event_id), + "source_event_kind": "message.user", + "candidate_count": 1, + "admission_count": 1, + "persisted_change_count": 1, + "noop_count": 0, + }, + } diff --git a/tests/unit/test_explicit_signal_capture.py b/tests/unit/test_explicit_signal_capture.py new file mode 100644 index 0000000..d0f986e --- /dev/null +++ b/tests/unit/test_explicit_signal_capture.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +from uuid import uuid4 + +import pytest + +from alicebot_api.contracts import ExplicitSignalCaptureRequestInput +from alicebot_api.explicit_preferences import ExplicitPreferenceExtractionValidationError +from alicebot_api.explicit_signal_capture import ( + ExplicitSignalCaptureValidationError, + extract_and_admit_explicit_signals, +) + + +def test_extract_and_admit_explicit_signals_runs_preferences_before_commitments(monkeypatch) -> None: + source_event_id = uuid4() + user_id = uuid4() + call_order: list[str] = [] + + def fake_extract_preferences(_store, *, user_id, request): + call_order.append("preferences") + assert request.source_event_id == source_event_id + assert user_id + return { + "candidates": [], + "admissions": [], + "summary": { + "source_event_id": str(source_event_id), + "source_event_kind": "message.user", + "candidate_count": 0, + "admission_count": 0, + "persisted_change_count": 0, + "noop_count": 0, + }, + } + + def fake_extract_commitments(_store, *, user_id, request): + call_order.append("commitments") + assert request.source_event_id == source_event_id + assert user_id + return { + "candidates": [ + { + "memory_key": "user.commitment.submit_tax_forms", + "value": { + "kind": "explicit_commitment", + "text": "submit tax forms", + }, + "source_event_ids": [str(source_event_id)], + "delete_requested": False, + "pattern": "remind_me_to", + "commitment_text": "submit tax forms", + "open_loop_title": "Remember to submit tax forms", + } + ], + "admissions": [], + "summary": { + "source_event_id": str(source_event_id), + "source_event_kind": "message.user", + "candidate_count": 1, + "admission_count": 0, + "persisted_change_count": 0, + "noop_count": 0, + "open_loop_created_count": 0, + "open_loop_noop_count": 0, + }, + } + + monkeypatch.setattr( + "alicebot_api.explicit_signal_capture.extract_and_admit_explicit_preferences", + fake_extract_preferences, + ) + monkeypatch.setattr( + "alicebot_api.explicit_signal_capture.extract_and_admit_explicit_commitments", + fake_extract_commitments, + ) + + payload = extract_and_admit_explicit_signals( + object(), # type: ignore[arg-type] + user_id=user_id, + request=ExplicitSignalCaptureRequestInput(source_event_id=source_event_id), + ) + + assert call_order == ["preferences", "commitments"] + assert payload["summary"] == { + "source_event_id": str(source_event_id), + "source_event_kind": "message.user", + "candidate_count": 1, + "admission_count": 0, + "persisted_change_count": 0, + "noop_count": 0, + "open_loop_created_count": 0, + "open_loop_noop_count": 0, + "preference_candidate_count": 0, + "preference_admission_count": 0, + "commitment_candidate_count": 1, + "commitment_admission_count": 0, + } + + +def test_extract_and_admit_explicit_signals_wraps_validation_error_from_pipeline(monkeypatch) -> None: + source_event_id = uuid4() + + def fake_extract_preferences(_store, *, user_id, request): + del user_id, request + raise ExplicitPreferenceExtractionValidationError( + "source_event_id must reference an existing message.user event owned by the user" + ) + + def fake_extract_commitments(_store, *, user_id, request): + del user_id, request + raise AssertionError("commitments pipeline should not run after preference validation failure") + + monkeypatch.setattr( + "alicebot_api.explicit_signal_capture.extract_and_admit_explicit_preferences", + fake_extract_preferences, + ) + monkeypatch.setattr( + "alicebot_api.explicit_signal_capture.extract_and_admit_explicit_commitments", + fake_extract_commitments, + ) + + with pytest.raises( + ExplicitSignalCaptureValidationError, + match="source_event_id must reference an existing message.user event owned by the user", + ): + extract_and_admit_explicit_signals( + object(), # type: ignore[arg-type] + user_id=uuid4(), + request=ExplicitSignalCaptureRequestInput(source_event_id=source_event_id), + ) diff --git a/tests/unit/test_gmail.py b/tests/unit/test_gmail.py new file mode 100644 index 0000000..ebcd6c9 --- /dev/null +++ b/tests/unit/test_gmail.py @@ -0,0 +1,1232 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from pathlib import Path +from uuid import UUID, uuid4 + +import pytest + +from alicebot_api.artifacts import TaskArtifactAlreadyExistsError +from alicebot_api.contracts import ( + GMAIL_PROTECTED_CREDENTIAL_KIND, + GMAIL_REFRESHABLE_PROTECTED_CREDENTIAL_KIND, + GMAIL_READONLY_SCOPE, + GmailAccountConnectInput, + GmailMessageIngestInput, +) +from alicebot_api.gmail import ( + GmailAccountAlreadyExistsError, + GmailAccountNotFoundError, + GmailCredentialInvalidError, + GmailCredentialNotFoundError, + GmailCredentialPersistenceError, + GmailCredentialValidationError, + GmailMessageUnsupportedError, + GMAIL_SECRET_MANAGER_KIND_FILE_V1, + GMAIL_SECRET_MANAGER_KIND_LEGACY_DB_V0, + RefreshedGmailCredential, + build_gmail_secret_ref, + build_gmail_message_artifact_relative_path, + build_gmail_protected_credential_blob, + create_gmail_account_record, + get_gmail_account_record, + ingest_gmail_message_record, + list_gmail_account_records, + resolve_gmail_access_token, +) +from alicebot_api.gmail_secret_manager import GmailSecretManagerError +from alicebot_api.store import ContinuityStoreInvariantError +from alicebot_api.workspaces import TaskWorkspaceNotFoundError + + +def _build_rfc822_email_bytes(*, plain_body: str) -> bytes: + return ( + "\r\n".join( + [ + "From: Alice <alice@example.com>", + "To: Bob <bob@example.com>", + "Subject: Sprint Update", + 'Content-Type: text/plain; charset="utf-8"', + "Content-Transfer-Encoding: 8bit", + "", + plain_body, + ] + ).encode("utf-8") + ) + + +class GmailStoreStub: + def __init__(self) -> None: + self.base_time = datetime(2026, 3, 16, 10, 0, tzinfo=UTC) + self.gmail_accounts: list[dict[str, object]] = [] + self.gmail_account_credentials: dict[UUID, dict[str, object]] = {} + self.task_workspaces: list[dict[str, object]] = [] + self.task_artifacts: list[dict[str, object]] = [] + self.operations: list[tuple[str, object]] = [] + + def create_gmail_account( + self, + *, + provider_account_id: str, + email_address: str, + display_name: str | None, + scope: str, + ) -> dict[str, object]: + row = { + "id": uuid4(), + "user_id": uuid4(), + "provider_account_id": provider_account_id, + "email_address": email_address, + "display_name": display_name, + "scope": scope, + "created_at": self.base_time + timedelta(minutes=len(self.gmail_accounts)), + "updated_at": self.base_time + timedelta(minutes=len(self.gmail_accounts)), + } + self.gmail_accounts.append(row) + return row + + def create_gmail_account_credential( + self, + *, + gmail_account_id: UUID, + auth_kind: str, + credential_kind: str, + secret_manager_kind: str, + secret_ref: str | None, + credential_blob: dict[str, object] | None, + ) -> dict[str, object]: + row = { + "gmail_account_id": gmail_account_id, + "user_id": next( + account["user_id"] + for account in self.gmail_accounts + if account["id"] == gmail_account_id + ), + "auth_kind": auth_kind, + "credential_kind": credential_kind, + "secret_manager_kind": secret_manager_kind, + "secret_ref": secret_ref, + "credential_blob": credential_blob, + "created_at": self.base_time + timedelta(minutes=len(self.gmail_account_credentials)), + "updated_at": self.base_time + timedelta(minutes=len(self.gmail_account_credentials)), + } + self.gmail_account_credentials[gmail_account_id] = row + self.operations.append(("create_gmail_account_credential", gmail_account_id)) + return row + + def get_gmail_account_optional(self, gmail_account_id: UUID) -> dict[str, object] | None: + return next( + (row for row in self.gmail_accounts if row["id"] == gmail_account_id), + None, + ) + + def get_gmail_account_credential_optional( + self, + gmail_account_id: UUID, + ) -> dict[str, object] | None: + self.operations.append(("get_gmail_account_credential_optional", gmail_account_id)) + return self.gmail_account_credentials.get(gmail_account_id) + + def update_gmail_account_credential( + self, + *, + gmail_account_id: UUID, + auth_kind: str, + credential_kind: str, + secret_manager_kind: str, + secret_ref: str | None, + credential_blob: dict[str, object] | None, + ) -> dict[str, object]: + existing = self.gmail_account_credentials[gmail_account_id] + updated = { + **existing, + "auth_kind": auth_kind, + "credential_kind": credential_kind, + "secret_manager_kind": secret_manager_kind, + "secret_ref": secret_ref, + "credential_blob": credential_blob, + "updated_at": self.base_time + timedelta(hours=1), + } + self.gmail_account_credentials[gmail_account_id] = updated + self.operations.append(("update_gmail_account_credential", gmail_account_id)) + return updated + + def get_gmail_account_by_provider_account_id_optional( + self, + provider_account_id: str, + ) -> dict[str, object] | None: + return next( + ( + row + for row in self.gmail_accounts + if row["provider_account_id"] == provider_account_id + ), + None, + ) + + def list_gmail_accounts(self) -> list[dict[str, object]]: + return sorted( + self.gmail_accounts, + key=lambda row: (row["created_at"], row["id"]), + ) + + def create_task_workspace(self, *, task_workspace_id: UUID, local_path: str) -> dict[str, object]: + row = { + "id": task_workspace_id, + "user_id": uuid4(), + "task_id": uuid4(), + "status": "active", + "local_path": local_path, + "created_at": self.base_time, + "updated_at": self.base_time, + } + self.task_workspaces.append(row) + return row + + def get_task_workspace_optional(self, task_workspace_id: UUID) -> dict[str, object] | None: + return next( + (row for row in self.task_workspaces if row["id"] == task_workspace_id), + None, + ) + + def lock_task_artifacts(self, task_workspace_id: UUID) -> None: + self.operations.append(("lock_task_artifacts", task_workspace_id)) + + def create_task_artifact( + self, + *, + task_workspace_id: UUID, + relative_path: str, + ) -> dict[str, object]: + row = { + "id": uuid4(), + "user_id": uuid4(), + "task_id": uuid4(), + "task_workspace_id": task_workspace_id, + "status": "registered", + "ingestion_status": "ingested", + "relative_path": relative_path, + "media_type_hint": "message/rfc822", + "created_at": self.base_time, + "updated_at": self.base_time, + } + self.task_artifacts.append(row) + return row + + def get_task_artifact_by_workspace_relative_path_optional( + self, + *, + task_workspace_id: UUID, + relative_path: str, + ) -> dict[str, object] | None: + self.operations.append( + ("get_task_artifact_by_workspace_relative_path_optional", task_workspace_id) + ) + return next( + ( + row + for row in self.task_artifacts + if row["task_workspace_id"] == task_workspace_id + and row["relative_path"] == relative_path + ), + None, + ) + + +class GmailSecretManagerStub: + def __init__(self) -> None: + self.secrets: dict[str, dict[str, object]] = {} + self.operations: list[tuple[str, str]] = [] + + @property + def kind(self) -> str: + return GMAIL_SECRET_MANAGER_KIND_FILE_V1 + + def load_secret(self, *, secret_ref: str) -> dict[str, object]: + self.operations.append(("load_secret", secret_ref)) + try: + return dict(self.secrets[secret_ref]) + except KeyError as exc: + raise GmailSecretManagerError(f"gmail secret {secret_ref} was not found") from exc + + def write_secret(self, *, secret_ref: str, payload: dict[str, object]) -> None: + self.operations.append(("write_secret", secret_ref)) + self.secrets[secret_ref] = dict(payload) + + def delete_secret(self, *, secret_ref: str) -> None: + self.operations.append(("delete_secret", secret_ref)) + self.secrets.pop(secret_ref, None) + + +def test_create_list_and_get_gmail_account_records_are_deterministic() -> None: + store = GmailStoreStub() + secret_manager = GmailSecretManagerStub() + user_id = uuid4() + + first = create_gmail_account_record( + store, + secret_manager, + user_id=user_id, + request=GmailAccountConnectInput( + provider_account_id="acct-001", + email_address="owner@example.com", + display_name="Owner", + scope=GMAIL_READONLY_SCOPE, + access_token="token-1", + ), + ) + second = create_gmail_account_record( + store, + secret_manager, + user_id=user_id, + request=GmailAccountConnectInput( + provider_account_id="acct-002", + email_address="owner+2@example.com", + display_name=None, + scope=GMAIL_READONLY_SCOPE, + access_token="token-2", + ), + ) + + assert list_gmail_account_records(store, user_id=user_id) == { + "items": [first["account"], second["account"]], + "summary": {"total_count": 2, "order": ["created_at_asc", "id_asc"]}, + } + assert get_gmail_account_record( + store, + user_id=user_id, + gmail_account_id=UUID(second["account"]["id"]), + ) == {"account": second["account"]} + assert "access_token" not in first["account"] + assert "access_token" not in second["account"] + + +def test_create_gmail_account_record_persists_protected_credential_and_hides_secret() -> None: + store = GmailStoreStub() + secret_manager = GmailSecretManagerStub() + user_id = uuid4() + + response = create_gmail_account_record( + store, + secret_manager, + user_id=user_id, + request=GmailAccountConnectInput( + provider_account_id="acct-001", + email_address="owner@example.com", + display_name="Owner", + scope=GMAIL_READONLY_SCOPE, + access_token="token-1", + ), + ) + + account_id = UUID(response["account"]["id"]) + assert response == { + "account": { + "id": str(account_id), + "provider": "gmail", + "auth_kind": "oauth_access_token", + "provider_account_id": "acct-001", + "email_address": "owner@example.com", + "display_name": "Owner", + "scope": GMAIL_READONLY_SCOPE, + "created_at": response["account"]["created_at"], + "updated_at": response["account"]["updated_at"], + } + } + secret_ref = build_gmail_secret_ref( + user_id=store.gmail_account_credentials[account_id]["user_id"], + gmail_account_id=account_id, + ) + assert store.gmail_account_credentials[account_id] == { + "gmail_account_id": account_id, + "user_id": store.gmail_account_credentials[account_id]["user_id"], + "auth_kind": "oauth_access_token", + "credential_kind": GMAIL_PROTECTED_CREDENTIAL_KIND, + "secret_manager_kind": GMAIL_SECRET_MANAGER_KIND_FILE_V1, + "secret_ref": secret_ref, + "credential_blob": None, + "created_at": store.gmail_account_credentials[account_id]["created_at"], + "updated_at": store.gmail_account_credentials[account_id]["updated_at"], + } + assert secret_manager.secrets[secret_ref] == { + "credential_kind": GMAIL_PROTECTED_CREDENTIAL_KIND, + "access_token": "token-1", + } + assert store.operations == [("create_gmail_account_credential", account_id)] + assert secret_manager.operations == [("write_secret", secret_ref)] + + +def test_create_gmail_account_record_persists_refreshable_protected_credential_and_hides_secret() -> None: + store = GmailStoreStub() + secret_manager = GmailSecretManagerStub() + user_id = uuid4() + expires_at = datetime(2030, 1, 1, 0, 0, tzinfo=UTC) + + response = create_gmail_account_record( + store, + secret_manager, + user_id=user_id, + request=GmailAccountConnectInput( + provider_account_id="acct-refresh-001", + email_address="owner@example.com", + display_name="Owner", + scope=GMAIL_READONLY_SCOPE, + access_token="token-1", + refresh_token="refresh-1", + client_id="client-1", + client_secret="secret-1", + access_token_expires_at=expires_at, + ), + ) + + account_id = UUID(response["account"]["id"]) + assert response == { + "account": { + "id": str(account_id), + "provider": "gmail", + "auth_kind": "oauth_access_token", + "provider_account_id": "acct-refresh-001", + "email_address": "owner@example.com", + "display_name": "Owner", + "scope": GMAIL_READONLY_SCOPE, + "created_at": response["account"]["created_at"], + "updated_at": response["account"]["updated_at"], + } + } + secret_ref = build_gmail_secret_ref( + user_id=store.gmail_account_credentials[account_id]["user_id"], + gmail_account_id=account_id, + ) + assert store.gmail_account_credentials[account_id]["credential_blob"] is None + assert store.gmail_account_credentials[account_id]["credential_kind"] == ( + GMAIL_REFRESHABLE_PROTECTED_CREDENTIAL_KIND + ) + assert store.gmail_account_credentials[account_id]["secret_manager_kind"] == ( + GMAIL_SECRET_MANAGER_KIND_FILE_V1 + ) + assert store.gmail_account_credentials[account_id]["secret_ref"] == secret_ref + assert secret_manager.secrets[secret_ref] == { + "credential_kind": GMAIL_REFRESHABLE_PROTECTED_CREDENTIAL_KIND, + "access_token": "token-1", + "refresh_token": "refresh-1", + "client_id": "client-1", + "client_secret": "secret-1", + "access_token_expires_at": expires_at.isoformat(), + } + assert store.operations == [("create_gmail_account_credential", account_id)] + + +def test_build_gmail_protected_credential_blob_rejects_partial_refresh_bundle() -> None: + with pytest.raises( + GmailCredentialValidationError, + match=( + "gmail refresh credentials must include refresh_token, client_id, client_secret, " + "and access_token_expires_at" + ), + ): + build_gmail_protected_credential_blob( + access_token="token-1", + refresh_token="refresh-1", + client_id="client-1", + ) + + +def test_create_gmail_account_record_rejects_duplicate_provider_account_id() -> None: + store = GmailStoreStub() + secret_manager = GmailSecretManagerStub() + user_id = uuid4() + request = GmailAccountConnectInput( + provider_account_id="acct-001", + email_address="owner@example.com", + display_name="Owner", + scope=GMAIL_READONLY_SCOPE, + access_token="token-1", + ) + create_gmail_account_record(store, secret_manager, user_id=user_id, request=request) + + with pytest.raises( + GmailAccountAlreadyExistsError, + match="gmail account acct-001 is already connected", + ): + create_gmail_account_record(store, secret_manager, user_id=user_id, request=request) + + +def test_get_gmail_account_record_raises_when_account_is_missing() -> None: + with pytest.raises(GmailAccountNotFoundError, match="was not found"): + get_gmail_account_record( + GmailStoreStub(), + user_id=uuid4(), + gmail_account_id=uuid4(), + ) + + +def test_resolve_gmail_access_token_reads_protected_credential() -> None: + store = GmailStoreStub() + secret_manager = GmailSecretManagerStub() + account = create_gmail_account_record( + store, + secret_manager, + user_id=uuid4(), + request=GmailAccountConnectInput( + provider_account_id="acct-001", + email_address="owner@example.com", + display_name="Owner", + scope=GMAIL_READONLY_SCOPE, + access_token="token-1", + ), + )["account"] + + assert resolve_gmail_access_token( + store, + secret_manager, + gmail_account_id=UUID(account["id"]), + ) == "token-1" + + +def test_resolve_gmail_access_token_rejects_missing_and_invalid_protected_credentials() -> None: + store = GmailStoreStub() + secret_manager = GmailSecretManagerStub() + account = create_gmail_account_record( + store, + secret_manager, + user_id=uuid4(), + request=GmailAccountConnectInput( + provider_account_id="acct-001", + email_address="owner@example.com", + display_name="Owner", + scope=GMAIL_READONLY_SCOPE, + access_token="token-1", + ), + )["account"] + account_id = UUID(account["id"]) + + store.gmail_account_credentials.pop(account_id) + with pytest.raises( + GmailCredentialNotFoundError, + match=f"gmail account {account_id} is missing protected credentials", + ): + resolve_gmail_access_token(store, secret_manager, gmail_account_id=account_id) + + store.gmail_account_credentials[account_id] = { + "gmail_account_id": account_id, + "user_id": uuid4(), + "auth_kind": "oauth_access_token", + "credential_kind": GMAIL_PROTECTED_CREDENTIAL_KIND, + "secret_manager_kind": GMAIL_SECRET_MANAGER_KIND_LEGACY_DB_V0, + "secret_ref": None, + "credential_blob": {"credential_kind": GMAIL_PROTECTED_CREDENTIAL_KIND}, + "created_at": store.base_time, + "updated_at": store.base_time, + } + with pytest.raises( + GmailCredentialInvalidError, + match=f"gmail account {account_id} has invalid protected credentials", + ): + resolve_gmail_access_token(store, secret_manager, gmail_account_id=account_id) + + +def test_resolve_gmail_access_token_externalizes_legacy_db_credentials_on_first_read() -> None: + store = GmailStoreStub() + secret_manager = GmailSecretManagerStub() + account = create_gmail_account_record( + store, + secret_manager, + user_id=uuid4(), + request=GmailAccountConnectInput( + provider_account_id="acct-legacy-001", + email_address="owner@example.com", + display_name="Owner", + scope=GMAIL_READONLY_SCOPE, + access_token="token-1", + ), + )["account"] + account_id = UUID(account["id"]) + credential_row = store.gmail_account_credentials[account_id] + legacy_blob = { + "credential_kind": GMAIL_PROTECTED_CREDENTIAL_KIND, + "access_token": "token-legacy-001", + } + secret_ref = credential_row["secret_ref"] + assert secret_ref is not None + credential_row["secret_manager_kind"] = GMAIL_SECRET_MANAGER_KIND_LEGACY_DB_V0 + credential_row["secret_ref"] = None + credential_row["credential_blob"] = legacy_blob + secret_manager.secrets.pop(secret_ref) + + assert resolve_gmail_access_token(store, secret_manager, gmail_account_id=account_id) == ( + "token-legacy-001" + ) + assert store.gmail_account_credentials[account_id]["secret_manager_kind"] == ( + GMAIL_SECRET_MANAGER_KIND_FILE_V1 + ) + assert store.gmail_account_credentials[account_id]["secret_ref"] == secret_ref + assert store.gmail_account_credentials[account_id]["credential_blob"] is None + assert secret_manager.secrets[secret_ref] == legacy_blob + + +def test_resolve_gmail_access_token_renews_expired_refreshable_credential(monkeypatch) -> None: + store = GmailStoreStub() + secret_manager = GmailSecretManagerStub() + expired_at = datetime(2020, 1, 1, 0, 0, tzinfo=UTC) + refreshed_at = datetime(2030, 1, 1, 0, 5, tzinfo=UTC) + account = create_gmail_account_record( + store, + secret_manager, + user_id=uuid4(), + request=GmailAccountConnectInput( + provider_account_id="acct-001", + email_address="owner@example.com", + display_name="Owner", + scope=GMAIL_READONLY_SCOPE, + access_token="token-1", + refresh_token="refresh-1", + client_id="client-1", + client_secret="secret-1", + access_token_expires_at=expired_at, + ), + )["account"] + account_id = UUID(account["id"]) + + monkeypatch.setattr( + "alicebot_api.gmail.refresh_gmail_access_token", + lambda **_kwargs: RefreshedGmailCredential( + access_token="token-2", + access_token_expires_at=refreshed_at, + ), + ) + + secret_ref = store.gmail_account_credentials[account_id]["secret_ref"] + assert resolve_gmail_access_token(store, secret_manager, gmail_account_id=account_id) == "token-2" + assert secret_ref is not None + assert secret_manager.secrets[secret_ref] == { + "credential_kind": GMAIL_REFRESHABLE_PROTECTED_CREDENTIAL_KIND, + "access_token": "token-2", + "refresh_token": "refresh-1", + "client_id": "client-1", + "client_secret": "secret-1", + "access_token_expires_at": refreshed_at.isoformat(), + } + assert store.operations[-2:] == [ + ("get_gmail_account_credential_optional", account_id), + ("update_gmail_account_credential", account_id), + ] + + +def test_resolve_gmail_access_token_persists_rotated_refresh_token(monkeypatch) -> None: + store = GmailStoreStub() + secret_manager = GmailSecretManagerStub() + expired_at = datetime(2020, 1, 1, 0, 0, tzinfo=UTC) + refreshed_at = datetime(2030, 1, 1, 0, 5, tzinfo=UTC) + account = create_gmail_account_record( + store, + secret_manager, + user_id=uuid4(), + request=GmailAccountConnectInput( + provider_account_id="acct-001", + email_address="owner@example.com", + display_name="Owner", + scope=GMAIL_READONLY_SCOPE, + access_token="token-1", + refresh_token="refresh-1", + client_id="client-1", + client_secret="secret-1", + access_token_expires_at=expired_at, + ), + )["account"] + account_id = UUID(account["id"]) + + monkeypatch.setattr( + "alicebot_api.gmail.refresh_gmail_access_token", + lambda **_kwargs: RefreshedGmailCredential( + access_token="token-2", + access_token_expires_at=refreshed_at, + refresh_token="refresh-2", + ), + ) + + secret_ref = store.gmail_account_credentials[account_id]["secret_ref"] + assert resolve_gmail_access_token(store, secret_manager, gmail_account_id=account_id) == "token-2" + assert secret_ref is not None + assert secret_manager.secrets[secret_ref] == { + "credential_kind": GMAIL_REFRESHABLE_PROTECTED_CREDENTIAL_KIND, + "access_token": "token-2", + "refresh_token": "refresh-2", + "client_id": "client-1", + "client_secret": "secret-1", + "access_token_expires_at": refreshed_at.isoformat(), + } + + +def test_resolve_gmail_access_token_fails_deterministically_when_persisting_refresh_update_fails( + monkeypatch, +) -> None: + store = GmailStoreStub() + secret_manager = GmailSecretManagerStub() + expired_at = datetime(2020, 1, 1, 0, 0, tzinfo=UTC) + refreshed_at = datetime(2030, 1, 1, 0, 5, tzinfo=UTC) + account = create_gmail_account_record( + store, + secret_manager, + user_id=uuid4(), + request=GmailAccountConnectInput( + provider_account_id="acct-001", + email_address="owner@example.com", + display_name="Owner", + scope=GMAIL_READONLY_SCOPE, + access_token="token-1", + refresh_token="refresh-1", + client_id="client-1", + client_secret="secret-1", + access_token_expires_at=expired_at, + ), + )["account"] + account_id = UUID(account["id"]) + secret_ref = store.gmail_account_credentials[account_id]["secret_ref"] + assert secret_ref is not None + original_blob = dict(secret_manager.secrets[secret_ref]) + + monkeypatch.setattr( + "alicebot_api.gmail.refresh_gmail_access_token", + lambda **_kwargs: RefreshedGmailCredential( + access_token="token-2", + access_token_expires_at=refreshed_at, + refresh_token="refresh-2", + ), + ) + + def fail_update(**_kwargs): + raise ContinuityStoreInvariantError("update_gmail_account_credential did not return a row") + + monkeypatch.setattr(store, "update_gmail_account_credential", fail_update) + + with pytest.raises( + GmailCredentialPersistenceError, + match=f"gmail account {account_id} renewed protected credentials could not be persisted", + ): + resolve_gmail_access_token(store, secret_manager, gmail_account_id=account_id) + + assert secret_manager.secrets[secret_ref] == original_blob + + +def test_resolve_gmail_access_token_rejects_invalid_refreshable_protected_credentials() -> None: + store = GmailStoreStub() + secret_manager = GmailSecretManagerStub() + account = create_gmail_account_record( + store, + secret_manager, + user_id=uuid4(), + request=GmailAccountConnectInput( + provider_account_id="acct-001", + email_address="owner@example.com", + display_name="Owner", + scope=GMAIL_READONLY_SCOPE, + access_token="token-1", + ), + )["account"] + account_id = UUID(account["id"]) + secret_ref = store.gmail_account_credentials[account_id]["secret_ref"] + assert secret_ref is not None + secret_manager.secrets[secret_ref] = { + "credential_kind": GMAIL_REFRESHABLE_PROTECTED_CREDENTIAL_KIND, + "access_token": "token-1", + "client_id": "client-1", + "client_secret": "secret-1", + "access_token_expires_at": "2020-01-01T00:00:00+00:00", + } + + with pytest.raises( + GmailCredentialInvalidError, + match=f"gmail account {account_id} has invalid protected credentials", + ): + resolve_gmail_access_token(store, secret_manager, gmail_account_id=account_id) + + +def test_ingest_gmail_message_record_writes_rfc822_artifact_and_reuses_artifact_seam( + monkeypatch, + tmp_path, +) -> None: + store = GmailStoreStub() + secret_manager = GmailSecretManagerStub() + user_id = uuid4() + workspace_id = uuid4() + workspace = store.create_task_workspace( + task_workspace_id=workspace_id, + local_path=str((tmp_path / "workspace").resolve()), + ) + account = create_gmail_account_record( + store, + secret_manager, + user_id=user_id, + request=GmailAccountConnectInput( + provider_account_id="acct-001", + email_address="owner@example.com", + display_name="Owner", + scope=GMAIL_READONLY_SCOPE, + access_token="token-1", + ), + )["account"] + raw_bytes = _build_rfc822_email_bytes(plain_body="hello from gmail") + calls: dict[str, object] = {} + + monkeypatch.setattr( + "alicebot_api.gmail.fetch_gmail_message_raw_bytes", + lambda **_kwargs: raw_bytes, + ) + + def fake_register(_store, *, user_id: UUID, request): + calls["register_user_id"] = user_id + calls["register_request"] = request + path = Path(request.local_path) + assert path.read_bytes() == raw_bytes + assert path.is_file() + return { + "artifact": { + "id": "00000000-0000-0000-0000-000000000123", + "task_id": str(workspace["task_id"]), + "task_workspace_id": str(workspace_id), + "status": "registered", + "ingestion_status": "pending", + "relative_path": path.relative_to(Path(workspace["local_path"])).as_posix(), + "media_type_hint": "message/rfc822", + "created_at": "2026-03-16T10:00:00+00:00", + "updated_at": "2026-03-16T10:00:00+00:00", + } + } + + def fake_ingest(_store, *, user_id: UUID, request): + calls["ingest_user_id"] = user_id + calls["ingest_request"] = request + return { + "artifact": { + "id": "00000000-0000-0000-0000-000000000123", + "task_id": str(workspace["task_id"]), + "task_workspace_id": str(workspace_id), + "status": "registered", + "ingestion_status": "ingested", + "relative_path": build_gmail_message_artifact_relative_path( + provider_account_id="acct-001", + provider_message_id="msg-001", + ), + "media_type_hint": "message/rfc822", + "created_at": "2026-03-16T10:00:00+00:00", + "updated_at": "2026-03-16T10:00:01+00:00", + }, + "summary": { + "total_count": 1, + "total_characters": 16, + "media_type": "message/rfc822", + "chunking_rule": "normalized_utf8_text_fixed_window_1000_chars_v1", + "order": ["sequence_no_asc", "id_asc"], + }, + } + + monkeypatch.setattr("alicebot_api.gmail.register_task_artifact_record", fake_register) + monkeypatch.setattr("alicebot_api.gmail.ingest_task_artifact_record", fake_ingest) + + response = ingest_gmail_message_record( + store, + secret_manager, + user_id=user_id, + request=GmailMessageIngestInput( + gmail_account_id=UUID(account["id"]), + task_workspace_id=workspace_id, + provider_message_id="msg-001", + ), + ) + + assert response == { + "account": account, + "message": { + "provider_message_id": "msg-001", + "artifact_relative_path": "gmail/acct-001/msg-001.eml", + "media_type": "message/rfc822", + }, + "artifact": { + "id": "00000000-0000-0000-0000-000000000123", + "task_id": str(workspace["task_id"]), + "task_workspace_id": str(workspace_id), + "status": "registered", + "ingestion_status": "ingested", + "relative_path": "gmail/acct-001/msg-001.eml", + "media_type_hint": "message/rfc822", + "created_at": "2026-03-16T10:00:00+00:00", + "updated_at": "2026-03-16T10:00:01+00:00", + }, + "summary": { + "total_count": 1, + "total_characters": 16, + "media_type": "message/rfc822", + "chunking_rule": "normalized_utf8_text_fixed_window_1000_chars_v1", + "order": ["sequence_no_asc", "id_asc"], + }, + } + assert calls["register_user_id"] == user_id + assert calls["ingest_user_id"] == user_id + assert store.operations[:2] == [ + ("create_gmail_account_credential", UUID(account["id"])), + ("get_gmail_account_credential_optional", UUID(account["id"])), + ] + + +def test_ingest_gmail_message_record_renews_expired_access_token_before_fetch( + monkeypatch, + tmp_path, +) -> None: + store = GmailStoreStub() + secret_manager = GmailSecretManagerStub() + user_id = uuid4() + workspace_id = uuid4() + workspace = store.create_task_workspace( + task_workspace_id=workspace_id, + local_path=str((tmp_path / "workspace").resolve()), + ) + account = create_gmail_account_record( + store, + secret_manager, + user_id=user_id, + request=GmailAccountConnectInput( + provider_account_id="acct-001", + email_address="owner@example.com", + display_name="Owner", + scope=GMAIL_READONLY_SCOPE, + access_token="token-expired", + refresh_token="refresh-1", + client_id="client-1", + client_secret="secret-1", + access_token_expires_at=datetime(2020, 1, 1, 0, 0, tzinfo=UTC), + ), + )["account"] + raw_bytes = _build_rfc822_email_bytes(plain_body="hello from gmail") + calls: dict[str, object] = {} + + monkeypatch.setattr( + "alicebot_api.gmail.refresh_gmail_access_token", + lambda **_kwargs: RefreshedGmailCredential( + access_token="token-refreshed", + access_token_expires_at=datetime(2030, 1, 1, 0, 5, tzinfo=UTC), + ), + ) + + def fake_fetch(**kwargs): + calls["fetch_access_token"] = kwargs["access_token"] + return raw_bytes + + monkeypatch.setattr("alicebot_api.gmail.fetch_gmail_message_raw_bytes", fake_fetch) + + monkeypatch.setattr( + "alicebot_api.gmail.register_task_artifact_record", + lambda _store, *, user_id, request: { + "artifact": { + "id": "00000000-0000-0000-0000-000000000123", + "task_id": str(workspace["task_id"]), + "task_workspace_id": str(workspace_id), + "status": "registered", + "ingestion_status": "pending", + "relative_path": Path(request.local_path) + .relative_to(Path(workspace["local_path"])) + .as_posix(), + "media_type_hint": "message/rfc822", + "created_at": "2026-03-16T10:00:00+00:00", + "updated_at": "2026-03-16T10:00:00+00:00", + } + }, + ) + monkeypatch.setattr( + "alicebot_api.gmail.ingest_task_artifact_record", + lambda _store, *, user_id, request: { + "artifact": { + "id": "00000000-0000-0000-0000-000000000123", + "task_id": str(workspace["task_id"]), + "task_workspace_id": str(workspace_id), + "status": "registered", + "ingestion_status": "ingested", + "relative_path": build_gmail_message_artifact_relative_path( + provider_account_id="acct-001", + provider_message_id="msg-001", + ), + "media_type_hint": "message/rfc822", + "created_at": "2026-03-16T10:00:00+00:00", + "updated_at": "2026-03-16T10:00:01+00:00", + }, + "summary": { + "total_count": 1, + "total_characters": 16, + "media_type": "message/rfc822", + "chunking_rule": "normalized_utf8_text_fixed_window_1000_chars_v1", + "order": ["sequence_no_asc", "id_asc"], + }, + }, + ) + + response = ingest_gmail_message_record( + store, + secret_manager, + user_id=user_id, + request=GmailMessageIngestInput( + gmail_account_id=UUID(account["id"]), + task_workspace_id=workspace_id, + provider_message_id="msg-001", + ), + ) + + assert response["message"]["artifact_relative_path"] == "gmail/acct-001/msg-001.eml" + assert calls["fetch_access_token"] == "token-refreshed" + assert store.operations[:3] == [ + ("create_gmail_account_credential", UUID(account["id"])), + ("get_gmail_account_credential_optional", UUID(account["id"])), + ("update_gmail_account_credential", UUID(account["id"])), + ] + + +def test_ingest_gmail_message_record_rejects_unsupported_message(monkeypatch, tmp_path) -> None: + store = GmailStoreStub() + secret_manager = GmailSecretManagerStub() + user_id = uuid4() + workspace_id = uuid4() + store.create_task_workspace( + task_workspace_id=workspace_id, + local_path=str((tmp_path / "workspace").resolve()), + ) + account = create_gmail_account_record( + store, + secret_manager, + user_id=user_id, + request=GmailAccountConnectInput( + provider_account_id="acct-001", + email_address="owner@example.com", + display_name="Owner", + scope=GMAIL_READONLY_SCOPE, + access_token="token-1", + ), + )["account"] + + monkeypatch.setattr( + "alicebot_api.gmail.fetch_gmail_message_raw_bytes", + lambda **_kwargs: b"not-a-valid-rfc822-email", + ) + + with pytest.raises( + GmailMessageUnsupportedError, + match="gmail message msg-unsupported is not a supported RFC822 email", + ): + ingest_gmail_message_record( + store, + secret_manager, + user_id=user_id, + request=GmailMessageIngestInput( + gmail_account_id=UUID(account["id"]), + task_workspace_id=workspace_id, + provider_message_id="msg-unsupported", + ), + ) + + +def test_ingest_gmail_message_record_rejects_duplicate_sanitized_path_before_fetch_or_write( + monkeypatch, + tmp_path, +) -> None: + store = GmailStoreStub() + secret_manager = GmailSecretManagerStub() + user_id = uuid4() + workspace_id = uuid4() + workspace_path = (tmp_path / "workspace").resolve() + store.create_task_workspace( + task_workspace_id=workspace_id, + local_path=str(workspace_path), + ) + account = create_gmail_account_record( + store, + secret_manager, + user_id=user_id, + request=GmailAccountConnectInput( + provider_account_id="acct-001", + email_address="owner@example.com", + display_name="Owner", + scope=GMAIL_READONLY_SCOPE, + access_token="token-1", + ), + )["account"] + relative_path = build_gmail_message_artifact_relative_path( + provider_account_id="acct-001", + provider_message_id="msg/001", + ) + existing_file = workspace_path / relative_path + existing_file.parent.mkdir(parents=True, exist_ok=True) + existing_file.write_bytes(b"original") + store.create_task_artifact( + task_workspace_id=workspace_id, + relative_path=relative_path, + ) + + def fail_fetch(**_kwargs): + raise AssertionError("fetch_gmail_message_raw_bytes should not be called") + + monkeypatch.setattr("alicebot_api.gmail.fetch_gmail_message_raw_bytes", fail_fetch) + + with pytest.raises( + TaskArtifactAlreadyExistsError, + match=f"artifact {relative_path} is already registered for task workspace {workspace_id}", + ): + ingest_gmail_message_record( + store, + secret_manager, + user_id=user_id, + request=GmailMessageIngestInput( + gmail_account_id=UUID(account["id"]), + task_workspace_id=workspace_id, + provider_message_id="msg:001", + ), + ) + + assert existing_file.read_bytes() == b"original" + assert store.operations[-3:] == [ + ("get_gmail_account_credential_optional", UUID(account["id"])), + ("lock_task_artifacts", workspace_id), + ("get_task_artifact_by_workspace_relative_path_optional", workspace_id), + ] + + +def test_ingest_gmail_message_record_requires_visible_workspace(monkeypatch) -> None: + store = GmailStoreStub() + secret_manager = GmailSecretManagerStub() + user_id = uuid4() + account = create_gmail_account_record( + store, + secret_manager, + user_id=user_id, + request=GmailAccountConnectInput( + provider_account_id="acct-001", + email_address="owner@example.com", + display_name="Owner", + scope=GMAIL_READONLY_SCOPE, + access_token="token-1", + ), + )["account"] + + monkeypatch.setattr( + "alicebot_api.gmail.fetch_gmail_message_raw_bytes", + lambda **_kwargs: _build_rfc822_email_bytes(plain_body="hello"), + ) + + with pytest.raises(TaskWorkspaceNotFoundError, match="task workspace .* was not found"): + ingest_gmail_message_record( + store, + secret_manager, + user_id=user_id, + request=GmailMessageIngestInput( + gmail_account_id=UUID(account["id"]), + task_workspace_id=uuid4(), + provider_message_id="msg-001", + ), + ) + + +def test_ingest_gmail_message_record_rejects_missing_protected_credentials_before_artifact_work( + monkeypatch, + tmp_path, +) -> None: + store = GmailStoreStub() + secret_manager = GmailSecretManagerStub() + user_id = uuid4() + workspace_id = uuid4() + workspace_path = (tmp_path / "workspace").resolve() + store.create_task_workspace( + task_workspace_id=workspace_id, + local_path=str(workspace_path), + ) + account = create_gmail_account_record( + store, + secret_manager, + user_id=user_id, + request=GmailAccountConnectInput( + provider_account_id="acct-001", + email_address="owner@example.com", + display_name="Owner", + scope=GMAIL_READONLY_SCOPE, + access_token="token-1", + ), + )["account"] + account_id = UUID(account["id"]) + store.gmail_account_credentials.pop(account_id) + + def fail_fetch(**_kwargs): + raise AssertionError("fetch_gmail_message_raw_bytes should not be called") + + monkeypatch.setattr("alicebot_api.gmail.fetch_gmail_message_raw_bytes", fail_fetch) + + with pytest.raises( + GmailCredentialNotFoundError, + match=f"gmail account {account_id} is missing protected credentials", + ): + ingest_gmail_message_record( + store, + secret_manager, + user_id=user_id, + request=GmailMessageIngestInput( + gmail_account_id=account_id, + task_workspace_id=workspace_id, + provider_message_id="msg-001", + ), + ) + + assert store.task_artifacts == [] + assert not workspace_path.exists() + assert ("lock_task_artifacts", workspace_id) not in store.operations + + +def test_ingest_gmail_message_record_rejects_invalid_protected_credentials_before_artifact_work( + monkeypatch, + tmp_path, +) -> None: + store = GmailStoreStub() + secret_manager = GmailSecretManagerStub() + user_id = uuid4() + workspace_id = uuid4() + workspace_path = (tmp_path / "workspace").resolve() + store.create_task_workspace( + task_workspace_id=workspace_id, + local_path=str(workspace_path), + ) + account = create_gmail_account_record( + store, + secret_manager, + user_id=user_id, + request=GmailAccountConnectInput( + provider_account_id="acct-001", + email_address="owner@example.com", + display_name="Owner", + scope=GMAIL_READONLY_SCOPE, + access_token="token-1", + ), + )["account"] + account_id = UUID(account["id"]) + secret_ref = store.gmail_account_credentials[account_id]["secret_ref"] + assert secret_ref is not None + secret_manager.secrets[secret_ref] = { + "credential_kind": GMAIL_PROTECTED_CREDENTIAL_KIND, + "access_token": "", + } + + def fail_fetch(**_kwargs): + raise AssertionError("fetch_gmail_message_raw_bytes should not be called") + + monkeypatch.setattr("alicebot_api.gmail.fetch_gmail_message_raw_bytes", fail_fetch) + + with pytest.raises( + GmailCredentialInvalidError, + match=f"gmail account {account_id} has invalid protected credentials", + ): + ingest_gmail_message_record( + store, + secret_manager, + user_id=user_id, + request=GmailMessageIngestInput( + gmail_account_id=account_id, + task_workspace_id=workspace_id, + provider_message_id="msg-001", + ), + ) + + assert store.task_artifacts == [] + assert not workspace_path.exists() + assert ("lock_task_artifacts", workspace_id) not in store.operations diff --git a/tests/unit/test_gmail_main.py b/tests/unit/test_gmail_main.py new file mode 100644 index 0000000..1ee6229 --- /dev/null +++ b/tests/unit/test_gmail_main.py @@ -0,0 +1,361 @@ +from __future__ import annotations + +import json +from contextlib import contextmanager +from uuid import uuid4 + +import pytest +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.gmail import ( + GmailAccountAlreadyExistsError, + GmailAccountNotFoundError, + GmailCredentialInvalidError, + GmailCredentialNotFoundError, + GmailCredentialPersistenceError, + GmailCredentialRefreshError, + GmailCredentialValidationError, + GmailMessageFetchError, + GmailMessageNotFoundError, + GmailMessageUnsupportedError, +) +from alicebot_api.workspaces import TaskWorkspaceNotFoundError + + +def _settings() -> Settings: + return Settings( + database_url="postgresql://app", + gmail_secret_manager_url="file:///tmp/test-gmail-secrets", + ) + + +def test_list_gmail_accounts_endpoint_returns_payload(monkeypatch) -> None: + user_id = uuid4() + settings = _settings() + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr( + main_module, + "list_gmail_account_records", + lambda *_args, **_kwargs: { + "items": [], + "summary": {"total_count": 0, "order": ["created_at_asc", "id_asc"]}, + }, + ) + + response = main_module.list_gmail_accounts(user_id) + + assert response.status_code == 200 + assert json.loads(response.body) == { + "items": [], + "summary": {"total_count": 0, "order": ["created_at_asc", "id_asc"]}, + } + + +def test_connect_gmail_account_endpoint_maps_duplicate_to_409(monkeypatch) -> None: + user_id = uuid4() + settings = _settings() + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + def fake_create_gmail_account_record(*_args, **_kwargs): + raise GmailAccountAlreadyExistsError("gmail account acct-001 is already connected") + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "create_gmail_account_record", fake_create_gmail_account_record) + + response = main_module.connect_gmail_account( + main_module.ConnectGmailAccountRequest( + user_id=user_id, + provider_account_id="acct-001", + email_address="owner@example.com", + display_name="Owner", + access_token="token-1", + ) + ) + + assert response.status_code == 409 + assert json.loads(response.body) == { + "detail": "gmail account acct-001 is already connected" + } + + +def test_connect_gmail_account_request_requires_complete_refresh_bundle() -> None: + with pytest.raises(ValueError, match="gmail refresh credentials must include refresh_token"): + main_module.ConnectGmailAccountRequest( + user_id=uuid4(), + provider_account_id="acct-001", + email_address="owner@example.com", + display_name="Owner", + access_token="token-1", + refresh_token="refresh-1", + ) + + +def test_connect_gmail_account_endpoint_maps_invalid_refresh_bundle_to_400(monkeypatch) -> None: + user_id = uuid4() + settings = _settings() + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + def fake_create_gmail_account_record(*_args, **_kwargs): + raise GmailCredentialValidationError( + "gmail refresh credentials must include refresh_token, client_id, client_secret, " + "and access_token_expires_at" + ) + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "create_gmail_account_record", fake_create_gmail_account_record) + + response = main_module.connect_gmail_account( + main_module.ConnectGmailAccountRequest( + user_id=user_id, + provider_account_id="acct-001", + email_address="owner@example.com", + display_name="Owner", + access_token="token-1", + ) + ) + + assert response.status_code == 400 + assert json.loads(response.body) == { + "detail": ( + "gmail refresh credentials must include refresh_token, client_id, client_secret, " + "and access_token_expires_at" + ) + } + + +def test_connect_gmail_account_endpoint_maps_secret_persistence_failure_to_409(monkeypatch) -> None: + user_id = uuid4() + settings = _settings() + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + def fake_create_gmail_account_record(*_args, **_kwargs): + raise GmailCredentialPersistenceError("gmail protected credentials could not be persisted") + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "create_gmail_account_record", fake_create_gmail_account_record) + + response = main_module.connect_gmail_account( + main_module.ConnectGmailAccountRequest( + user_id=user_id, + provider_account_id="acct-001", + email_address="owner@example.com", + display_name="Owner", + access_token="token-1", + ) + ) + + assert response.status_code == 409 + assert json.loads(response.body) == { + "detail": "gmail protected credentials could not be persisted" + } + + +def test_get_gmail_account_endpoint_maps_not_found_to_404(monkeypatch) -> None: + user_id = uuid4() + gmail_account_id = uuid4() + settings = _settings() + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + def fake_get_gmail_account_record(*_args, **_kwargs): + raise GmailAccountNotFoundError(f"gmail account {gmail_account_id} was not found") + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "get_gmail_account_record", fake_get_gmail_account_record) + + response = main_module.get_gmail_account(gmail_account_id, user_id) + + assert response.status_code == 404 + assert json.loads(response.body) == {"detail": f"gmail account {gmail_account_id} was not found"} + + +def test_ingest_gmail_message_endpoint_maps_workspace_not_found_to_404(monkeypatch) -> None: + user_id = uuid4() + gmail_account_id = uuid4() + task_workspace_id = uuid4() + settings = _settings() + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + def fake_ingest_gmail_message_record(*_args, **_kwargs): + raise TaskWorkspaceNotFoundError(f"task workspace {task_workspace_id} was not found") + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "ingest_gmail_message_record", fake_ingest_gmail_message_record) + + response = main_module.ingest_gmail_message( + gmail_account_id, + "msg-001", + main_module.IngestGmailMessageRequest( + user_id=user_id, + task_workspace_id=task_workspace_id, + ), + ) + + assert response.status_code == 404 + assert json.loads(response.body) == { + "detail": f"task workspace {task_workspace_id} was not found" + } + + +def test_ingest_gmail_message_endpoint_maps_upstream_errors(monkeypatch) -> None: + user_id = uuid4() + gmail_account_id = uuid4() + task_workspace_id = uuid4() + settings = _settings() + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + + def fake_missing(*_args, **_kwargs): + raise GmailMessageNotFoundError("gmail message msg-001 was not found") + + monkeypatch.setattr(main_module, "ingest_gmail_message_record", fake_missing) + response = main_module.ingest_gmail_message( + gmail_account_id, + "msg-001", + main_module.IngestGmailMessageRequest( + user_id=user_id, + task_workspace_id=task_workspace_id, + ), + ) + assert response.status_code == 404 + assert json.loads(response.body) == {"detail": "gmail message msg-001 was not found"} + + def fake_unsupported(*_args, **_kwargs): + raise GmailMessageUnsupportedError("gmail message msg-001 is not a supported RFC822 email") + + monkeypatch.setattr(main_module, "ingest_gmail_message_record", fake_unsupported) + response = main_module.ingest_gmail_message( + gmail_account_id, + "msg-001", + main_module.IngestGmailMessageRequest( + user_id=user_id, + task_workspace_id=task_workspace_id, + ), + ) + assert response.status_code == 400 + assert json.loads(response.body) == { + "detail": "gmail message msg-001 is not a supported RFC822 email" + } + + def fake_missing_credentials(*_args, **_kwargs): + raise GmailCredentialNotFoundError( + f"gmail account {gmail_account_id} is missing protected credentials" + ) + + monkeypatch.setattr(main_module, "ingest_gmail_message_record", fake_missing_credentials) + response = main_module.ingest_gmail_message( + gmail_account_id, + "msg-001", + main_module.IngestGmailMessageRequest( + user_id=user_id, + task_workspace_id=task_workspace_id, + ), + ) + assert response.status_code == 409 + assert json.loads(response.body) == { + "detail": f"gmail account {gmail_account_id} is missing protected credentials" + } + + def fake_invalid_credentials(*_args, **_kwargs): + raise GmailCredentialInvalidError( + f"gmail account {gmail_account_id} has invalid protected credentials" + ) + + monkeypatch.setattr(main_module, "ingest_gmail_message_record", fake_invalid_credentials) + response = main_module.ingest_gmail_message( + gmail_account_id, + "msg-001", + main_module.IngestGmailMessageRequest( + user_id=user_id, + task_workspace_id=task_workspace_id, + ), + ) + assert response.status_code == 409 + assert json.loads(response.body) == { + "detail": f"gmail account {gmail_account_id} has invalid protected credentials" + } + + def fake_persistence_error(*_args, **_kwargs): + raise GmailCredentialPersistenceError( + f"gmail account {gmail_account_id} renewed protected credentials could not be persisted" + ) + + monkeypatch.setattr(main_module, "ingest_gmail_message_record", fake_persistence_error) + response = main_module.ingest_gmail_message( + gmail_account_id, + "msg-001", + main_module.IngestGmailMessageRequest( + user_id=user_id, + task_workspace_id=task_workspace_id, + ), + ) + assert response.status_code == 409 + assert json.loads(response.body) == { + "detail": f"gmail account {gmail_account_id} renewed protected credentials could not be persisted" + } + + def fake_fetch_error(*_args, **_kwargs): + raise GmailMessageFetchError("gmail message msg-001 could not be fetched") + + monkeypatch.setattr(main_module, "ingest_gmail_message_record", fake_fetch_error) + response = main_module.ingest_gmail_message( + gmail_account_id, + "msg-001", + main_module.IngestGmailMessageRequest( + user_id=user_id, + task_workspace_id=task_workspace_id, + ), + ) + assert response.status_code == 502 + assert json.loads(response.body) == { + "detail": "gmail message msg-001 could not be fetched" + } + + def fake_refresh_error(*_args, **_kwargs): + raise GmailCredentialRefreshError( + f"gmail account {gmail_account_id} access token could not be renewed" + ) + + monkeypatch.setattr(main_module, "ingest_gmail_message_record", fake_refresh_error) + response = main_module.ingest_gmail_message( + gmail_account_id, + "msg-001", + main_module.IngestGmailMessageRequest( + user_id=user_id, + task_workspace_id=task_workspace_id, + ), + ) + assert response.status_code == 502 + assert json.loads(response.body) == { + "detail": f"gmail account {gmail_account_id} access token could not be renewed" + } diff --git a/tests/unit/test_gmail_refresh.py b/tests/unit/test_gmail_refresh.py new file mode 100644 index 0000000..da7c9d7 --- /dev/null +++ b/tests/unit/test_gmail_refresh.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +import json +from datetime import UTC, datetime, timedelta +from io import BytesIO +from urllib.error import HTTPError, URLError +from urllib.parse import parse_qs +from uuid import uuid4 + +import pytest + +from alicebot_api.gmail import ( + GMAIL_TOKEN_REFRESH_TIMEOUT_SECONDS, + GMAIL_TOKEN_REFRESH_URL, + GmailCredentialInvalidError, + GmailCredentialRefreshError, + RefreshedGmailCredential, + refresh_gmail_access_token, +) + + +class _FakeHTTPResponse: + def __init__(self, payload: bytes) -> None: + self._payload = payload + + def __enter__(self) -> _FakeHTTPResponse: + return self + + def __exit__(self, exc_type, exc, tb) -> bool: + return False + + def read(self) -> bytes: + return self._payload + + +def _make_http_error(status_code: int) -> HTTPError: + return HTTPError( + GMAIL_TOKEN_REFRESH_URL, + status_code, + "upstream error", + hdrs=None, + fp=BytesIO(b'{"error":"invalid_grant"}'), + ) + + +def test_refresh_gmail_access_token_posts_expected_payload_and_returns_expiry(monkeypatch) -> None: + gmail_account_id = uuid4() + seen: dict[str, object] = {} + + def fake_urlopen(request, timeout: int): + seen["url"] = request.full_url + seen["timeout"] = timeout + seen["content_type"] = request.headers["Content-type"] + seen["accept"] = request.headers["Accept"] + seen["body"] = parse_qs(request.data.decode("utf-8")) + return _FakeHTTPResponse( + json.dumps({"access_token": "token-refreshed", "expires_in": 3600}).encode("utf-8") + ) + + monkeypatch.setattr("alicebot_api.gmail.urlopen", fake_urlopen) + + started_at = datetime.now(UTC) + refreshed_credential = refresh_gmail_access_token( + gmail_account_id=gmail_account_id, + refresh_token="refresh-001", + client_id="client-001", + client_secret="secret-001", + ) + finished_at = datetime.now(UTC) + + assert refreshed_credential == RefreshedGmailCredential( + access_token="token-refreshed", + access_token_expires_at=refreshed_credential.access_token_expires_at, + refresh_token=None, + ) + assert started_at + timedelta(seconds=3590) <= refreshed_credential.access_token_expires_at <= ( + finished_at + timedelta(seconds=3610) + ) + assert seen == { + "url": GMAIL_TOKEN_REFRESH_URL, + "timeout": GMAIL_TOKEN_REFRESH_TIMEOUT_SECONDS, + "content_type": "application/x-www-form-urlencoded", + "accept": "application/json", + "body": { + "client_id": ["client-001"], + "client_secret": ["secret-001"], + "refresh_token": ["refresh-001"], + "grant_type": ["refresh_token"], + }, + } + + +def test_refresh_gmail_access_token_returns_rotated_refresh_token_when_provider_supplies_one( + monkeypatch, +) -> None: + gmail_account_id = uuid4() + + def fake_urlopen(_request, timeout: int): + assert timeout == GMAIL_TOKEN_REFRESH_TIMEOUT_SECONDS + return _FakeHTTPResponse( + json.dumps( + { + "access_token": "token-refreshed", + "expires_in": 3600, + "refresh_token": "refresh-rotated", + } + ).encode("utf-8") + ) + + monkeypatch.setattr("alicebot_api.gmail.urlopen", fake_urlopen) + + refreshed_credential = refresh_gmail_access_token( + gmail_account_id=gmail_account_id, + refresh_token="refresh-001", + client_id="client-001", + client_secret="secret-001", + ) + + assert refreshed_credential.refresh_token == "refresh-rotated" + + +@pytest.mark.parametrize("status_code", [400, 401]) +def test_refresh_gmail_access_token_maps_invalid_refresh_rejections_to_invalid_error( + monkeypatch, + status_code: int, +) -> None: + gmail_account_id = uuid4() + + def fake_urlopen(_request, timeout: int): + assert timeout == GMAIL_TOKEN_REFRESH_TIMEOUT_SECONDS + raise _make_http_error(status_code) + + monkeypatch.setattr("alicebot_api.gmail.urlopen", fake_urlopen) + + with pytest.raises( + GmailCredentialInvalidError, + match=f"gmail account {gmail_account_id} refresh credentials were rejected", + ): + refresh_gmail_access_token( + gmail_account_id=gmail_account_id, + refresh_token="refresh-001", + client_id="client-001", + client_secret="secret-001", + ) + + +def test_refresh_gmail_access_token_maps_non_deterministic_http_failure_to_refresh_error( + monkeypatch, +) -> None: + gmail_account_id = uuid4() + + def fake_urlopen(_request, timeout: int): + assert timeout == GMAIL_TOKEN_REFRESH_TIMEOUT_SECONDS + raise _make_http_error(500) + + monkeypatch.setattr("alicebot_api.gmail.urlopen", fake_urlopen) + + with pytest.raises( + GmailCredentialRefreshError, + match=f"gmail account {gmail_account_id} access token could not be renewed", + ): + refresh_gmail_access_token( + gmail_account_id=gmail_account_id, + refresh_token="refresh-001", + client_id="client-001", + client_secret="secret-001", + ) + + +@pytest.mark.parametrize( + ("response_payload", "error"), + [ + (b"not-json", None), + (json.dumps({"expires_in": 3600}).encode("utf-8"), None), + (None, URLError("network down")), + ], +) +def test_refresh_gmail_access_token_maps_malformed_or_transport_failures_to_refresh_error( + monkeypatch, + response_payload: bytes | None, + error: Exception | None, +) -> None: + gmail_account_id = uuid4() + + def fake_urlopen(_request, timeout: int): + assert timeout == GMAIL_TOKEN_REFRESH_TIMEOUT_SECONDS + if error is not None: + raise error + assert response_payload is not None + return _FakeHTTPResponse(response_payload) + + monkeypatch.setattr("alicebot_api.gmail.urlopen", fake_urlopen) + + with pytest.raises( + GmailCredentialRefreshError, + match=f"gmail account {gmail_account_id} access token could not be renewed", + ): + refresh_gmail_access_token( + gmail_account_id=gmail_account_id, + refresh_token="refresh-001", + client_id="client-001", + client_secret="secret-001", + ) diff --git a/tests/unit/test_gmail_secret_manager.py b/tests/unit/test_gmail_secret_manager.py new file mode 100644 index 0000000..1db5cc9 --- /dev/null +++ b/tests/unit/test_gmail_secret_manager.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import stat + +import pytest + +from alicebot_api.gmail_secret_manager import ( + GmailSecretManagerError, + build_gmail_secret_manager, +) + + +def test_build_gmail_secret_manager_rejects_non_file_schemes() -> None: + with pytest.raises(ValueError, match="GMAIL_SECRET_MANAGER_URL must use the file:// scheme"): + build_gmail_secret_manager("memory://gmail-secrets") + + +def test_build_gmail_secret_manager_requires_explicit_configuration() -> None: + with pytest.raises(ValueError, match="GMAIL_SECRET_MANAGER_URL must be configured"): + build_gmail_secret_manager("") + + +def test_file_gmail_secret_manager_round_trips_secret_payload(tmp_path) -> None: + manager = build_gmail_secret_manager(tmp_path.resolve().as_uri()) + secret_ref = "users/00000000-0000-0000-0000-000000000001/gmail-account-credentials/cred.json" + payload = { + "credential_kind": "gmail_oauth_access_token_v1", + "access_token": "token-001", + } + + manager.write_secret(secret_ref=secret_ref, payload=payload) + + assert manager.load_secret(secret_ref=secret_ref) == payload + secret_path = tmp_path / secret_ref + assert stat.S_IMODE(secret_path.stat().st_mode) == 0o600 + assert stat.S_IMODE(secret_path.parent.stat().st_mode) == 0o700 + + +def test_file_gmail_secret_manager_rejects_missing_or_escaped_refs(tmp_path) -> None: + manager = build_gmail_secret_manager(tmp_path.resolve().as_uri()) + + with pytest.raises(GmailSecretManagerError, match="was not found"): + manager.load_secret(secret_ref="users/u/gmail-account-credentials/missing.json") + + with pytest.raises(GmailSecretManagerError, match="outside the configured root"): + manager.write_secret( + secret_ref="../../escape.json", + payload={"credential_kind": "gmail_oauth_access_token_v1", "access_token": "token"}, + ) diff --git a/tests/unit/test_importers.py b/tests/unit/test_importers.py new file mode 100644 index 0000000..2d91f39 --- /dev/null +++ b/tests/unit/test_importers.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from alicebot_api.chatgpt_import import ChatGPTImportValidationError, load_chatgpt_payload +from alicebot_api.markdown_import import MarkdownImportValidationError, load_markdown_payload + + +REPO_ROOT = Path(__file__).resolve().parents[2] +MARKDOWN_FIXTURE = REPO_ROOT / "fixtures" / "importers" / "markdown" / "workspace_v1.md" +CHATGPT_FIXTURE = REPO_ROOT / "fixtures" / "importers" / "chatgpt" / "workspace_v1.json" + + +def test_markdown_adapter_loads_fixture_with_deterministic_mapping() -> None: + first = load_markdown_payload(MARKDOWN_FIXTURE) + second = load_markdown_payload(MARKDOWN_FIXTURE) + + assert first.context.fixture_id == "markdown-s37-workspace-v1" + assert first.context.workspace_id == "markdown-workspace-demo-001" + assert first.context.workspace_name == "Markdown Import Demo" + assert len(first.items) == 5 + + first_item = first.items[0] + assert first_item.object_type == "Decision" + assert first_item.status == "active" + assert first_item.body["decision_text"] == "Keep markdown importer deterministic for baseline evidence." + assert first_item.source_provenance["project"] == "Markdown Import Project" + + assert [item.dedupe_key for item in first.items] == [item.dedupe_key for item in second.items] + + +def test_markdown_adapter_rejects_unclosed_frontmatter(tmp_path: Path) -> None: + source = tmp_path / "broken.md" + source.write_text("---\nfixture_id: x\n- Decision: broken\n", encoding="utf-8") + + with pytest.raises(MarkdownImportValidationError, match="frontmatter"): + load_markdown_payload(source) + + +def test_chatgpt_adapter_loads_fixture_with_deterministic_mapping() -> None: + first = load_chatgpt_payload(CHATGPT_FIXTURE) + second = load_chatgpt_payload(CHATGPT_FIXTURE) + + assert first.context.fixture_id == "chatgpt-s37-workspace-v1" + assert first.context.workspace_id == "chatgpt-workspace-demo-001" + assert first.context.workspace_name == "ChatGPT Import Demo" + assert len(first.items) == 5 + + first_item = first.items[0] + assert first_item.object_type == "Decision" + assert first_item.status == "active" + assert first_item.body["decision_text"] == "Keep ChatGPT import provenance explicit for every message." + assert first_item.source_provenance["project"] == "ChatGPT Import Project" + + assert [item.dedupe_key for item in first.items] == [item.dedupe_key for item in second.items] + + +def test_chatgpt_adapter_rejects_invalid_payload(tmp_path: Path) -> None: + source = tmp_path / "invalid.json" + source.write_text(json.dumps({"workspace": {"id": "x"}}), encoding="utf-8") + + with pytest.raises(ChatGPTImportValidationError, match="must include one of"): + load_chatgpt_payload(source) diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py new file mode 100644 index 0000000..6fe92fe --- /dev/null +++ b/tests/unit/test_main.py @@ -0,0 +1,3917 @@ +from __future__ import annotations + +import asyncio +import json +from contextlib import contextmanager +from datetime import datetime +from uuid import uuid4 + +import pytest +from fastapi import Request +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.artifacts import TaskArtifactNotFoundError +from alicebot_api.compiler import CompiledTraceRun +from alicebot_api.contracts import AdmissionDecisionOutput +from alicebot_api.embedding import ( + EmbeddingConfigValidationError, + MemoryEmbeddingNotFoundError, + MemoryEmbeddingValidationError, + TaskArtifactChunkEmbeddingNotFoundError, + TaskArtifactChunkEmbeddingValidationError, +) +from alicebot_api.entity import EntityNotFoundError, EntityValidationError +from alicebot_api.entity_edge import EntityEdgeValidationError +from alicebot_api.memory import ( + MemoryAdmissionValidationError, + MemoryReviewNotFoundError, + OpenLoopNotFoundError, + OpenLoopValidationError, +) +from alicebot_api.response_generation import ResponseFailure +from alicebot_api.semantic_retrieval import ( + SemanticArtifactChunkRetrievalValidationError, + SemanticMemoryRetrievalValidationError, +) +from alicebot_api.store import ContinuityStoreInvariantError + + +def test_healthcheck_reports_ok_when_database_is_reachable(monkeypatch) -> None: + settings = Settings( + app_env="test", + database_url="postgresql://db", + redis_url="redis://alicebot:supersecret@cache:6379/0", + s3_endpoint_url="http://object-store", + healthcheck_timeout_seconds=7, + ) + ping_calls: list[tuple[str, int]] = [] + + def fake_get_settings() -> Settings: + return settings + + def fake_ping_database(database_url: str, timeout_seconds: int) -> bool: + ping_calls.append((database_url, timeout_seconds)) + return True + + monkeypatch.setattr(main_module, "get_settings", fake_get_settings) + monkeypatch.setattr(main_module, "ping_database", fake_ping_database) + + response = main_module.healthcheck() + + assert response.status_code == 200 + assert json.loads(response.body) == { + "status": "ok", + "environment": "test", + "services": { + "database": {"status": "ok"}, + "redis": {"status": "not_checked", "url": "redis://cache:6379/0"}, + "object_storage": { + "status": "not_checked", + "endpoint_url": "http://object-store", + }, + }, + } + assert ping_calls == [("postgresql://db", 7)] + + +def test_healthcheck_reports_degraded_when_database_is_unreachable(monkeypatch) -> None: + settings = Settings( + app_env="test", + database_url="postgresql://db", + redis_url="redis://alicebot:supersecret@cache:6379/0", + s3_endpoint_url="http://object-store", + healthcheck_timeout_seconds=4, + ) + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "ping_database", lambda *_args, **_kwargs: False) + + response = main_module.healthcheck() + + assert response.status_code == 503 + assert json.loads(response.body) == { + "status": "degraded", + "environment": "test", + "services": { + "database": {"status": "unreachable"}, + "redis": {"status": "not_checked", "url": "redis://cache:6379/0"}, + "object_storage": { + "status": "not_checked", + "endpoint_url": "http://object-store", + }, + }, + } + + +def test_healthcheck_route_is_registered() -> None: + route_paths = {route.path for route in main_module.app.routes} + + assert "/healthz" in route_paths + assert "/v0/context/compile" in route_paths + assert "/v0/responses" in route_paths + assert "/v0/memories/admit" in route_paths + assert "/v0/open-loops" in route_paths + assert "/v0/open-loops/{open_loop_id}" in route_paths + assert "/v0/open-loops/{open_loop_id}/status" in route_paths + assert "/v0/consents" in route_paths + assert "/v0/policies" in route_paths + assert "/v0/policies/{policy_id}" in route_paths + assert "/v0/policies/evaluate" in route_paths + assert "/v0/memories/extract-explicit-preferences" in route_paths + assert "/v0/open-loops/extract-explicit-commitments" in route_paths + assert "/v0/memories/capture-explicit-signals" in route_paths + assert "/v0/memories" in route_paths + assert "/v0/memories/review-queue" in route_paths + assert "/v0/memories/quality-gate" in route_paths + assert "/v0/memories/evaluation-summary" in route_paths + assert "/v0/memories/semantic-retrieval" in route_paths + assert "/v0/memories/{memory_id}" in route_paths + assert "/v0/memories/{memory_id}/revisions" in route_paths + assert "/v0/memories/{memory_id}/labels" in route_paths + assert "/v0/embedding-configs" in route_paths + assert "/v0/memory-embeddings" in route_paths + assert "/v0/memories/{memory_id}/embeddings" in route_paths + assert "/v0/memory-embeddings/{memory_embedding_id}" in route_paths + assert "/v0/admin/debug/continuity/lifecycle" in route_paths + assert "/v0/admin/debug/continuity/lifecycle/{continuity_object_id}" in route_paths + assert "/v0/task-artifact-chunk-embeddings" in route_paths + assert "/v0/task-artifacts/{task_artifact_id}/chunk-embeddings" in route_paths + assert "/v0/task-artifact-chunks/{task_artifact_chunk_id}/embeddings" in route_paths + assert "/v0/task-artifact-chunk-embeddings/{task_artifact_chunk_embedding_id}" in route_paths + assert "/v0/entities" in route_paths + assert "/v0/entity-edges" in route_paths + assert "/v0/tools/route" in route_paths + assert "/v0/execution-budgets" in route_paths + assert "/v0/execution-budgets/{execution_budget_id}" in route_paths + assert "/v0/execution-budgets/{execution_budget_id}/deactivate" in route_paths + assert "/v0/execution-budgets/{execution_budget_id}/supersede" in route_paths + assert "/v0/tool-executions" in route_paths + assert "/v0/tool-executions/{execution_id}" in route_paths + assert "/v0/tasks" in route_paths + assert "/v0/tasks/{task_id}" in route_paths + assert "/v0/tasks/{task_id}/workspace" in route_paths + assert "/v0/tasks/{task_id}/steps" in route_paths + assert "/v0/threads/{thread_id}/resumption-brief" in route_paths + assert "/v0/task-workspaces" in route_paths + assert "/v0/task-workspaces/{task_workspace_id}" in route_paths + assert "/v0/task-workspaces/{task_workspace_id}/artifacts" in route_paths + assert "/v0/task-artifacts" in route_paths + assert "/v0/task-artifacts/{task_artifact_id}" in route_paths + assert "/v0/task-artifacts/{task_artifact_id}/ingest" in route_paths + assert "/v0/task-artifacts/{task_artifact_id}/chunks" in route_paths + assert "/v0/tasks/{task_id}/artifact-chunks/semantic-retrieval" in route_paths + assert "/v0/task-artifacts/{task_artifact_id}/chunks/semantic-retrieval" in route_paths + assert "/v0/task-steps/{task_step_id}" in route_paths + assert "/v0/task-steps/{task_step_id}/transition" in route_paths + assert "/v0/entities/{entity_id}" in route_paths + assert "/v0/entities/{entity_id}/edges" in route_paths + assert "/v1/channels/telegram/daily-brief" in route_paths + assert "/v1/channels/telegram/daily-brief/deliver" in route_paths + assert "/v1/channels/telegram/notification-preferences" in route_paths + assert "/v1/channels/telegram/open-loop-prompts" in route_paths + assert "/v1/channels/telegram/open-loop-prompts/{prompt_id}/deliver" in route_paths + assert "/v1/channels/telegram/scheduler/jobs" in route_paths + + +def test_redact_url_credentials_strips_embedded_secrets() -> None: + assert main_module.redact_url_credentials("redis://alicebot:supersecret@cache:6379/0") == ( + "redis://cache:6379/0" + ) + assert main_module.redact_url_credentials("redis://cache:6379/0") == "redis://cache:6379/0" + + +def test_build_healthcheck_payload_keeps_boundary_statuses_consistent() -> None: + settings = Settings( + app_env="test", + redis_url="redis://alicebot:supersecret@cache:6379/0", + s3_endpoint_url="http://object-store", + ) + + assert main_module.build_healthcheck_payload(settings, database_ok=True) == { + "status": "ok", + "environment": "test", + "services": { + "database": {"status": "ok"}, + "redis": {"status": "not_checked", "url": "redis://cache:6379/0"}, + "object_storage": { + "status": "not_checked", + "endpoint_url": "http://object-store", + }, + }, + } + assert main_module.build_healthcheck_payload(settings, database_ok=False)["services"][ + "database" + ] == {"status": "unreachable"} + + +def _build_request( + *, + method: str, + path: str, + query_string: str = "", + body: bytes = b"", + headers: dict[str, str] | None = None, +) -> Request: + encoded_headers = [ + (key.lower().encode("utf-8"), value.encode("utf-8")) + for key, value in (headers or {}).items() + ] + + async def receive() -> dict[str, object]: + return {"type": "http.request", "body": body, "more_body": False} + + return Request( + { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": path, + "raw_path": path.encode("utf-8"), + "query_string": query_string.encode("utf-8"), + "headers": encoded_headers, + "client": ("127.0.0.1", 12345), + "server": ("testserver", 80), + }, + receive, + ) + + +def test_resolve_authenticated_user_id_prefers_configured_identity() -> None: + configured_user_id = uuid4() + request = _build_request( + method="GET", + path="/v0/threads", + headers={"x-alicebot-user-id": str(uuid4())}, + ) + + resolved = main_module._resolve_authenticated_user_id( + Settings(app_env="test", auth_user_id=str(configured_user_id)), + request, + ) + + assert resolved == configured_user_id + + +def test_resolve_authenticated_user_id_allows_dev_without_header() -> None: + request = _build_request(method="GET", path="/v0/threads") + + resolved = main_module._resolve_authenticated_user_id( + Settings(app_env="test", auth_user_id=""), + request, + ) + + assert resolved is None + + +def test_rewrite_user_id_query_param_rejects_mismatch() -> None: + request = _build_request( + method="GET", + path="/v0/threads", + query_string="user_id=00000000-0000-0000-0000-000000000002", + ) + + with pytest.raises(ValueError, match="query user_id does not match authenticated user"): + main_module._rewrite_user_id_query_param( + request, + uuid4(), + ) + + +def test_rewrite_user_id_json_body_injects_missing_user_id() -> None: + authenticated_user_id = uuid4() + thread_id = uuid4() + request = _build_request( + method="POST", + path="/v0/responses", + body=json.dumps({"thread_id": str(thread_id), "message": "hello"}).encode("utf-8"), + headers={"content-type": "application/json"}, + ) + + rewritten_request = asyncio.run( + main_module._rewrite_user_id_json_body(request, authenticated_user_id) + ) + rewritten_body = asyncio.run(rewritten_request.body()) + + assert json.loads(rewritten_body) == { + "thread_id": str(thread_id), + "message": "hello", + "user_id": str(authenticated_user_id), + } + + +def test_rewrite_user_id_json_body_rejects_mismatch() -> None: + request = _build_request( + method="POST", + path="/v0/responses", + body=json.dumps( + { + "user_id": "00000000-0000-0000-0000-000000000001", + "thread_id": str(uuid4()), + "message": "hello", + } + ).encode("utf-8"), + headers={"content-type": "application/json"}, + ) + + with pytest.raises(ValueError, match="request user_id does not match authenticated user"): + asyncio.run(main_module._rewrite_user_id_json_body(request, uuid4())) + + +def test_request_client_identifier_ignores_forwarded_header_when_proxy_not_trusted() -> None: + request = _build_request( + method="POST", + path="/v1/auth/magic-link/start", + headers={"x-forwarded-for": "203.0.113.9, 127.0.0.1"}, + ) + + client_identifier = main_module._request_client_identifier( + request, + Settings(database_url="postgresql://app"), + ) + + assert client_identifier == "127.0.0.1" + + +def test_request_client_identifier_uses_forwarded_header_for_trusted_proxy() -> None: + request = _build_request( + method="POST", + path="/v1/auth/magic-link/start", + headers={"x-forwarded-for": "203.0.113.9, 127.0.0.1"}, + ) + + client_identifier = main_module._request_client_identifier( + request, + Settings( + database_url="postgresql://app", + trust_proxy_headers=True, + trusted_proxy_ips=("127.0.0.1",), + ), + ) + + assert client_identifier == "203.0.113.9" + + +def test_entrypoint_rate_limit_memory_backend_enforces_limits() -> None: + settings = Settings( + database_url="postgresql://app", + entrypoint_rate_limit_backend="memory", + ) + + main_module.entrypoint_rate_limiter.reset() + first_result = main_module._enforce_entrypoint_rate_limit( + settings=settings, + key="entrypoint-test-memory-backend", + max_requests=1, + window_seconds=60, + detail_code="entrypoint_test_limited", + message="entrypoint test limit exceeded", + ) + second_result = main_module._enforce_entrypoint_rate_limit( + settings=settings, + key="entrypoint-test-memory-backend", + max_requests=1, + window_seconds=60, + detail_code="entrypoint_test_limited", + message="entrypoint test limit exceeded", + ) + main_module.entrypoint_rate_limiter.reset() + + assert first_result is None + assert second_result is not None + assert second_result.status_code == 429 + assert json.loads(second_result.body)["detail"]["code"] == "entrypoint_test_limited" + + +def test_entrypoint_rate_limit_returns_503_when_redis_backend_is_unavailable(monkeypatch) -> None: + settings = Settings( + app_env="staging", + database_url="postgresql://app", + entrypoint_rate_limit_backend="redis", + ) + main_module.entrypoint_rate_limiter.reset() + monkeypatch.setattr(main_module, "redis", None) + + limited = main_module._enforce_entrypoint_rate_limit( + settings=settings, + key="entrypoint-test-redis-unavailable", + max_requests=1, + window_seconds=60, + detail_code="entrypoint_test_limited", + message="entrypoint test limit exceeded", + ) + + assert limited is not None + assert limited.status_code == 503 + assert json.loads(limited.body) == { + "detail": { + "code": "entrypoint_rate_limiter_unavailable", + "message": "entrypoint rate limiter backend is unavailable", + } + } + + +def test_compile_context_returns_trace_and_context_pack(monkeypatch) -> None: + user_id = uuid4() + thread_id = uuid4() + settings = Settings(database_url="postgresql://app") + captured: dict[str, object] = {} + + @contextmanager + def fake_user_connection(database_url: str, current_user_id): + captured["database_url"] = database_url + captured["current_user_id"] = current_user_id + yield object() + + def fake_compile_and_persist_trace( + store, + *, + user_id, + thread_id, + limits, + semantic_retrieval, + artifact_retrieval, + semantic_artifact_retrieval, + ): + captured["store_type"] = type(store).__name__ + captured["user_id"] = user_id + captured["thread_id"] = thread_id + captured["limits"] = limits + captured["semantic_retrieval"] = semantic_retrieval + captured["artifact_retrieval"] = artifact_retrieval + captured["semantic_artifact_retrieval"] = semantic_artifact_retrieval + return CompiledTraceRun( + trace_id="trace-123", + trace_event_count=5, + context_pack={ + "compiler_version": "continuity_v0", + "scope": {"user_id": str(user_id), "thread_id": str(thread_id)}, + "limits": { + "max_sessions": 2, + "max_events": 4, + "max_memories": 3, + "max_entities": 2, + "max_entity_edges": 6, + }, + "user": { + "id": str(user_id), + "email": "owner@example.com", + "display_name": "Owner", + "created_at": "2026-03-11T09:00:00+00:00", + }, + "thread": { + "id": str(thread_id), + "title": "Thread", + "created_at": "2026-03-11T09:00:00+00:00", + "updated_at": "2026-03-11T09:01:00+00:00", + }, + "sessions": [], + "events": [], + "memories": [ + { + "id": "memory-123", + "memory_key": "user.preference.coffee", + "value": {"likes": "oat milk"}, + "status": "active", + "source_event_ids": ["event-1"], + "created_at": "2026-03-11T09:00:00+00:00", + "updated_at": "2026-03-11T09:02:00+00:00", + "source_provenance": {"sources": ["symbolic"], "semantic_score": None}, + } + ], + "memory_summary": { + "candidate_count": 2, + "included_count": 1, + "excluded_deleted_count": 1, + "excluded_limit_count": 0, + "hybrid_retrieval": { + "requested": False, + "embedding_config_id": None, + "query_vector_dimensions": 0, + "semantic_limit": 0, + "symbolic_selected_count": 1, + "semantic_selected_count": 0, + "merged_candidate_count": 1, + "deduplicated_count": 0, + "included_symbolic_only_count": 1, + "included_semantic_only_count": 0, + "included_dual_source_count": 0, + "similarity_metric": None, + "source_precedence": ["symbolic", "semantic"], + "symbolic_order": ["updated_at_asc", "created_at_asc", "id_asc"], + "semantic_order": ["score_desc", "created_at_asc", "id_asc"], + }, + }, + "artifact_chunks": [], + "artifact_chunk_summary": { + "requested": False, + "lexical_requested": False, + "semantic_requested": False, + "scope": None, + "query": None, + "query_terms": [], + "embedding_config_id": None, + "query_vector_dimensions": 0, + "limit": 0, + "lexical_limit": 0, + "semantic_limit": 0, + "searched_artifact_count": 0, + "lexical_candidate_count": 0, + "semantic_candidate_count": 0, + "merged_candidate_count": 0, + "deduplicated_count": 0, + "included_count": 0, + "included_lexical_only_count": 0, + "included_semantic_only_count": 0, + "included_dual_source_count": 0, + "excluded_uningested_artifact_count": 0, + "excluded_limit_count": 0, + "matching_rule": None, + "similarity_metric": None, + "source_precedence": ["lexical", "semantic"], + "lexical_order": [ + "matched_query_term_count_desc", + "first_match_char_start_asc", + "relative_path_asc", + "sequence_no_asc", + "id_asc", + ], + "semantic_order": ["score_desc", "relative_path_asc", "sequence_no_asc", "id_asc"], + "merged_order": [ + "source_precedence_asc", + "lexical_rank_asc", + "semantic_rank_asc", + "relative_path_asc", + "sequence_no_asc", + "id_asc", + ], + }, + "entities": [ + { + "id": "entity-123", + "entity_type": "project", + "name": "AliceBot", + "source_memory_ids": ["memory-123"], + "created_at": "2026-03-11T09:03:00+00:00", + } + ], + "entity_summary": { + "candidate_count": 2, + "included_count": 1, + "excluded_limit_count": 1, + }, + "entity_edges": [ + { + "id": "edge-123", + "from_entity_id": "entity-123", + "to_entity_id": "entity-999", + "relationship_type": "depends_on", + "valid_from": "2026-03-11T09:04:00+00:00", + "valid_to": None, + "source_memory_ids": ["memory-123"], + "created_at": "2026-03-11T09:04:00+00:00", + } + ], + "entity_edge_summary": { + "anchor_entity_count": 1, + "candidate_count": 2, + "included_count": 1, + "excluded_limit_count": 1, + }, + }, + ) + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr( + main_module.ContinuityStore, + "get_thread", + lambda _self, thread_id: { + "id": thread_id, + "user_id": user_id, + "title": "Thread", + "agent_profile_id": "assistant_default", + "created_at": datetime.now(), + "updated_at": datetime.now(), + }, + ) + monkeypatch.setattr(main_module, "compile_and_persist_trace", fake_compile_and_persist_trace) + + response = main_module.compile_context( + main_module.CompileContextRequest( + user_id=user_id, + thread_id=thread_id, + max_sessions=2, + max_events=4, + max_memories=3, + max_entities=2, + max_entity_edges=6, + ) + ) + + assert response.status_code == 200 + assert json.loads(response.body) == { + "trace_id": "trace-123", + "trace_event_count": 5, + "context_pack": { + "compiler_version": "continuity_v0", + "scope": {"user_id": str(user_id), "thread_id": str(thread_id)}, + "limits": { + "max_sessions": 2, + "max_events": 4, + "max_memories": 3, + "max_entities": 2, + "max_entity_edges": 6, + }, + "user": { + "id": str(user_id), + "email": "owner@example.com", + "display_name": "Owner", + "created_at": "2026-03-11T09:00:00+00:00", + }, + "thread": { + "id": str(thread_id), + "title": "Thread", + "created_at": "2026-03-11T09:00:00+00:00", + "updated_at": "2026-03-11T09:01:00+00:00", + }, + "sessions": [], + "events": [], + "memories": [ + { + "id": "memory-123", + "memory_key": "user.preference.coffee", + "value": {"likes": "oat milk"}, + "status": "active", + "source_event_ids": ["event-1"], + "created_at": "2026-03-11T09:00:00+00:00", + "updated_at": "2026-03-11T09:02:00+00:00", + "source_provenance": {"sources": ["symbolic"], "semantic_score": None}, + } + ], + "memory_summary": { + "candidate_count": 2, + "included_count": 1, + "excluded_deleted_count": 1, + "excluded_limit_count": 0, + "hybrid_retrieval": { + "requested": False, + "embedding_config_id": None, + "query_vector_dimensions": 0, + "semantic_limit": 0, + "symbolic_selected_count": 1, + "semantic_selected_count": 0, + "merged_candidate_count": 1, + "deduplicated_count": 0, + "included_symbolic_only_count": 1, + "included_semantic_only_count": 0, + "included_dual_source_count": 0, + "similarity_metric": None, + "source_precedence": ["symbolic", "semantic"], + "symbolic_order": ["updated_at_asc", "created_at_asc", "id_asc"], + "semantic_order": ["score_desc", "created_at_asc", "id_asc"], + }, + }, + "artifact_chunks": [], + "artifact_chunk_summary": { + "requested": False, + "lexical_requested": False, + "semantic_requested": False, + "scope": None, + "query": None, + "query_terms": [], + "embedding_config_id": None, + "query_vector_dimensions": 0, + "limit": 0, + "lexical_limit": 0, + "semantic_limit": 0, + "searched_artifact_count": 0, + "lexical_candidate_count": 0, + "semantic_candidate_count": 0, + "merged_candidate_count": 0, + "deduplicated_count": 0, + "included_count": 0, + "included_lexical_only_count": 0, + "included_semantic_only_count": 0, + "included_dual_source_count": 0, + "excluded_uningested_artifact_count": 0, + "excluded_limit_count": 0, + "matching_rule": None, + "similarity_metric": None, + "source_precedence": ["lexical", "semantic"], + "lexical_order": [ + "matched_query_term_count_desc", + "first_match_char_start_asc", + "relative_path_asc", + "sequence_no_asc", + "id_asc", + ], + "semantic_order": ["score_desc", "relative_path_asc", "sequence_no_asc", "id_asc"], + "merged_order": [ + "source_precedence_asc", + "lexical_rank_asc", + "semantic_rank_asc", + "relative_path_asc", + "sequence_no_asc", + "id_asc", + ], + }, + "entities": [ + { + "id": "entity-123", + "entity_type": "project", + "name": "AliceBot", + "source_memory_ids": ["memory-123"], + "created_at": "2026-03-11T09:03:00+00:00", + } + ], + "entity_summary": { + "candidate_count": 2, + "included_count": 1, + "excluded_limit_count": 1, + }, + "entity_edges": [ + { + "id": "edge-123", + "from_entity_id": "entity-123", + "to_entity_id": "entity-999", + "relationship_type": "depends_on", + "valid_from": "2026-03-11T09:04:00+00:00", + "valid_to": None, + "source_memory_ids": ["memory-123"], + "created_at": "2026-03-11T09:04:00+00:00", + } + ], + "entity_edge_summary": { + "anchor_entity_count": 1, + "candidate_count": 2, + "included_count": 1, + "excluded_limit_count": 1, + }, + }, + "metadata": { + "agent_profile_id": "assistant_default", + }, + } + assert captured["database_url"] == "postgresql://app" + assert captured["current_user_id"] == user_id + assert captured["user_id"] == user_id + assert captured["thread_id"] == thread_id + assert captured["limits"].max_sessions == 2 + assert captured["limits"].max_events == 4 + assert captured["limits"].max_memories == 3 + assert captured["limits"].max_entities == 2 + assert captured["limits"].max_entity_edges == 6 + assert captured["semantic_retrieval"] is None + assert captured["artifact_retrieval"] is None + assert captured["semantic_artifact_retrieval"] is None + + +def test_compile_context_returns_not_found_when_scope_row_is_missing(monkeypatch) -> None: + @contextmanager + def fake_user_connection(_database_url: str, _current_user_id): + yield object() + + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url="postgresql://app")) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr( + main_module.ContinuityStore, + "get_thread", + lambda _self, thread_id: { + "id": thread_id, + "user_id": uuid4(), + "title": "Thread", + "agent_profile_id": "assistant_default", + "created_at": datetime.now(), + "updated_at": datetime.now(), + }, + ) + monkeypatch.setattr( + main_module, + "compile_and_persist_trace", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + ContinuityStoreInvariantError("get_thread did not return a row from the database") + ), + ) + + response = main_module.compile_context( + main_module.CompileContextRequest(user_id=uuid4(), thread_id=uuid4()) + ) + + assert response.status_code == 404 + assert json.loads(response.body) == { + "detail": "get_thread did not return a row from the database", + } + + +def test_compile_context_routes_semantic_and_artifact_inputs_and_validation_errors( + monkeypatch, +) -> None: + user_id = uuid4() + thread_id = uuid4() + config_id = uuid4() + settings = Settings(database_url="postgresql://app") + captured: dict[str, object] = {} + + @contextmanager + def fake_user_connection(database_url: str, current_user_id): + captured["database_url"] = database_url + captured["current_user_id"] = current_user_id + yield object() + + def fake_compile_and_persist_trace( + store, + *, + user_id, + thread_id, + limits, + semantic_retrieval, + artifact_retrieval, + semantic_artifact_retrieval, + ): + captured["store_type"] = type(store).__name__ + captured["user_id"] = user_id + captured["thread_id"] = thread_id + captured["limits"] = limits + captured["semantic_retrieval"] = semantic_retrieval + captured["artifact_retrieval"] = artifact_retrieval + captured["semantic_artifact_retrieval"] = semantic_artifact_retrieval + return CompiledTraceRun( + trace_id="trace-semantic", + trace_event_count=7, + context_pack={ + "compiler_version": "continuity_v0", + "scope": {"user_id": str(user_id), "thread_id": str(thread_id)}, + "limits": { + "max_sessions": 3, + "max_events": 8, + "max_memories": 5, + "max_entities": 5, + "max_entity_edges": 10, + }, + "user": { + "id": str(user_id), + "email": "owner@example.com", + "display_name": "Owner", + "created_at": "2026-03-12T09:00:00+00:00", + }, + "thread": { + "id": str(thread_id), + "title": "Thread", + "created_at": "2026-03-12T09:00:00+00:00", + "updated_at": "2026-03-12T09:01:00+00:00", + }, + "sessions": [], + "events": [], + "memories": [ + { + "id": "memory-123", + "memory_key": "user.preference.coffee", + "value": {"likes": "oat milk"}, + "status": "active", + "source_event_ids": ["event-123"], + "created_at": "2026-03-12T09:00:00+00:00", + "updated_at": "2026-03-12T09:00:00+00:00", + "source_provenance": { + "sources": ["symbolic", "semantic"], + "semantic_score": 0.99, + }, + } + ], + "memory_summary": { + "candidate_count": 1, + "included_count": 1, + "excluded_deleted_count": 0, + "excluded_limit_count": 0, + "hybrid_retrieval": { + "requested": True, + "embedding_config_id": str(config_id), + "query_vector_dimensions": 3, + "semantic_limit": 2, + "symbolic_selected_count": 1, + "semantic_selected_count": 1, + "merged_candidate_count": 1, + "deduplicated_count": 1, + "included_symbolic_only_count": 0, + "included_semantic_only_count": 0, + "included_dual_source_count": 1, + "similarity_metric": "cosine_similarity", + "source_precedence": ["symbolic", "semantic"], + "symbolic_order": ["updated_at_asc", "created_at_asc", "id_asc"], + "semantic_order": ["score_desc", "created_at_asc", "id_asc"], + }, + }, + "artifact_chunks": [ + { + "id": "chunk-123", + "task_id": "task-123", + "task_artifact_id": "artifact-123", + "relative_path": "docs/spec.txt", + "media_type": "text/plain", + "sequence_no": 1, + "char_start": 0, + "char_end_exclusive": 16, + "text": "alpha beta spec", + "source_provenance": { + "sources": ["lexical", "semantic"], + "lexical_match": { + "matched_query_terms": ["alpha", "beta"], + "matched_query_term_count": 2, + "first_match_char_start": 0, + }, + "semantic_score": 0.99, + }, + } + ], + "artifact_chunk_summary": { + "requested": True, + "lexical_requested": True, + "semantic_requested": True, + "scope": {"kind": "task", "task_id": "task-123"}, + "query": "alpha beta", + "query_terms": ["alpha", "beta"], + "embedding_config_id": str(config_id), + "query_vector_dimensions": 3, + "limit": 2, + "lexical_limit": 2, + "semantic_limit": 2, + "searched_artifact_count": 1, + "lexical_candidate_count": 1, + "semantic_candidate_count": 1, + "merged_candidate_count": 1, + "deduplicated_count": 1, + "included_count": 1, + "included_lexical_only_count": 0, + "included_semantic_only_count": 0, + "included_dual_source_count": 1, + "excluded_uningested_artifact_count": 0, + "excluded_limit_count": 0, + "matching_rule": "casefolded_unicode_word_overlap_unique_query_terms_v1", + "similarity_metric": "cosine_similarity", + "source_precedence": ["lexical", "semantic"], + "lexical_order": [ + "matched_query_term_count_desc", + "first_match_char_start_asc", + "relative_path_asc", + "sequence_no_asc", + "id_asc", + ], + "semantic_order": ["score_desc", "relative_path_asc", "sequence_no_asc", "id_asc"], + "merged_order": [ + "source_precedence_asc", + "lexical_rank_asc", + "semantic_rank_asc", + "relative_path_asc", + "sequence_no_asc", + "id_asc", + ], + }, + "entities": [], + "entity_summary": { + "candidate_count": 0, + "included_count": 0, + "excluded_limit_count": 0, + }, + "entity_edges": [], + "entity_edge_summary": { + "anchor_entity_count": 0, + "candidate_count": 0, + "included_count": 0, + "excluded_limit_count": 0, + }, + }, + ) + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr( + main_module.ContinuityStore, + "get_thread", + lambda _self, thread_id: { + "id": thread_id, + "user_id": user_id, + "title": "Thread", + "agent_profile_id": "assistant_default", + "created_at": datetime.now(), + "updated_at": datetime.now(), + }, + ) + monkeypatch.setattr(main_module, "compile_and_persist_trace", fake_compile_and_persist_trace) + + response = main_module.compile_context( + main_module.CompileContextRequest( + user_id=user_id, + thread_id=thread_id, + semantic=main_module.CompileContextSemanticRequest( + embedding_config_id=config_id, + query_vector=[0.1, 0.2, 0.3], + limit=2, + ), + artifact_retrieval=main_module.CompileContextTaskScopedArtifactRetrievalRequest( + kind="task", + task_id=uuid4(), + query="alpha beta", + limit=2, + ), + semantic_artifact_retrieval=( + main_module.CompileContextTaskScopedSemanticArtifactRetrievalRequest( + kind="task", + task_id=uuid4(), + embedding_config_id=config_id, + query_vector=[0.1, 0.2, 0.3], + limit=2, + ) + ), + ) + ) + + assert response.status_code == 200 + assert json.loads(response.body)["context_pack"]["memory_summary"]["hybrid_retrieval"] == { + "requested": True, + "embedding_config_id": str(config_id), + "query_vector_dimensions": 3, + "semantic_limit": 2, + "symbolic_selected_count": 1, + "semantic_selected_count": 1, + "merged_candidate_count": 1, + "deduplicated_count": 1, + "included_symbolic_only_count": 0, + "included_semantic_only_count": 0, + "included_dual_source_count": 1, + "similarity_metric": "cosine_similarity", + "source_precedence": ["symbolic", "semantic"], + "symbolic_order": ["updated_at_asc", "created_at_asc", "id_asc"], + "semantic_order": ["score_desc", "created_at_asc", "id_asc"], + } + assert captured["database_url"] == "postgresql://app" + assert captured["current_user_id"] == user_id + assert captured["semantic_retrieval"].embedding_config_id == config_id + assert captured["semantic_retrieval"].query_vector == (0.1, 0.2, 0.3) + assert captured["semantic_retrieval"].limit == 2 + assert captured["artifact_retrieval"].task_id is not None + assert captured["artifact_retrieval"].query == "alpha beta" + assert captured["artifact_retrieval"].limit == 2 + assert captured["semantic_artifact_retrieval"].task_id is not None + assert captured["semantic_artifact_retrieval"].embedding_config_id == config_id + assert captured["semantic_artifact_retrieval"].query_vector == (0.1, 0.2, 0.3) + assert captured["semantic_artifact_retrieval"].limit == 2 + + monkeypatch.setattr( + main_module, + "compile_and_persist_trace", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + SemanticMemoryRetrievalValidationError( + "embedding_config_id must reference an existing embedding config owned by the user" + ) + ), + ) + + error_response = main_module.compile_context( + main_module.CompileContextRequest( + user_id=user_id, + thread_id=thread_id, + semantic=main_module.CompileContextSemanticRequest( + embedding_config_id=config_id, + query_vector=[0.1, 0.2, 0.3], + limit=2, + ), + ) + ) + + assert error_response.status_code == 400 + assert json.loads(error_response.body) == { + "detail": "embedding_config_id must reference an existing embedding config owned by the user" + } + + monkeypatch.setattr( + main_module, + "compile_and_persist_trace", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + SemanticArtifactChunkRetrievalValidationError( + "query_vector length must match embedding config dimensions (3): 2" + ) + ), + ) + + semantic_artifact_error_response = main_module.compile_context( + main_module.CompileContextRequest( + user_id=user_id, + thread_id=thread_id, + semantic_artifact_retrieval=( + main_module.CompileContextTaskScopedSemanticArtifactRetrievalRequest( + kind="task", + task_id=uuid4(), + embedding_config_id=config_id, + query_vector=[0.1, 0.2], + limit=2, + ) + ), + ) + ) + + assert semantic_artifact_error_response.status_code == 400 + assert json.loads(semantic_artifact_error_response.body) == { + "detail": "query_vector length must match embedding config dimensions (3): 2" + } + + +def test_compile_context_request_rejects_invalid_artifact_scope_shape() -> None: + with pytest.raises(Exception) as exc_info: + main_module.CompileContextRequest( + user_id=uuid4(), + thread_id=uuid4(), + artifact_retrieval={ + "kind": "task", + "task_artifact_id": str(uuid4()), + "query": "alpha beta", + }, + ) + + assert "task_id" in str(exc_info.value) + + +def test_compile_context_request_rejects_invalid_semantic_artifact_scope_shape() -> None: + with pytest.raises(Exception) as exc_info: + main_module.CompileContextRequest( + user_id=uuid4(), + thread_id=uuid4(), + semantic_artifact_retrieval={ + "kind": "task", + "task_artifact_id": str(uuid4()), + "embedding_config_id": str(uuid4()), + "query_vector": [0.1, 0.2, 0.3], + }, + ) + + assert "task_id" in str(exc_info.value) + + +def test_generate_assistant_response_returns_assistant_and_trace_payload(monkeypatch) -> None: + user_id = uuid4() + thread_id = uuid4() + settings = Settings( + database_url="postgresql://app", + model_provider="openai_responses", + model_name="gpt-5-mini", + ) + captured: dict[str, object] = {} + + @contextmanager + def fake_user_connection(database_url: str, current_user_id): + captured["database_url"] = database_url + captured["current_user_id"] = current_user_id + yield object() + + def fake_generate_response(store, *, settings, user_id, thread_id, message_text, limits): + captured["store_type"] = type(store).__name__ + captured["settings"] = settings + captured["user_id"] = user_id + captured["thread_id"] = thread_id + captured["message_text"] = message_text + captured["limits"] = limits + return { + "assistant": { + "event_id": "assistant-event-123", + "sequence_no": 5, + "text": "Hello back.", + "model_provider": "openai_responses", + "model": "gpt-5-mini", + }, + "trace": { + "compile_trace_id": "compile-trace-123", + "compile_trace_event_count": 11, + "response_trace_id": "response-trace-123", + "response_trace_event_count": 2, + }, + } + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr( + main_module.ContinuityStore, + "get_thread", + lambda _self, thread_id: { + "id": thread_id, + "user_id": user_id, + "title": "Thread", + "agent_profile_id": "assistant_default", + "created_at": datetime.now(), + "updated_at": datetime.now(), + }, + ) + monkeypatch.setattr(main_module, "generate_response", fake_generate_response) + + response = main_module.generate_assistant_response( + main_module.GenerateResponseRequest( + user_id=user_id, + thread_id=thread_id, + message="Hello?", + max_sessions=2, + max_events=4, + max_memories=3, + max_entities=2, + max_entity_edges=6, + ) + ) + + assert response.status_code == 200 + assert json.loads(response.body) == { + "assistant": { + "event_id": "assistant-event-123", + "sequence_no": 5, + "text": "Hello back.", + "model_provider": "openai_responses", + "model": "gpt-5-mini", + }, + "trace": { + "compile_trace_id": "compile-trace-123", + "compile_trace_event_count": 11, + "response_trace_id": "response-trace-123", + "response_trace_event_count": 2, + }, + "metadata": { + "agent_profile_id": "assistant_default", + }, + } + assert captured["database_url"] == "postgresql://app" + assert captured["current_user_id"] == user_id + assert captured["user_id"] == user_id + assert captured["thread_id"] == thread_id + assert captured["message_text"] == "Hello?" + assert captured["limits"].max_sessions == 2 + assert captured["limits"].max_events == 4 + assert captured["limits"].max_memories == 3 + assert captured["limits"].max_entities == 2 + assert captured["limits"].max_entity_edges == 6 + + +def test_generate_assistant_response_returns_502_with_trace_when_model_invocation_fails( + monkeypatch, +) -> None: + user_id = uuid4() + thread_id = uuid4() + + @contextmanager + def fake_user_connection(_database_url: str, _current_user_id): + yield object() + + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url="postgresql://app")) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr( + main_module.ContinuityStore, + "get_thread", + lambda _self, thread_id: { + "id": thread_id, + "user_id": user_id, + "title": "Thread", + "agent_profile_id": "assistant_default", + "created_at": datetime.now(), + "updated_at": datetime.now(), + }, + ) + monkeypatch.setattr( + main_module, + "generate_response", + lambda *_args, **_kwargs: ResponseFailure( + detail="upstream timeout", + trace={ + "compile_trace_id": "compile-trace-123", + "compile_trace_event_count": 9, + "response_trace_id": "response-trace-123", + "response_trace_event_count": 2, + }, + ), + ) + + response = main_module.generate_assistant_response( + main_module.GenerateResponseRequest( + user_id=user_id, + thread_id=thread_id, + message="Hello?", + ) + ) + + assert response.status_code == 502 + assert json.loads(response.body) == { + "detail": "upstream timeout", + "trace": { + "compile_trace_id": "compile-trace-123", + "compile_trace_event_count": 9, + "response_trace_id": "response-trace-123", + "response_trace_event_count": 2, + }, + "metadata": { + "agent_profile_id": "assistant_default", + }, + } + + +def test_generate_assistant_response_enforces_rate_limit(monkeypatch) -> None: + user_id = uuid4() + thread_id = uuid4() + settings = Settings( + app_env="test", + database_url="postgresql://app", + response_rate_limit_max_requests=1, + response_rate_limit_window_seconds=60, + ) + + @contextmanager + def fake_user_connection(_database_url: str, _current_user_id): + yield object() + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr( + main_module.ContinuityStore, + "get_thread", + lambda _self, thread_id: { + "id": thread_id, + "user_id": user_id, + "title": "Thread", + "agent_profile_id": "assistant_default", + "created_at": datetime.now(), + "updated_at": datetime.now(), + }, + ) + monkeypatch.setattr( + main_module, + "generate_response", + lambda *_args, **_kwargs: { + "assistant": { + "event_id": "assistant-event-123", + "sequence_no": 5, + "text": "Hello back.", + "model_provider": "openai_responses", + "model": "gpt-5-mini", + }, + "trace": { + "compile_trace_id": "compile-trace-123", + "compile_trace_event_count": 11, + "response_trace_id": "response-trace-123", + "response_trace_event_count": 2, + }, + }, + ) + main_module.response_rate_limiter.reset() + + first_response = main_module.generate_assistant_response( + main_module.GenerateResponseRequest( + user_id=user_id, + thread_id=thread_id, + message="Hello?", + ) + ) + second_response = main_module.generate_assistant_response( + main_module.GenerateResponseRequest( + user_id=user_id, + thread_id=thread_id, + message="Hello again?", + ) + ) + main_module.response_rate_limiter.reset() + + assert first_response.status_code == 200 + assert second_response.status_code == 429 + retry_after = int(second_response.headers["Retry-After"]) + assert 1 <= retry_after <= 60 + assert json.loads(second_response.body) == { + "detail": { + "code": "response_rate_limit_exceeded", + "message": "response generation rate limit exceeded; max 1 requests per 60 seconds", + "retry_after_seconds": retry_after, + } + } + + +def test_admit_memory_returns_decision_payload(monkeypatch) -> None: + user_id = uuid4() + settings = Settings(database_url="postgresql://app") + captured: dict[str, object] = {} + + @contextmanager + def fake_user_connection(database_url: str, current_user_id): + captured["database_url"] = database_url + captured["current_user_id"] = current_user_id + yield object() + + def fake_admit_memory_candidate(store, *, user_id, candidate): + captured["store_type"] = type(store).__name__ + captured["user_id"] = user_id + captured["candidate"] = candidate + return AdmissionDecisionOutput( + action="ADD", + reason="source_backed_add", + memory={ + "id": "memory-123", + "user_id": str(user_id), + "memory_key": "user.preference.coffee", + "value": {"likes": "oat milk"}, + "status": "active", + "source_event_ids": ["event-1"], + "created_at": "2026-03-11T09:00:00+00:00", + "updated_at": "2026-03-11T09:00:00+00:00", + "deleted_at": None, + }, + revision={ + "id": "revision-123", + "user_id": str(user_id), + "memory_id": "memory-123", + "sequence_no": 1, + "action": "ADD", + "memory_key": "user.preference.coffee", + "previous_value": None, + "new_value": {"likes": "oat milk"}, + "source_event_ids": ["event-1"], + "candidate": { + "memory_key": "user.preference.coffee", + "value": {"likes": "oat milk"}, + "source_event_ids": ["event-1"], + "delete_requested": False, + }, + "created_at": "2026-03-11T09:00:00+00:00", + }, + ) + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "admit_memory_candidate", fake_admit_memory_candidate) + + response = main_module.admit_memory( + main_module.AdmitMemoryRequest( + user_id=user_id, + memory_key="user.preference.coffee", + value={"likes": "oat milk"}, + source_event_ids=[uuid4()], + ) + ) + + assert response.status_code == 200 + assert json.loads(response.body) == { + "decision": "ADD", + "reason": "source_backed_add", + "memory": { + "id": "memory-123", + "user_id": str(user_id), + "memory_key": "user.preference.coffee", + "value": {"likes": "oat milk"}, + "status": "active", + "source_event_ids": ["event-1"], + "created_at": "2026-03-11T09:00:00+00:00", + "updated_at": "2026-03-11T09:00:00+00:00", + "deleted_at": None, + }, + "revision": { + "id": "revision-123", + "user_id": str(user_id), + "memory_id": "memory-123", + "sequence_no": 1, + "action": "ADD", + "memory_key": "user.preference.coffee", + "previous_value": None, + "new_value": {"likes": "oat milk"}, + "source_event_ids": ["event-1"], + "candidate": { + "memory_key": "user.preference.coffee", + "value": {"likes": "oat milk"}, + "source_event_ids": ["event-1"], + "delete_requested": False, + }, + "created_at": "2026-03-11T09:00:00+00:00", + }, + } + assert captured["database_url"] == "postgresql://app" + assert captured["current_user_id"] == user_id + assert captured["user_id"] == user_id + assert captured["candidate"].memory_key == "user.preference.coffee" + + +def test_admit_memory_includes_open_loop_payload_when_created(monkeypatch) -> None: + user_id = uuid4() + settings = Settings(database_url="postgresql://app") + captured: dict[str, object] = {} + + @contextmanager + def fake_user_connection(_database_url: str, _current_user_id): + yield object() + + def fake_admit_memory_candidate(_store, *, user_id, candidate): + captured["user_id"] = user_id + captured["candidate"] = candidate + return AdmissionDecisionOutput( + action="NOOP", + reason="memory_unchanged", + memory=None, + revision=None, + open_loop={ + "id": "loop-123", + "memory_id": "memory-123", + "title": "Confirm before reorder", + "status": "open", + "opened_at": "2026-03-23T10:00:00+00:00", + "due_at": "2026-03-25T10:00:00+00:00", + "resolved_at": None, + "resolution_note": None, + "created_at": "2026-03-23T10:00:00+00:00", + "updated_at": "2026-03-23T10:00:00+00:00", + }, + ) + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "admit_memory_candidate", fake_admit_memory_candidate) + + response = main_module.admit_memory( + main_module.AdmitMemoryRequest( + user_id=user_id, + memory_key="user.preference.coffee", + value={"likes": "oat milk"}, + source_event_ids=[uuid4()], + open_loop=main_module.AdmitMemoryOpenLoopRequest( + title="Confirm before reorder", + due_at="2026-03-25T10:00:00+00:00", + ), + ) + ) + + assert response.status_code == 200 + assert json.loads(response.body)["open_loop"] == { + "id": "loop-123", + "memory_id": "memory-123", + "title": "Confirm before reorder", + "status": "open", + "opened_at": "2026-03-23T10:00:00+00:00", + "due_at": "2026-03-25T10:00:00+00:00", + "resolved_at": None, + "resolution_note": None, + "created_at": "2026-03-23T10:00:00+00:00", + "updated_at": "2026-03-23T10:00:00+00:00", + } + assert captured["candidate"].open_loop is not None + assert captured["candidate"].open_loop.title == "Confirm before reorder" + + +def test_admit_memory_returns_bad_request_when_source_validation_fails(monkeypatch) -> None: + @contextmanager + def fake_user_connection(_database_url: str, _current_user_id): + yield object() + + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url="postgresql://app")) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr( + main_module, + "admit_memory_candidate", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + MemoryAdmissionValidationError("source_event_ids must all reference existing events owned by the user") + ), + ) + + response = main_module.admit_memory( + main_module.AdmitMemoryRequest( + user_id=uuid4(), + memory_key="user.preference.coffee", + value={"likes": "black"}, + source_event_ids=[uuid4()], + ) + ) + + assert response.status_code == 400 + assert json.loads(response.body) == { + "detail": "source_event_ids must all reference existing events owned by the user", + } + + +def test_extract_explicit_preferences_returns_payload(monkeypatch) -> None: + user_id = uuid4() + source_event_id = uuid4() + settings = Settings(database_url="postgresql://app") + captured: dict[str, object] = {} + + @contextmanager + def fake_user_connection(database_url: str, current_user_id): + captured["database_url"] = database_url + captured["current_user_id"] = current_user_id + yield object() + + def fake_extract_and_admit_explicit_preferences(store, *, user_id, request): + captured["store_type"] = type(store).__name__ + captured["user_id"] = user_id + captured["request"] = request + return { + "candidates": [ + { + "memory_key": "user.preference.black_coffee", + "value": { + "kind": "explicit_preference", + "preference": "like", + "text": "black coffee", + }, + "source_event_ids": [str(source_event_id)], + "delete_requested": False, + "pattern": "i_like", + "subject_text": "black coffee", + } + ], + "admissions": [ + { + "decision": "ADD", + "reason": "source_backed_add", + "memory": { + "id": "memory-123", + "user_id": str(user_id), + "memory_key": "user.preference.black_coffee", + "value": { + "kind": "explicit_preference", + "preference": "like", + "text": "black coffee", + }, + "status": "active", + "source_event_ids": [str(source_event_id)], + "created_at": "2026-03-12T09:00:00+00:00", + "updated_at": "2026-03-12T09:00:00+00:00", + "deleted_at": None, + }, + "revision": { + "id": "revision-123", + "user_id": str(user_id), + "memory_id": "memory-123", + "sequence_no": 1, + "action": "ADD", + "memory_key": "user.preference.black_coffee", + "previous_value": None, + "new_value": { + "kind": "explicit_preference", + "preference": "like", + "text": "black coffee", + }, + "source_event_ids": [str(source_event_id)], + "candidate": { + "memory_key": "user.preference.black_coffee", + "value": { + "kind": "explicit_preference", + "preference": "like", + "text": "black coffee", + }, + "source_event_ids": [str(source_event_id)], + "delete_requested": False, + }, + "created_at": "2026-03-12T09:00:00+00:00", + }, + } + ], + "summary": { + "source_event_id": str(source_event_id), + "source_event_kind": "message.user", + "candidate_count": 1, + "admission_count": 1, + "persisted_change_count": 1, + "noop_count": 0, + }, + } + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr( + main_module, + "extract_and_admit_explicit_preferences", + fake_extract_and_admit_explicit_preferences, + ) + + response = main_module.extract_explicit_preferences( + main_module.ExtractExplicitPreferencesRequest( + user_id=user_id, + source_event_id=source_event_id, + ) + ) + + assert response.status_code == 200 + assert json.loads(response.body) == { + "candidates": [ + { + "memory_key": "user.preference.black_coffee", + "value": { + "kind": "explicit_preference", + "preference": "like", + "text": "black coffee", + }, + "source_event_ids": [str(source_event_id)], + "delete_requested": False, + "pattern": "i_like", + "subject_text": "black coffee", + } + ], + "admissions": [ + { + "decision": "ADD", + "reason": "source_backed_add", + "memory": { + "id": "memory-123", + "user_id": str(user_id), + "memory_key": "user.preference.black_coffee", + "value": { + "kind": "explicit_preference", + "preference": "like", + "text": "black coffee", + }, + "status": "active", + "source_event_ids": [str(source_event_id)], + "created_at": "2026-03-12T09:00:00+00:00", + "updated_at": "2026-03-12T09:00:00+00:00", + "deleted_at": None, + }, + "revision": { + "id": "revision-123", + "user_id": str(user_id), + "memory_id": "memory-123", + "sequence_no": 1, + "action": "ADD", + "memory_key": "user.preference.black_coffee", + "previous_value": None, + "new_value": { + "kind": "explicit_preference", + "preference": "like", + "text": "black coffee", + }, + "source_event_ids": [str(source_event_id)], + "candidate": { + "memory_key": "user.preference.black_coffee", + "value": { + "kind": "explicit_preference", + "preference": "like", + "text": "black coffee", + }, + "source_event_ids": [str(source_event_id)], + "delete_requested": False, + }, + "created_at": "2026-03-12T09:00:00+00:00", + }, + } + ], + "summary": { + "source_event_id": str(source_event_id), + "source_event_kind": "message.user", + "candidate_count": 1, + "admission_count": 1, + "persisted_change_count": 1, + "noop_count": 0, + }, + } + assert captured["database_url"] == "postgresql://app" + assert captured["current_user_id"] == user_id + assert captured["user_id"] == user_id + assert captured["request"].source_event_id == source_event_id + + +def test_extract_explicit_preferences_returns_bad_request_when_source_event_is_invalid( + monkeypatch, +) -> None: + @contextmanager + def fake_user_connection(_database_url: str, _current_user_id): + yield object() + + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url="postgresql://app")) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr( + main_module, + "extract_and_admit_explicit_preferences", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + main_module.ExplicitPreferenceExtractionValidationError( + "source_event_id must reference an existing message.user event owned by the user" + ) + ), + ) + + response = main_module.extract_explicit_preferences( + main_module.ExtractExplicitPreferencesRequest( + user_id=uuid4(), + source_event_id=uuid4(), + ) + ) + + assert response.status_code == 400 + assert json.loads(response.body) == { + "detail": "source_event_id must reference an existing message.user event owned by the user", + } + + +def test_extract_explicit_commitments_returns_payload(monkeypatch) -> None: + user_id = uuid4() + source_event_id = uuid4() + settings = Settings(database_url="postgresql://app") + captured: dict[str, object] = {} + + @contextmanager + def fake_user_connection(database_url: str, current_user_id): + captured["database_url"] = database_url + captured["current_user_id"] = current_user_id + yield object() + + def fake_extract_and_admit_explicit_commitments(store, *, user_id, request): + captured["store_type"] = type(store).__name__ + captured["user_id"] = user_id + captured["request"] = request + return { + "candidates": [ + { + "memory_key": "user.commitment.submit_tax_forms", + "value": { + "kind": "explicit_commitment", + "text": "submit tax forms", + }, + "source_event_ids": [str(source_event_id)], + "delete_requested": False, + "pattern": "remind_me_to", + "commitment_text": "submit tax forms", + "open_loop_title": "Remember to submit tax forms", + } + ], + "admissions": [ + { + "decision": "ADD", + "reason": "source_backed_add", + "memory": { + "id": "memory-123", + "user_id": str(user_id), + "memory_key": "user.commitment.submit_tax_forms", + "value": { + "kind": "explicit_commitment", + "text": "submit tax forms", + }, + "status": "active", + "source_event_ids": [str(source_event_id)], + "memory_type": "commitment", + "created_at": "2026-03-23T09:00:00+00:00", + "updated_at": "2026-03-23T09:00:00+00:00", + "deleted_at": None, + }, + "revision": { + "id": "revision-123", + "user_id": str(user_id), + "memory_id": "memory-123", + "sequence_no": 1, + "action": "ADD", + "memory_key": "user.commitment.submit_tax_forms", + "previous_value": None, + "new_value": { + "kind": "explicit_commitment", + "text": "submit tax forms", + }, + "source_event_ids": [str(source_event_id)], + "candidate": { + "memory_key": "user.commitment.submit_tax_forms", + "value": { + "kind": "explicit_commitment", + "text": "submit tax forms", + }, + "source_event_ids": [str(source_event_id)], + "delete_requested": False, + "memory_type": "commitment", + }, + "created_at": "2026-03-23T09:00:00+00:00", + }, + "open_loop": { + "decision": "CREATED", + "reason": "created_open_loop_for_memory", + "open_loop": { + "id": "loop-123", + "memory_id": "memory-123", + "title": "Remember to submit tax forms", + "status": "open", + "opened_at": "2026-03-23T09:00:00+00:00", + "due_at": None, + "resolved_at": None, + "resolution_note": None, + "created_at": "2026-03-23T09:00:00+00:00", + "updated_at": "2026-03-23T09:00:00+00:00", + }, + }, + } + ], + "summary": { + "source_event_id": str(source_event_id), + "source_event_kind": "message.user", + "candidate_count": 1, + "admission_count": 1, + "persisted_change_count": 1, + "noop_count": 0, + "open_loop_created_count": 1, + "open_loop_noop_count": 0, + }, + } + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr( + main_module, + "extract_and_admit_explicit_commitments", + fake_extract_and_admit_explicit_commitments, + ) + + response = main_module.extract_explicit_commitments( + main_module.ExtractExplicitCommitmentsRequest( + user_id=user_id, + source_event_id=source_event_id, + ) + ) + + assert response.status_code == 200 + assert json.loads(response.body)["summary"] == { + "source_event_id": str(source_event_id), + "source_event_kind": "message.user", + "candidate_count": 1, + "admission_count": 1, + "persisted_change_count": 1, + "noop_count": 0, + "open_loop_created_count": 1, + "open_loop_noop_count": 0, + } + assert captured["database_url"] == "postgresql://app" + assert captured["current_user_id"] == user_id + assert captured["user_id"] == user_id + assert captured["request"].source_event_id == source_event_id + + +def test_extract_explicit_commitments_returns_bad_request_when_source_event_is_invalid( + monkeypatch, +) -> None: + @contextmanager + def fake_user_connection(_database_url: str, _current_user_id): + yield object() + + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url="postgresql://app")) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr( + main_module, + "extract_and_admit_explicit_commitments", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + main_module.ExplicitCommitmentExtractionValidationError( + "source_event_id must reference an existing message.user event owned by the user" + ) + ), + ) + + response = main_module.extract_explicit_commitments( + main_module.ExtractExplicitCommitmentsRequest( + user_id=uuid4(), + source_event_id=uuid4(), + ) + ) + + assert response.status_code == 400 + assert json.loads(response.body) == { + "detail": "source_event_id must reference an existing message.user event owned by the user", + } + + +def test_capture_explicit_signals_returns_payload(monkeypatch) -> None: + user_id = uuid4() + source_event_id = uuid4() + settings = Settings(database_url="postgresql://app") + captured: dict[str, object] = {} + + @contextmanager + def fake_user_connection(database_url: str, current_user_id): + captured["database_url"] = database_url + captured["current_user_id"] = current_user_id + yield object() + + def fake_extract_and_admit_explicit_signals(store, *, user_id, request): + captured["store_type"] = type(store).__name__ + captured["user_id"] = user_id + captured["request"] = request + return { + "preferences": { + "candidates": [], + "admissions": [], + "summary": { + "source_event_id": str(source_event_id), + "source_event_kind": "message.user", + "candidate_count": 0, + "admission_count": 0, + "persisted_change_count": 0, + "noop_count": 0, + }, + }, + "commitments": { + "candidates": [ + { + "memory_key": "user.commitment.submit_tax_forms", + "value": { + "kind": "explicit_commitment", + "text": "submit tax forms", + }, + "source_event_ids": [str(source_event_id)], + "delete_requested": False, + "pattern": "remind_me_to", + "commitment_text": "submit tax forms", + "open_loop_title": "Remember to submit tax forms", + } + ], + "admissions": [], + "summary": { + "source_event_id": str(source_event_id), + "source_event_kind": "message.user", + "candidate_count": 1, + "admission_count": 0, + "persisted_change_count": 0, + "noop_count": 0, + "open_loop_created_count": 0, + "open_loop_noop_count": 0, + }, + }, + "summary": { + "source_event_id": str(source_event_id), + "source_event_kind": "message.user", + "candidate_count": 1, + "admission_count": 0, + "persisted_change_count": 0, + "noop_count": 0, + "open_loop_created_count": 0, + "open_loop_noop_count": 0, + "preference_candidate_count": 0, + "preference_admission_count": 0, + "commitment_candidate_count": 1, + "commitment_admission_count": 0, + }, + } + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr( + main_module, + "extract_and_admit_explicit_signals", + fake_extract_and_admit_explicit_signals, + ) + + response = main_module.capture_explicit_signals( + main_module.CaptureExplicitSignalsRequest( + user_id=user_id, + source_event_id=source_event_id, + ) + ) + + assert response.status_code == 200 + assert json.loads(response.body)["summary"] == { + "source_event_id": str(source_event_id), + "source_event_kind": "message.user", + "candidate_count": 1, + "admission_count": 0, + "persisted_change_count": 0, + "noop_count": 0, + "open_loop_created_count": 0, + "open_loop_noop_count": 0, + "preference_candidate_count": 0, + "preference_admission_count": 0, + "commitment_candidate_count": 1, + "commitment_admission_count": 0, + } + assert captured["database_url"] == "postgresql://app" + assert captured["current_user_id"] == user_id + assert captured["user_id"] == user_id + assert captured["request"].source_event_id == source_event_id + + +def test_capture_explicit_signals_returns_bad_request_when_source_event_is_invalid( + monkeypatch, +) -> None: + @contextmanager + def fake_user_connection(_database_url: str, _current_user_id): + yield object() + + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url="postgresql://app")) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr( + main_module, + "extract_and_admit_explicit_signals", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + main_module.ExplicitSignalCaptureValidationError( + "source_event_id must reference an existing message.user event owned by the user" + ) + ), + ) + + response = main_module.capture_explicit_signals( + main_module.CaptureExplicitSignalsRequest( + user_id=uuid4(), + source_event_id=uuid4(), + ) + ) + + assert response.status_code == 400 + assert json.loads(response.body) == { + "detail": "source_event_id must reference an existing message.user event owned by the user", + } + + +def test_list_memories_returns_review_payload(monkeypatch) -> None: + user_id = uuid4() + settings = Settings(database_url="postgresql://app") + captured: dict[str, object] = {} + + @contextmanager + def fake_user_connection(database_url: str, current_user_id): + captured["database_url"] = database_url + captured["current_user_id"] = current_user_id + yield object() + + def fake_list_memory_review_records(store, *, user_id, status, limit): + captured["store_type"] = type(store).__name__ + captured["user_id"] = user_id + captured["status"] = status + captured["limit"] = limit + return { + "items": [ + { + "id": "memory-123", + "memory_key": "user.preference.coffee", + "value": {"likes": "oat milk"}, + "status": "active", + "source_event_ids": ["event-1"], + "created_at": "2026-03-11T09:00:00+00:00", + "updated_at": "2026-03-11T09:02:00+00:00", + "deleted_at": None, + } + ], + "summary": { + "status": "active", + "limit": 10, + "returned_count": 1, + "total_count": 1, + "has_more": False, + "order": ["updated_at_desc", "created_at_desc", "id_desc"], + }, + } + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "list_memory_review_records", fake_list_memory_review_records) + + response = main_module.list_memories(user_id=user_id, status="active", limit=10) + + assert response.status_code == 200 + assert json.loads(response.body) == { + "items": [ + { + "id": "memory-123", + "memory_key": "user.preference.coffee", + "value": {"likes": "oat milk"}, + "status": "active", + "source_event_ids": ["event-1"], + "created_at": "2026-03-11T09:00:00+00:00", + "updated_at": "2026-03-11T09:02:00+00:00", + "deleted_at": None, + } + ], + "summary": { + "status": "active", + "limit": 10, + "returned_count": 1, + "total_count": 1, + "has_more": False, + "order": ["updated_at_desc", "created_at_desc", "id_desc"], + }, + } + assert captured["database_url"] == "postgresql://app" + assert captured["current_user_id"] == user_id + assert captured["user_id"] == user_id + assert captured["status"] == "active" + assert captured["limit"] == 10 + + +def test_open_loop_routes_return_payload_and_errors(monkeypatch) -> None: + user_id = uuid4() + open_loop_id = uuid4() + memory_id = uuid4() + settings = Settings(database_url="postgresql://app") + captured: dict[str, object] = {} + + @contextmanager + def fake_user_connection(database_url: str, current_user_id): + captured["database_url"] = database_url + captured["current_user_id"] = current_user_id + yield object() + + def fake_list_open_loop_records(store, *, user_id, status, limit): + captured["list_store_type"] = type(store).__name__ + captured["list_user_id"] = user_id + captured["list_status"] = status + captured["list_limit"] = limit + return { + "items": [ + { + "id": str(open_loop_id), + "memory_id": str(memory_id), + "title": "Follow up", + "status": "open", + "opened_at": "2026-03-23T09:00:00+00:00", + "due_at": None, + "resolved_at": None, + "resolution_note": None, + "created_at": "2026-03-23T09:00:00+00:00", + "updated_at": "2026-03-23T09:00:00+00:00", + } + ], + "summary": { + "status": "open", + "limit": 10, + "returned_count": 1, + "total_count": 1, + "has_more": False, + "order": ["opened_at_desc", "created_at_desc", "id_desc"], + }, + } + + def fake_get_open_loop_record(_store, *, user_id, open_loop_id): + captured["detail_user_id"] = user_id + captured["detail_open_loop_id"] = open_loop_id + return { + "open_loop": { + "id": str(open_loop_id), + "memory_id": str(memory_id), + "title": "Follow up", + "status": "open", + "opened_at": "2026-03-23T09:00:00+00:00", + "due_at": None, + "resolved_at": None, + "resolution_note": None, + "created_at": "2026-03-23T09:00:00+00:00", + "updated_at": "2026-03-23T09:00:00+00:00", + } + } + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "list_open_loop_records", fake_list_open_loop_records) + monkeypatch.setattr(main_module, "get_open_loop_record", fake_get_open_loop_record) + + list_response = main_module.list_open_loops(user_id=user_id, status="open", limit=10) + detail_response = main_module.get_open_loop(open_loop_id=open_loop_id, user_id=user_id) + + assert list_response.status_code == 200 + assert json.loads(list_response.body)["summary"]["status"] == "open" + assert detail_response.status_code == 200 + assert json.loads(detail_response.body)["open_loop"]["id"] == str(open_loop_id) + assert captured["list_status"] == "open" + assert captured["list_limit"] == 10 + assert captured["detail_open_loop_id"] == open_loop_id + + monkeypatch.setattr( + main_module, + "get_open_loop_record", + lambda *_args, **_kwargs: (_ for _ in ()).throw(OpenLoopNotFoundError("open loop hidden")), + ) + not_found_response = main_module.get_open_loop(open_loop_id=open_loop_id, user_id=user_id) + assert not_found_response.status_code == 404 + assert json.loads(not_found_response.body) == {"detail": "open loop hidden"} + + +def test_open_loop_mutation_routes_handle_create_and_status_validation(monkeypatch) -> None: + user_id = uuid4() + open_loop_id = uuid4() + settings = Settings(database_url="postgresql://app") + captured: dict[str, object] = {} + + @contextmanager + def fake_user_connection(database_url: str, current_user_id): + captured["database_url"] = database_url + captured["current_user_id"] = current_user_id + yield object() + + def fake_create_open_loop_record(_store, *, user_id, open_loop): + captured["create_user_id"] = user_id + captured["create_open_loop"] = open_loop + return { + "open_loop": { + "id": str(open_loop_id), + "memory_id": None, + "title": open_loop.title, + "status": "open", + "opened_at": "2026-03-23T09:00:00+00:00", + "due_at": None, + "resolved_at": None, + "resolution_note": None, + "created_at": "2026-03-23T09:00:00+00:00", + "updated_at": "2026-03-23T09:00:00+00:00", + } + } + + def fake_update_open_loop_status_record(_store, *, user_id, open_loop_id, request): + captured["status_user_id"] = user_id + captured["status_open_loop_id"] = open_loop_id + captured["status_request"] = request + return { + "open_loop": { + "id": str(open_loop_id), + "memory_id": None, + "title": "Follow up", + "status": "resolved", + "opened_at": "2026-03-23T09:00:00+00:00", + "due_at": None, + "resolved_at": "2026-03-24T09:00:00+00:00", + "resolution_note": "Resolved", + "created_at": "2026-03-23T09:00:00+00:00", + "updated_at": "2026-03-24T09:00:00+00:00", + } + } + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "create_open_loop_record", fake_create_open_loop_record) + monkeypatch.setattr(main_module, "update_open_loop_status_record", fake_update_open_loop_status_record) + + create_response = main_module.create_open_loop( + main_module.CreateOpenLoopRequest( + user_id=user_id, + title="Follow up", + ) + ) + status_response = main_module.update_open_loop_status( + open_loop_id=open_loop_id, + request=main_module.UpdateOpenLoopStatusRequest( + user_id=user_id, + status="resolved", + resolution_note="Resolved", + ), + ) + + assert create_response.status_code == 201 + assert json.loads(create_response.body)["open_loop"]["title"] == "Follow up" + assert status_response.status_code == 200 + assert json.loads(status_response.body)["open_loop"]["status"] == "resolved" + assert captured["status_request"].status == "resolved" + + monkeypatch.setattr( + main_module, + "update_open_loop_status_record", + lambda *_args, **_kwargs: (_ for _ in ()).throw(OpenLoopValidationError("status invalid")), + ) + bad_status_response = main_module.update_open_loop_status( + open_loop_id=open_loop_id, + request=main_module.UpdateOpenLoopStatusRequest(user_id=user_id, status="invalid"), + ) + assert bad_status_response.status_code == 400 + assert json.loads(bad_status_response.body) == {"detail": "status invalid"} + + +def test_get_memory_returns_not_found_when_memory_is_inaccessible(monkeypatch) -> None: + memory_id = uuid4() + + @contextmanager + def fake_user_connection(_database_url: str, _current_user_id): + yield object() + + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url="postgresql://app")) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr( + main_module, + "get_memory_review_record", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + main_module.MemoryReviewNotFoundError(f"memory {memory_id} was not found") + ), + ) + + response = main_module.get_memory(memory_id=memory_id, user_id=uuid4()) + + assert response.status_code == 404 + assert json.loads(response.body) == { + "detail": f"memory {memory_id} was not found", + } + + +def test_list_memory_review_queue_returns_unlabeled_active_queue_payload(monkeypatch) -> None: + user_id = uuid4() + settings = Settings(database_url="postgresql://app") + captured: dict[str, object] = {} + + @contextmanager + def fake_user_connection(database_url: str, current_user_id): + captured["database_url"] = database_url + captured["current_user_id"] = current_user_id + yield object() + + def fake_list_memory_review_queue_records(store, *, user_id, limit, priority_mode): + captured["store_type"] = type(store).__name__ + captured["user_id"] = user_id + captured["limit"] = limit + captured["priority_mode"] = priority_mode + return { + "items": [ + { + "id": "memory-123", + "memory_key": "user.preference.coffee", + "value": {"likes": "oat milk"}, + "status": "active", + "source_event_ids": ["event-1"], + "is_high_risk": True, + "is_stale_truth": False, + "queue_priority_mode": "high_risk_first", + "priority_reason": "high_risk", + "created_at": "2026-03-12T09:00:00+00:00", + "updated_at": "2026-03-12T09:02:00+00:00", + } + ], + "summary": { + "memory_status": "active", + "review_state": "unlabeled", + "priority_mode": "high_risk_first", + "available_priority_modes": [ + "oldest_first", + "recent_first", + "high_risk_first", + "stale_truth_first", + ], + "limit": 7, + "returned_count": 1, + "total_count": 1, + "has_more": False, + "order": [ + "is_high_risk_desc", + "confidence_asc_nulls_first", + "updated_at_desc", + "created_at_desc", + "id_desc", + ], + }, + } + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "list_memory_review_queue_records", fake_list_memory_review_queue_records) + + response = main_module.list_memory_review_queue( + user_id=user_id, + limit=7, + priority_mode="high_risk_first", + ) + + assert response.status_code == 200 + assert json.loads(response.body) == { + "items": [ + { + "id": "memory-123", + "memory_key": "user.preference.coffee", + "value": {"likes": "oat milk"}, + "status": "active", + "source_event_ids": ["event-1"], + "is_high_risk": True, + "is_stale_truth": False, + "queue_priority_mode": "high_risk_first", + "priority_reason": "high_risk", + "created_at": "2026-03-12T09:00:00+00:00", + "updated_at": "2026-03-12T09:02:00+00:00", + } + ], + "summary": { + "memory_status": "active", + "review_state": "unlabeled", + "priority_mode": "high_risk_first", + "available_priority_modes": [ + "oldest_first", + "recent_first", + "high_risk_first", + "stale_truth_first", + ], + "limit": 7, + "returned_count": 1, + "total_count": 1, + "has_more": False, + "order": [ + "is_high_risk_desc", + "confidence_asc_nulls_first", + "updated_at_desc", + "created_at_desc", + "id_desc", + ], + }, + } + assert captured["database_url"] == "postgresql://app" + assert captured["current_user_id"] == user_id + assert captured["user_id"] == user_id + assert captured["limit"] == 7 + assert captured["priority_mode"] == "high_risk_first" + + +def test_get_memories_evaluation_summary_returns_aggregate_payload(monkeypatch) -> None: + user_id = uuid4() + settings = Settings(database_url="postgresql://app") + captured: dict[str, object] = {} + + @contextmanager + def fake_user_connection(database_url: str, current_user_id): + captured["database_url"] = database_url + captured["current_user_id"] = current_user_id + yield object() + + def fake_get_memory_evaluation_summary(store, *, user_id): + captured["store_type"] = type(store).__name__ + captured["user_id"] = user_id + return { + "summary": { + "total_memory_count": 4, + "active_memory_count": 3, + "deleted_memory_count": 1, + "labeled_memory_count": 2, + "unlabeled_memory_count": 2, + "total_label_row_count": 3, + "label_row_counts_by_value": { + "correct": 1, + "incorrect": 0, + "outdated": 1, + "insufficient_evidence": 1, + }, + "label_value_order": [ + "correct", + "incorrect", + "outdated", + "insufficient_evidence", + ], + } + } + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "get_memory_evaluation_summary", fake_get_memory_evaluation_summary) + + response = main_module.get_memories_evaluation_summary(user_id=user_id) + + assert response.status_code == 200 + assert json.loads(response.body) == { + "summary": { + "total_memory_count": 4, + "active_memory_count": 3, + "deleted_memory_count": 1, + "labeled_memory_count": 2, + "unlabeled_memory_count": 2, + "total_label_row_count": 3, + "label_row_counts_by_value": { + "correct": 1, + "incorrect": 0, + "outdated": 1, + "insufficient_evidence": 1, + }, + "label_value_order": [ + "correct", + "incorrect", + "outdated", + "insufficient_evidence", + ], + } + } + assert captured["database_url"] == "postgresql://app" + assert captured["current_user_id"] == user_id + assert captured["user_id"] == user_id + + +def test_get_memories_quality_gate_returns_canonical_payload(monkeypatch) -> None: + user_id = uuid4() + settings = Settings(database_url="postgresql://app") + captured: dict[str, object] = {} + + @contextmanager + def fake_user_connection(database_url: str, current_user_id): + captured["database_url"] = database_url + captured["current_user_id"] = current_user_id + yield object() + + def fake_get_memory_quality_gate_summary(store, *, user_id): + captured["store_type"] = type(store).__name__ + captured["user_id"] = user_id + return { + "summary": { + "status": "needs_review", + "precision": 0.9, + "precision_target": 0.8, + "adjudicated_sample_count": 10, + "minimum_adjudicated_sample": 10, + "remaining_to_minimum_sample": 0, + "unlabeled_memory_count": 1, + "high_risk_memory_count": 1, + "stale_truth_count": 0, + "superseded_active_conflict_count": 0, + "counts": { + "active_memory_count": 11, + "labeled_active_memory_count": 10, + "adjudicated_correct_count": 9, + "adjudicated_incorrect_count": 1, + "outdated_label_count": 0, + "insufficient_evidence_label_count": 0, + }, + } + } + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "get_memory_quality_gate_summary", fake_get_memory_quality_gate_summary) + + response = main_module.get_memories_quality_gate(user_id=user_id) + + assert response.status_code == 200 + assert json.loads(response.body) == { + "summary": { + "status": "needs_review", + "precision": 0.9, + "precision_target": 0.8, + "adjudicated_sample_count": 10, + "minimum_adjudicated_sample": 10, + "remaining_to_minimum_sample": 0, + "unlabeled_memory_count": 1, + "high_risk_memory_count": 1, + "stale_truth_count": 0, + "superseded_active_conflict_count": 0, + "counts": { + "active_memory_count": 11, + "labeled_active_memory_count": 10, + "adjudicated_correct_count": 9, + "adjudicated_incorrect_count": 1, + "outdated_label_count": 0, + "insufficient_evidence_label_count": 0, + }, + } + } + assert captured["database_url"] == "postgresql://app" + assert captured["current_user_id"] == user_id + assert captured["user_id"] == user_id + + +def test_list_memory_revisions_returns_review_payload(monkeypatch) -> None: + user_id = uuid4() + memory_id = uuid4() + settings = Settings(database_url="postgresql://app") + captured: dict[str, object] = {} + + @contextmanager + def fake_user_connection(database_url: str, current_user_id): + captured["database_url"] = database_url + captured["current_user_id"] = current_user_id + yield object() + + def fake_list_memory_revision_review_records(store, *, user_id, memory_id, limit): + captured["store_type"] = type(store).__name__ + captured["user_id"] = user_id + captured["memory_id"] = memory_id + captured["limit"] = limit + return { + "items": [ + { + "id": "revision-123", + "memory_id": str(memory_id), + "sequence_no": 1, + "action": "ADD", + "memory_key": "user.preference.coffee", + "previous_value": None, + "new_value": {"likes": "black"}, + "source_event_ids": ["event-1"], + "created_at": "2026-03-11T09:00:00+00:00", + } + ], + "summary": { + "memory_id": str(memory_id), + "limit": 5, + "returned_count": 1, + "total_count": 1, + "has_more": False, + "order": ["sequence_no_asc"], + }, + } + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr( + main_module, + "list_memory_revision_review_records", + fake_list_memory_revision_review_records, + ) + + response = main_module.list_memory_revisions(memory_id=memory_id, user_id=user_id, limit=5) + + assert response.status_code == 200 + assert json.loads(response.body) == { + "items": [ + { + "id": "revision-123", + "memory_id": str(memory_id), + "sequence_no": 1, + "action": "ADD", + "memory_key": "user.preference.coffee", + "previous_value": None, + "new_value": {"likes": "black"}, + "source_event_ids": ["event-1"], + "created_at": "2026-03-11T09:00:00+00:00", + } + ], + "summary": { + "memory_id": str(memory_id), + "limit": 5, + "returned_count": 1, + "total_count": 1, + "has_more": False, + "order": ["sequence_no_asc"], + }, + } + assert captured["database_url"] == "postgresql://app" + assert captured["current_user_id"] == user_id + assert captured["user_id"] == user_id + assert captured["memory_id"] == memory_id + assert captured["limit"] == 5 + + +def test_create_memory_review_label_returns_created_payload(monkeypatch) -> None: + memory_id = uuid4() + user_id = uuid4() + settings = Settings(database_url="postgresql://app") + captured: dict[str, object] = {} + + @contextmanager + def fake_user_connection(database_url: str, current_user_id): + captured["database_url"] = database_url + captured["current_user_id"] = current_user_id + yield object() + + def fake_create_memory_review_label_record(store, *, user_id, memory_id, label, note): + captured["store_type"] = type(store).__name__ + captured["user_id"] = user_id + captured["memory_id"] = memory_id + captured["label"] = label + captured["note"] = note + return { + "label": { + "id": "label-123", + "memory_id": str(memory_id), + "reviewer_user_id": str(user_id), + "label": "correct", + "note": "Backed by the latest source.", + "created_at": "2026-03-12T09:00:00+00:00", + }, + "summary": { + "memory_id": str(memory_id), + "total_count": 1, + "counts_by_label": { + "correct": 1, + "incorrect": 0, + "outdated": 0, + "insufficient_evidence": 0, + }, + "order": ["created_at_asc", "id_asc"], + }, + } + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr( + main_module, + "create_memory_review_label_record", + fake_create_memory_review_label_record, + ) + + response = main_module.create_memory_review_label( + memory_id, + main_module.CreateMemoryReviewLabelRequest( + user_id=user_id, + label="correct", + note="Backed by the latest source.", + ), + ) + + assert response.status_code == 201 + assert json.loads(response.body) == { + "label": { + "id": "label-123", + "memory_id": str(memory_id), + "reviewer_user_id": str(user_id), + "label": "correct", + "note": "Backed by the latest source.", + "created_at": "2026-03-12T09:00:00+00:00", + }, + "summary": { + "memory_id": str(memory_id), + "total_count": 1, + "counts_by_label": { + "correct": 1, + "incorrect": 0, + "outdated": 0, + "insufficient_evidence": 0, + }, + "order": ["created_at_asc", "id_asc"], + }, + } + assert captured["database_url"] == "postgresql://app" + assert captured["current_user_id"] == user_id + assert captured["memory_id"] == memory_id + assert captured["label"] == "correct" + assert captured["note"] == "Backed by the latest source." + + +def test_create_memory_review_label_returns_not_found_for_inaccessible_memory(monkeypatch) -> None: + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url="postgresql://app")) + + @contextmanager + def fake_user_connection(_database_url: str, _current_user_id): + yield object() + + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr( + main_module, + "create_memory_review_label_record", + lambda *_args, **_kwargs: (_ for _ in ()).throw(MemoryReviewNotFoundError("memory missing")), + ) + + response = main_module.create_memory_review_label( + uuid4(), + main_module.CreateMemoryReviewLabelRequest(user_id=uuid4(), label="incorrect"), + ) + + assert response.status_code == 404 + assert json.loads(response.body) == {"detail": "memory missing"} + + +def test_list_memory_review_labels_returns_deterministic_items_and_summary(monkeypatch) -> None: + memory_id = uuid4() + user_id = uuid4() + settings = Settings(database_url="postgresql://app") + captured: dict[str, object] = {} + + @contextmanager + def fake_user_connection(database_url: str, current_user_id): + captured["database_url"] = database_url + captured["current_user_id"] = current_user_id + yield object() + + def fake_list_memory_review_label_records(store, *, user_id, memory_id): + captured["store_type"] = type(store).__name__ + captured["user_id"] = user_id + captured["memory_id"] = memory_id + return { + "items": [ + { + "id": "label-123", + "memory_id": str(memory_id), + "reviewer_user_id": str(user_id), + "label": "incorrect", + "note": "Conflicts with the latest event.", + "created_at": "2026-03-12T09:00:00+00:00", + }, + { + "id": "label-124", + "memory_id": str(memory_id), + "reviewer_user_id": str(user_id), + "label": "outdated", + "note": None, + "created_at": "2026-03-12T09:01:00+00:00", + }, + ], + "summary": { + "memory_id": str(memory_id), + "total_count": 2, + "counts_by_label": { + "correct": 0, + "incorrect": 1, + "outdated": 1, + "insufficient_evidence": 0, + }, + "order": ["created_at_asc", "id_asc"], + }, + } + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr( + main_module, + "list_memory_review_label_records", + fake_list_memory_review_label_records, + ) + + response = main_module.list_memory_review_labels(memory_id=memory_id, user_id=user_id) + + assert response.status_code == 200 + assert json.loads(response.body) == { + "items": [ + { + "id": "label-123", + "memory_id": str(memory_id), + "reviewer_user_id": str(user_id), + "label": "incorrect", + "note": "Conflicts with the latest event.", + "created_at": "2026-03-12T09:00:00+00:00", + }, + { + "id": "label-124", + "memory_id": str(memory_id), + "reviewer_user_id": str(user_id), + "label": "outdated", + "note": None, + "created_at": "2026-03-12T09:01:00+00:00", + }, + ], + "summary": { + "memory_id": str(memory_id), + "total_count": 2, + "counts_by_label": { + "correct": 0, + "incorrect": 1, + "outdated": 1, + "insufficient_evidence": 0, + }, + "order": ["created_at_asc", "id_asc"], + }, + } + assert captured["database_url"] == "postgresql://app" + assert captured["current_user_id"] == user_id + assert captured["memory_id"] == memory_id + + +def test_list_memory_review_labels_returns_not_found_for_inaccessible_memory(monkeypatch) -> None: + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url="postgresql://app")) + + @contextmanager + def fake_user_connection(_database_url: str, _current_user_id): + yield object() + + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr( + main_module, + "list_memory_review_label_records", + lambda *_args, **_kwargs: (_ for _ in ()).throw(MemoryReviewNotFoundError("memory hidden")), + ) + + response = main_module.list_memory_review_labels(uuid4(), uuid4()) + + assert response.status_code == 404 + assert json.loads(response.body) == {"detail": "memory hidden"} + + +def test_create_embedding_config_returns_created_payload(monkeypatch) -> None: + user_id = uuid4() + settings = Settings(database_url="postgresql://app") + captured: dict[str, object] = {} + + @contextmanager + def fake_user_connection(database_url: str, current_user_id): + captured["database_url"] = database_url + captured["current_user_id"] = current_user_id + yield object() + + def fake_create_embedding_config_record(store, *, user_id, config): + captured["store_type"] = type(store).__name__ + captured["user_id"] = user_id + captured["config"] = config + return { + "embedding_config": { + "id": "config-123", + "provider": "openai", + "model": "text-embedding-3-large", + "version": "2026-03-12", + "dimensions": 3, + "status": "active", + "metadata": {"task": "memory_retrieval"}, + "created_at": "2026-03-12T10:00:00+00:00", + } + } + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "create_embedding_config_record", fake_create_embedding_config_record) + + response = main_module.create_embedding_config( + main_module.CreateEmbeddingConfigRequest( + user_id=user_id, + provider="openai", + model="text-embedding-3-large", + version="2026-03-12", + dimensions=3, + status="active", + metadata={"task": "memory_retrieval"}, + ) + ) + + assert response.status_code == 201 + assert json.loads(response.body) == { + "embedding_config": { + "id": "config-123", + "provider": "openai", + "model": "text-embedding-3-large", + "version": "2026-03-12", + "dimensions": 3, + "status": "active", + "metadata": {"task": "memory_retrieval"}, + "created_at": "2026-03-12T10:00:00+00:00", + } + } + assert captured["database_url"] == "postgresql://app" + assert captured["current_user_id"] == user_id + assert captured["user_id"] == user_id + assert captured["config"].provider == "openai" + + +def test_create_embedding_config_returns_bad_request_for_validation_failure(monkeypatch) -> None: + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url="postgresql://app")) + + @contextmanager + def fake_user_connection(_database_url: str, _current_user_id): + yield object() + + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr( + main_module, + "create_embedding_config_record", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + EmbeddingConfigValidationError( + "embedding config already exists for provider/model/version under the user scope: " + "openai/text-embedding-3-large/2026-03-12" + ) + ), + ) + + response = main_module.create_embedding_config( + main_module.CreateEmbeddingConfigRequest( + user_id=uuid4(), + provider="openai", + model="text-embedding-3-large", + version="2026-03-12", + dimensions=3, + status="active", + metadata={"task": "memory_retrieval"}, + ) + ) + + assert response.status_code == 400 + assert json.loads(response.body) == { + "detail": ( + "embedding config already exists for provider/model/version under the user scope: " + "openai/text-embedding-3-large/2026-03-12" + ) + } + + +def test_upsert_memory_embedding_routes_success_and_validation_errors(monkeypatch) -> None: + user_id = uuid4() + memory_id = uuid4() + config_id = uuid4() + settings = Settings(database_url="postgresql://app") + captured: dict[str, object] = {} + + @contextmanager + def fake_user_connection(database_url: str, current_user_id): + captured["database_url"] = database_url + captured["current_user_id"] = current_user_id + yield object() + + def fake_upsert_memory_embedding_record(store, *, user_id, request): + captured["store_type"] = type(store).__name__ + captured["user_id"] = user_id + captured["request"] = request + return { + "embedding": { + "id": "embedding-123", + "memory_id": str(memory_id), + "embedding_config_id": str(config_id), + "dimensions": 3, + "vector": [0.1, 0.2, 0.3], + "created_at": "2026-03-12T10:00:00+00:00", + "updated_at": "2026-03-12T10:00:00+00:00", + }, + "write_mode": "created", + } + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "upsert_memory_embedding_record", fake_upsert_memory_embedding_record) + + response = main_module.upsert_memory_embedding( + main_module.UpsertMemoryEmbeddingRequest( + user_id=user_id, + memory_id=memory_id, + embedding_config_id=config_id, + vector=[0.1, 0.2, 0.3], + ) + ) + + assert response.status_code == 201 + assert json.loads(response.body)["write_mode"] == "created" + assert captured["database_url"] == "postgresql://app" + assert captured["current_user_id"] == user_id + assert captured["user_id"] == user_id + assert captured["request"].memory_id == memory_id + + monkeypatch.setattr( + main_module, + "upsert_memory_embedding_record", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + MemoryEmbeddingValidationError( + "embedding_config_id must reference an existing embedding config owned by the user" + ) + ), + ) + + error_response = main_module.upsert_memory_embedding( + main_module.UpsertMemoryEmbeddingRequest( + user_id=user_id, + memory_id=memory_id, + embedding_config_id=config_id, + vector=[0.1, 0.2, 0.3], + ) + ) + + assert error_response.status_code == 400 + assert json.loads(error_response.body) == { + "detail": "embedding_config_id must reference an existing embedding config owned by the user" + } + + +def test_retrieve_semantic_memories_routes_success_and_validation_errors(monkeypatch) -> None: + user_id = uuid4() + config_id = uuid4() + settings = Settings(database_url="postgresql://app") + captured: dict[str, object] = {} + + @contextmanager + def fake_user_connection(database_url: str, current_user_id): + captured["database_url"] = database_url + captured["current_user_id"] = current_user_id + yield object() + + def fake_retrieve_semantic_memory_records(store, *, user_id, request): + captured["store_type"] = type(store).__name__ + captured["user_id"] = user_id + captured["request"] = request + return { + "items": [ + { + "memory_id": "memory-123", + "memory_key": "user.preference.coffee", + "value": {"likes": "oat milk"}, + "source_event_ids": ["event-123"], + "created_at": "2026-03-12T10:00:00+00:00", + "updated_at": "2026-03-12T10:00:00+00:00", + "score": 0.99, + } + ], + "summary": { + "embedding_config_id": str(config_id), + "limit": 5, + "returned_count": 1, + "similarity_metric": "cosine_similarity", + "order": ["score_desc", "created_at_asc", "id_asc"], + }, + } + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr( + main_module, + "retrieve_semantic_memory_records", + fake_retrieve_semantic_memory_records, + ) + + response = main_module.retrieve_semantic_memories( + main_module.RetrieveSemanticMemoriesRequest( + user_id=user_id, + embedding_config_id=config_id, + query_vector=[0.1, 0.2, 0.3], + limit=5, + ) + ) + + assert response.status_code == 200 + assert json.loads(response.body)["summary"] == { + "embedding_config_id": str(config_id), + "limit": 5, + "returned_count": 1, + "similarity_metric": "cosine_similarity", + "order": ["score_desc", "created_at_asc", "id_asc"], + } + assert captured["database_url"] == "postgresql://app" + assert captured["current_user_id"] == user_id + assert captured["user_id"] == user_id + assert captured["request"].embedding_config_id == config_id + assert captured["request"].query_vector == (0.1, 0.2, 0.3) + + monkeypatch.setattr( + main_module, + "retrieve_semantic_memory_records", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + SemanticMemoryRetrievalValidationError( + "embedding_config_id must reference an existing embedding config owned by the user" + ) + ), + ) + + error_response = main_module.retrieve_semantic_memories( + main_module.RetrieveSemanticMemoriesRequest( + user_id=user_id, + embedding_config_id=config_id, + query_vector=[0.1, 0.2, 0.3], + limit=5, + ) + ) + + assert error_response.status_code == 400 + assert json.loads(error_response.body) == { + "detail": "embedding_config_id must reference an existing embedding config owned by the user" + } + + +def test_memory_embedding_read_routes_return_payload_and_not_found(monkeypatch) -> None: + user_id = uuid4() + memory_id = uuid4() + embedding_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(_database_url: str, _current_user_id): + yield object() + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr( + main_module, + "list_memory_embedding_records", + lambda *_args, **_kwargs: { + "items": [ + { + "id": str(embedding_id), + "memory_id": str(memory_id), + "embedding_config_id": "config-123", + "dimensions": 3, + "vector": [0.1, 0.2, 0.3], + "created_at": "2026-03-12T10:00:00+00:00", + "updated_at": "2026-03-12T10:00:00+00:00", + } + ], + "summary": { + "memory_id": str(memory_id), + "total_count": 1, + "order": ["created_at_asc", "id_asc"], + }, + }, + ) + monkeypatch.setattr( + main_module, + "get_memory_embedding_record", + lambda *_args, **_kwargs: { + "embedding": { + "id": str(embedding_id), + "memory_id": str(memory_id), + "embedding_config_id": "config-123", + "dimensions": 3, + "vector": [0.1, 0.2, 0.3], + "created_at": "2026-03-12T10:00:00+00:00", + "updated_at": "2026-03-12T10:00:00+00:00", + } + }, + ) + + list_response = main_module.list_memory_embeddings(memory_id=memory_id, user_id=user_id) + detail_response = main_module.get_memory_embedding(memory_embedding_id=embedding_id, user_id=user_id) + + assert list_response.status_code == 200 + assert json.loads(list_response.body)["summary"]["memory_id"] == str(memory_id) + assert detail_response.status_code == 200 + assert json.loads(detail_response.body)["embedding"]["id"] == str(embedding_id) + + monkeypatch.setattr( + main_module, + "get_memory_embedding_record", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + MemoryEmbeddingNotFoundError(f"memory embedding {embedding_id} was not found") + ), + ) + + not_found_response = main_module.get_memory_embedding( + memory_embedding_id=embedding_id, + user_id=user_id, + ) + + assert not_found_response.status_code == 404 + assert json.loads(not_found_response.body) == { + "detail": f"memory embedding {embedding_id} was not found" + } + + +def test_task_artifact_chunk_embedding_routes_success_and_validation_errors(monkeypatch) -> None: + user_id = uuid4() + chunk_id = uuid4() + config_id = uuid4() + settings = Settings(database_url="postgresql://app") + captured: dict[str, object] = {} + + @contextmanager + def fake_user_connection(database_url: str, current_user_id): + captured["database_url"] = database_url + captured["current_user_id"] = current_user_id + yield object() + + def fake_upsert_task_artifact_chunk_embedding_record(store, *, user_id, request): + captured["store_type"] = type(store).__name__ + captured["user_id"] = user_id + captured["request"] = request + return { + "embedding": { + "id": "artifact-embedding-123", + "task_artifact_id": "artifact-123", + "task_artifact_chunk_id": str(chunk_id), + "task_artifact_chunk_sequence_no": 2, + "embedding_config_id": str(config_id), + "dimensions": 3, + "vector": [0.1, 0.2, 0.3], + "created_at": "2026-03-14T12:00:00+00:00", + "updated_at": "2026-03-14T12:00:00+00:00", + }, + "write_mode": "created", + } + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr( + main_module, + "upsert_task_artifact_chunk_embedding_record", + fake_upsert_task_artifact_chunk_embedding_record, + ) + + response = main_module.upsert_task_artifact_chunk_embedding( + main_module.UpsertTaskArtifactChunkEmbeddingRequest( + user_id=user_id, + task_artifact_chunk_id=chunk_id, + embedding_config_id=config_id, + vector=[0.1, 0.2, 0.3], + ) + ) + + assert response.status_code == 201 + assert json.loads(response.body)["write_mode"] == "created" + assert captured["database_url"] == "postgresql://app" + assert captured["current_user_id"] == user_id + assert captured["user_id"] == user_id + assert captured["request"].task_artifact_chunk_id == chunk_id + + monkeypatch.setattr( + main_module, + "upsert_task_artifact_chunk_embedding_record", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + TaskArtifactChunkEmbeddingValidationError( + "task_artifact_chunk_id must reference an existing task artifact chunk owned by the user" + ) + ), + ) + + error_response = main_module.upsert_task_artifact_chunk_embedding( + main_module.UpsertTaskArtifactChunkEmbeddingRequest( + user_id=user_id, + task_artifact_chunk_id=chunk_id, + embedding_config_id=config_id, + vector=[0.1, 0.2, 0.3], + ) + ) + + assert error_response.status_code == 400 + assert json.loads(error_response.body) == { + "detail": "task_artifact_chunk_id must reference an existing task artifact chunk owned by the user" + } + + +def test_task_artifact_chunk_embedding_read_routes_return_payload_and_not_found(monkeypatch) -> None: + user_id = uuid4() + artifact_id = uuid4() + chunk_id = uuid4() + embedding_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(_database_url: str, _current_user_id): + yield object() + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr( + main_module, + "list_task_artifact_chunk_embedding_records_for_artifact", + lambda *_args, **_kwargs: { + "items": [], + "summary": { + "total_count": 0, + "order": ["task_artifact_chunk_sequence_no_asc", "created_at_asc", "id_asc"], + "scope": { + "kind": "artifact", + "task_artifact_id": str(artifact_id), + }, + }, + }, + ) + monkeypatch.setattr( + main_module, + "list_task_artifact_chunk_embedding_records_for_chunk", + lambda *_args, **_kwargs: { + "items": [], + "summary": { + "total_count": 0, + "order": ["task_artifact_chunk_sequence_no_asc", "created_at_asc", "id_asc"], + "scope": { + "kind": "chunk", + "task_artifact_id": str(artifact_id), + "task_artifact_chunk_id": str(chunk_id), + }, + }, + }, + ) + monkeypatch.setattr( + main_module, + "get_task_artifact_chunk_embedding_record", + lambda *_args, **_kwargs: { + "embedding": { + "id": str(embedding_id), + "task_artifact_id": str(artifact_id), + "task_artifact_chunk_id": str(chunk_id), + "task_artifact_chunk_sequence_no": 2, + "embedding_config_id": "config-123", + "dimensions": 3, + "vector": [0.1, 0.2, 0.3], + "created_at": "2026-03-14T12:00:00+00:00", + "updated_at": "2026-03-14T12:00:00+00:00", + } + }, + ) + + artifact_response = main_module.list_task_artifact_chunk_embeddings_for_artifact( + task_artifact_id=artifact_id, + user_id=user_id, + ) + chunk_response = main_module.list_task_artifact_chunk_embeddings( + task_artifact_chunk_id=chunk_id, + user_id=user_id, + ) + detail_response = main_module.get_task_artifact_chunk_embedding( + task_artifact_chunk_embedding_id=embedding_id, + user_id=user_id, + ) + + assert artifact_response.status_code == 200 + assert json.loads(artifact_response.body)["summary"]["scope"]["task_artifact_id"] == str( + artifact_id + ) + assert chunk_response.status_code == 200 + assert json.loads(chunk_response.body)["summary"]["scope"]["task_artifact_chunk_id"] == str( + chunk_id + ) + assert detail_response.status_code == 200 + assert json.loads(detail_response.body)["embedding"]["id"] == str(embedding_id) + + monkeypatch.setattr( + main_module, + "list_task_artifact_chunk_embedding_records_for_artifact", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + TaskArtifactNotFoundError(f"task artifact {artifact_id} was not found") + ), + ) + monkeypatch.setattr( + main_module, + "list_task_artifact_chunk_embedding_records_for_chunk", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + TaskArtifactChunkEmbeddingNotFoundError( + f"task artifact chunk {chunk_id} was not found" + ) + ), + ) + monkeypatch.setattr( + main_module, + "get_task_artifact_chunk_embedding_record", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + TaskArtifactChunkEmbeddingNotFoundError( + f"task artifact chunk embedding {embedding_id} was not found" + ) + ), + ) + + missing_artifact_response = main_module.list_task_artifact_chunk_embeddings_for_artifact( + task_artifact_id=artifact_id, + user_id=user_id, + ) + missing_chunk_response = main_module.list_task_artifact_chunk_embeddings( + task_artifact_chunk_id=chunk_id, + user_id=user_id, + ) + missing_detail_response = main_module.get_task_artifact_chunk_embedding( + task_artifact_chunk_embedding_id=embedding_id, + user_id=user_id, + ) + + assert missing_artifact_response.status_code == 404 + assert json.loads(missing_artifact_response.body) == { + "detail": f"task artifact {artifact_id} was not found" + } + assert missing_chunk_response.status_code == 404 + assert json.loads(missing_chunk_response.body) == { + "detail": f"task artifact chunk {chunk_id} was not found" + } + assert missing_detail_response.status_code == 404 + assert json.loads(missing_detail_response.body) == { + "detail": f"task artifact chunk embedding {embedding_id} was not found" + } + + +def test_create_entity_returns_created_payload(monkeypatch) -> None: + user_id = uuid4() + first_memory_id = uuid4() + second_memory_id = uuid4() + settings = Settings(database_url="postgresql://app") + captured: dict[str, object] = {} + + @contextmanager + def fake_user_connection(database_url: str, current_user_id): + captured["database_url"] = database_url + captured["current_user_id"] = current_user_id + yield object() + + def fake_create_entity_record(store, *, user_id, entity): + captured["store_type"] = type(store).__name__ + captured["user_id"] = user_id + captured["entity"] = entity + return { + "entity": { + "id": "entity-123", + "entity_type": "project", + "name": "AliceBot", + "source_memory_ids": [str(first_memory_id), str(second_memory_id)], + "created_at": "2026-03-12T10:00:00+00:00", + } + } + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "create_entity_record", fake_create_entity_record) + + response = main_module.create_entity( + main_module.CreateEntityRequest( + user_id=user_id, + entity_type="project", + name="AliceBot", + source_memory_ids=[first_memory_id, second_memory_id], + ) + ) + + assert response.status_code == 201 + assert json.loads(response.body) == { + "entity": { + "id": "entity-123", + "entity_type": "project", + "name": "AliceBot", + "source_memory_ids": [str(first_memory_id), str(second_memory_id)], + "created_at": "2026-03-12T10:00:00+00:00", + } + } + assert captured["database_url"] == "postgresql://app" + assert captured["current_user_id"] == user_id + assert captured["user_id"] == user_id + assert captured["entity"].entity_type == "project" + assert captured["entity"].name == "AliceBot" + + +def test_create_entity_returns_bad_request_when_source_memory_validation_fails(monkeypatch) -> None: + @contextmanager + def fake_user_connection(_database_url: str, _current_user_id): + yield object() + + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url="postgresql://app")) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr( + main_module, + "create_entity_record", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + EntityValidationError("source_memory_ids must all reference existing memories owned by the user") + ), + ) + + response = main_module.create_entity( + main_module.CreateEntityRequest( + user_id=uuid4(), + entity_type="person", + name="Alex", + source_memory_ids=[uuid4()], + ) + ) + + assert response.status_code == 400 + assert json.loads(response.body) == { + "detail": "source_memory_ids must all reference existing memories owned by the user", + } + + +def test_create_entity_edge_returns_created_payload(monkeypatch) -> None: + user_id = uuid4() + from_entity_id = uuid4() + to_entity_id = uuid4() + source_memory_id = uuid4() + settings = Settings(database_url="postgresql://app") + captured: dict[str, object] = {} + + @contextmanager + def fake_user_connection(database_url: str, current_user_id): + captured["database_url"] = database_url + captured["current_user_id"] = current_user_id + yield object() + + def fake_create_entity_edge_record(store, *, user_id, edge): + captured["store_type"] = type(store).__name__ + captured["user_id"] = user_id + captured["edge"] = edge + return { + "edge": { + "id": "edge-123", + "from_entity_id": str(from_entity_id), + "to_entity_id": str(to_entity_id), + "relationship_type": "works_on", + "valid_from": "2026-03-12T10:00:00+00:00", + "valid_to": None, + "source_memory_ids": [str(source_memory_id)], + "created_at": "2026-03-12T10:01:00+00:00", + } + } + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "create_entity_edge_record", fake_create_entity_edge_record) + + response = main_module.create_entity_edge( + main_module.CreateEntityEdgeRequest( + user_id=user_id, + from_entity_id=from_entity_id, + to_entity_id=to_entity_id, + relationship_type="works_on", + valid_from="2026-03-12T10:00:00+00:00", + source_memory_ids=[source_memory_id], + ) + ) + + assert response.status_code == 201 + assert json.loads(response.body) == { + "edge": { + "id": "edge-123", + "from_entity_id": str(from_entity_id), + "to_entity_id": str(to_entity_id), + "relationship_type": "works_on", + "valid_from": "2026-03-12T10:00:00+00:00", + "valid_to": None, + "source_memory_ids": [str(source_memory_id)], + "created_at": "2026-03-12T10:01:00+00:00", + } + } + assert captured["database_url"] == "postgresql://app" + assert captured["current_user_id"] == user_id + assert captured["user_id"] == user_id + assert captured["edge"].from_entity_id == from_entity_id + assert captured["edge"].to_entity_id == to_entity_id + + +def test_create_entity_edge_returns_bad_request_for_validation_failure(monkeypatch) -> None: + @contextmanager + def fake_user_connection(_database_url: str, _current_user_id): + yield object() + + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url="postgresql://app")) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr( + main_module, + "create_entity_edge_record", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + EntityEdgeValidationError("valid_to must be greater than or equal to valid_from") + ), + ) + + response = main_module.create_entity_edge( + main_module.CreateEntityEdgeRequest( + user_id=uuid4(), + from_entity_id=uuid4(), + to_entity_id=uuid4(), + relationship_type="works_on", + valid_from="2026-03-12T11:00:00+00:00", + valid_to="2026-03-12T10:00:00+00:00", + source_memory_ids=[uuid4()], + ) + ) + + assert response.status_code == 400 + assert json.loads(response.body) == { + "detail": "valid_to must be greater than or equal to valid_from", + } + + +def test_list_entities_returns_deterministic_payload(monkeypatch) -> None: + user_id = uuid4() + settings = Settings(database_url="postgresql://app") + captured: dict[str, object] = {} + + @contextmanager + def fake_user_connection(database_url: str, current_user_id): + captured["database_url"] = database_url + captured["current_user_id"] = current_user_id + yield object() + + def fake_list_entity_records(store, *, user_id): + captured["store_type"] = type(store).__name__ + captured["user_id"] = user_id + return { + "items": [ + { + "id": "entity-123", + "entity_type": "project", + "name": "AliceBot", + "source_memory_ids": ["memory-1"], + "created_at": "2026-03-12T10:00:00+00:00", + } + ], + "summary": { + "total_count": 1, + "order": ["created_at_asc", "id_asc"], + }, + } + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "list_entity_records", fake_list_entity_records) + + response = main_module.list_entities(user_id=user_id) + + assert response.status_code == 200 + assert json.loads(response.body) == { + "items": [ + { + "id": "entity-123", + "entity_type": "project", + "name": "AliceBot", + "source_memory_ids": ["memory-1"], + "created_at": "2026-03-12T10:00:00+00:00", + } + ], + "summary": { + "total_count": 1, + "order": ["created_at_asc", "id_asc"], + }, + } + assert captured["database_url"] == "postgresql://app" + assert captured["current_user_id"] == user_id + assert captured["user_id"] == user_id + + +def test_list_entity_edges_returns_deterministic_payload(monkeypatch) -> None: + user_id = uuid4() + entity_id = uuid4() + settings = Settings(database_url="postgresql://app") + captured: dict[str, object] = {} + + @contextmanager + def fake_user_connection(database_url: str, current_user_id): + captured["database_url"] = database_url + captured["current_user_id"] = current_user_id + yield object() + + def fake_list_entity_edge_records(store, *, user_id, entity_id): + captured["store_type"] = type(store).__name__ + captured["user_id"] = user_id + captured["entity_id"] = entity_id + return { + "items": [ + { + "id": "edge-123", + "from_entity_id": str(entity_id), + "to_entity_id": "entity-456", + "relationship_type": "works_on", + "valid_from": None, + "valid_to": None, + "source_memory_ids": ["memory-1"], + "created_at": "2026-03-12T10:00:00+00:00", + } + ], + "summary": { + "entity_id": str(entity_id), + "total_count": 1, + "order": ["created_at_asc", "id_asc"], + }, + } + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "list_entity_edge_records", fake_list_entity_edge_records) + + response = main_module.list_entity_edges(entity_id=entity_id, user_id=user_id) + + assert response.status_code == 200 + assert json.loads(response.body) == { + "items": [ + { + "id": "edge-123", + "from_entity_id": str(entity_id), + "to_entity_id": "entity-456", + "relationship_type": "works_on", + "valid_from": None, + "valid_to": None, + "source_memory_ids": ["memory-1"], + "created_at": "2026-03-12T10:00:00+00:00", + } + ], + "summary": { + "entity_id": str(entity_id), + "total_count": 1, + "order": ["created_at_asc", "id_asc"], + }, + } + assert captured["database_url"] == "postgresql://app" + assert captured["current_user_id"] == user_id + assert captured["user_id"] == user_id + assert captured["entity_id"] == entity_id + + +def test_list_entity_edges_returns_not_found_for_inaccessible_entity(monkeypatch) -> None: + entity_id = uuid4() + + @contextmanager + def fake_user_connection(_database_url: str, _current_user_id): + yield object() + + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url="postgresql://app")) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr( + main_module, + "list_entity_edge_records", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + EntityNotFoundError(f"entity {entity_id} was not found") + ), + ) + + response = main_module.list_entity_edges(entity_id=entity_id, user_id=uuid4()) + + assert response.status_code == 404 + assert json.loads(response.body) == { + "detail": f"entity {entity_id} was not found", + } + + +def test_get_entity_returns_detail_payload(monkeypatch) -> None: + user_id = uuid4() + entity_id = uuid4() + settings = Settings(database_url="postgresql://app") + captured: dict[str, object] = {} + + @contextmanager + def fake_user_connection(database_url: str, current_user_id): + captured["database_url"] = database_url + captured["current_user_id"] = current_user_id + yield object() + + def fake_get_entity_record(store, *, user_id, entity_id): + captured["store_type"] = type(store).__name__ + captured["user_id"] = user_id + captured["entity_id"] = entity_id + return { + "entity": { + "id": str(entity_id), + "entity_type": "person", + "name": "Alex", + "source_memory_ids": ["memory-1"], + "created_at": "2026-03-12T10:00:00+00:00", + } + } + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "get_entity_record", fake_get_entity_record) + + response = main_module.get_entity(entity_id=entity_id, user_id=user_id) + + assert response.status_code == 200 + assert json.loads(response.body) == { + "entity": { + "id": str(entity_id), + "entity_type": "person", + "name": "Alex", + "source_memory_ids": ["memory-1"], + "created_at": "2026-03-12T10:00:00+00:00", + } + } + assert captured["database_url"] == "postgresql://app" + assert captured["current_user_id"] == user_id + assert captured["user_id"] == user_id + assert captured["entity_id"] == entity_id + + +def test_get_entity_returns_not_found_for_inaccessible_entity(monkeypatch) -> None: + entity_id = uuid4() + + @contextmanager + def fake_user_connection(_database_url: str, _current_user_id): + yield object() + + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url="postgresql://app")) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr( + main_module, + "get_entity_record", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + EntityNotFoundError(f"entity {entity_id} was not found") + ), + ) + + response = main_module.get_entity(entity_id=entity_id, user_id=uuid4()) + + assert response.status_code == 404 + assert json.loads(response.body) == { + "detail": f"entity {entity_id} was not found", + } diff --git a/tests/unit/test_mcp.py b/tests/unit/test_mcp.py new file mode 100644 index 0000000..fc7807f --- /dev/null +++ b/tests/unit/test_mcp.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +from io import BytesIO +from uuid import UUID + +import pytest + +import alicebot_api.mcp_server as mcp_server +from alicebot_api.mcp_tools import MCPRuntimeContext, MCPToolError, MCPToolNotFoundError, call_mcp_tool, list_mcp_tools + + +def test_mcp_tool_surface_is_adr_aligned_and_deterministic() -> None: + tools = list_mcp_tools() + names = [tool["name"] for tool in tools] + assert names == [ + "alice_capture", + "alice_recall", + "alice_resume", + "alice_open_loops", + "alice_recent_decisions", + "alice_recent_changes", + "alice_memory_review", + "alice_memory_correct", + "alice_context_pack", + ] + + for tool in tools: + assert isinstance(tool["inputSchema"], dict) + assert tool["inputSchema"].get("type") == "object" + assert tool["inputSchema"].get("additionalProperties") is False + + +def test_call_mcp_tool_rejects_unknown_tool() -> None: + context = MCPRuntimeContext( + database_url="postgresql://localhost/alicebot", + user_id=UUID("11111111-1111-4111-8111-111111111111"), + ) + with pytest.raises(MCPToolNotFoundError, match="unknown tool"): + call_mcp_tool(context, name="alice_nonexistent", arguments={}) + + +def test_call_mcp_tool_requires_object_arguments() -> None: + context = MCPRuntimeContext( + database_url="postgresql://localhost/alicebot", + user_id=UUID("11111111-1111-4111-8111-111111111111"), + ) + with pytest.raises(MCPToolError, match="tool arguments must be a JSON object"): + call_mcp_tool(context, name="alice_recall", arguments=["not-a-json-object"]) + + +def test_mcp_server_initialize_and_tools_list(monkeypatch) -> None: + context = MCPRuntimeContext( + database_url="postgresql://localhost/alicebot", + user_id=UUID("11111111-1111-4111-8111-111111111111"), + ) + server = mcp_server.MCPServer(context=context, input_stream=BytesIO(), output_stream=BytesIO()) + + initialize_response = server._handle_request( + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {}, + } + ) + assert initialize_response is not None + assert initialize_response["result"]["protocolVersion"] == "2024-11-05" + assert initialize_response["result"]["serverInfo"]["name"] == "alice-core-mcp" + + monkeypatch.setattr( + mcp_server, + "list_mcp_tools", + lambda: [{"name": "alice_recall", "description": "Recall", "inputSchema": {"type": "object"}}], + ) + list_response = server._handle_request( + { + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list", + "params": {}, + } + ) + assert list_response is not None + assert list_response["result"]["tools"] == [ + {"name": "alice_recall", "description": "Recall", "inputSchema": {"type": "object"}} + ] + + +def test_mcp_server_tools_call_success_and_error_paths(monkeypatch) -> None: + context = MCPRuntimeContext( + database_url="postgresql://localhost/alicebot", + user_id=UUID("11111111-1111-4111-8111-111111111111"), + ) + server = mcp_server.MCPServer(context=context, input_stream=BytesIO(), output_stream=BytesIO()) + + monkeypatch.setattr(mcp_server, "call_mcp_tool", lambda *_args, **_kwargs: {"ok": True}) + success_response = server._handle_request( + { + "jsonrpc": "2.0", + "id": 7, + "method": "tools/call", + "params": {"name": "alice_recall", "arguments": {}}, + } + ) + assert success_response is not None + assert success_response["result"]["isError"] is False + assert success_response["result"]["structuredContent"] == {"ok": True} + + def raise_tool_error(*_args, **_kwargs): + raise MCPToolError("invalid input") + + monkeypatch.setattr(mcp_server, "call_mcp_tool", raise_tool_error) + error_response = server._handle_request( + { + "jsonrpc": "2.0", + "id": 8, + "method": "tools/call", + "params": {"name": "alice_recall", "arguments": {}}, + } + ) + assert error_response is not None + assert error_response["result"]["isError"] is True + assert error_response["result"]["content"][0]["text"] == "invalid input" diff --git a/tests/unit/test_memory.py b/tests/unit/test_memory.py new file mode 100644 index 0000000..f3f32bf --- /dev/null +++ b/tests/unit/test_memory.py @@ -0,0 +1,1792 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from uuid import UUID, uuid4 + +import psycopg +import pytest + +import alicebot_api.memory as memory_module +from alicebot_api.contracts import ( + MemoryCandidateInput, + OpenLoopCandidateInput, + OpenLoopCreateInput, + OpenLoopStatusUpdateInput, +) +from alicebot_api.memory import ( + MemoryAdmissionValidationError, + MemoryReviewNotFoundError, + OpenLoopNotFoundError, + OpenLoopValidationError, + admit_memory_candidate, + create_open_loop_record, + create_memory_review_label_record, + get_open_loop_record, + get_memory_evaluation_summary, + get_memory_quality_gate_summary, + get_memory_trust_dashboard_summary, + get_memory_review_record, + list_open_loop_records, + list_memory_review_queue_records, + list_memory_review_label_records, + list_memory_review_records, + list_memory_revision_review_records, + update_open_loop_status_record, +) + + +class MemoryStoreStub: + def __init__(self) -> None: + self.base_time = datetime(2026, 3, 11, 12, 0, tzinfo=UTC) + self.events: dict[UUID, dict[str, object]] = {} + self.threads: dict[UUID, dict[str, object]] = {} + self.memories: dict[tuple[str, str], dict[str, object]] = {} + self.revisions: list[dict[str, object]] = [] + self.open_loops: list[dict[str, object]] = [] + self.allowed_profiles: dict[str, dict[str, str]] = { + "assistant_default": { + "id": "assistant_default", + "name": "Assistant Default", + "description": "Default profile", + }, + "coach_default": { + "id": "coach_default", + "name": "Coach Default", + "description": "Coach profile", + }, + } + + def list_events_by_ids(self, event_ids: list[UUID]) -> list[dict[str, object]]: + return [self.events[event_id] for event_id in event_ids if event_id in self.events] + + def get_thread_optional(self, thread_id: UUID) -> dict[str, object] | None: + return self.threads.get(thread_id) + + def get_agent_profile_optional(self, profile_id: str) -> dict[str, str] | None: + return self.allowed_profiles.get(profile_id) + + def _find_memory_by_id(self, memory_id: UUID) -> dict[str, object] | None: + for memory in self.memories.values(): + if memory["id"] == memory_id: + return memory + return None + + def get_memory_by_key(self, memory_key: str) -> dict[str, object] | None: + for memory in self.memories.values(): + if memory["memory_key"] == memory_key: + return memory + return None + + def get_memory_by_key_and_profile( + self, + *, + memory_key: str, + agent_profile_id: str, + ) -> dict[str, object] | None: + return self.memories.get((memory_key, agent_profile_id)) + + def create_memory( + self, + *, + memory_key: str, + value, + status: str, + source_event_ids: list[str], + memory_type: str = "preference", + confidence: float | None = None, + salience: float | None = None, + confirmation_status: str = "unconfirmed", + trust_class: str = "deterministic", + promotion_eligibility: str = "promotable", + evidence_count: int | None = None, + independent_source_count: int | None = None, + extracted_by_model: str | None = None, + trust_reason: str | None = None, + valid_from: datetime | None = None, + valid_to: datetime | None = None, + last_confirmed_at: datetime | None = None, + agent_profile_id: str = "assistant_default", + ) -> dict[str, object]: + memory = { + "id": uuid4(), + "user_id": uuid4(), + "agent_profile_id": agent_profile_id, + "memory_key": memory_key, + "value": value, + "status": status, + "source_event_ids": source_event_ids, + "memory_type": memory_type, + "confidence": confidence, + "salience": salience, + "confirmation_status": confirmation_status, + "trust_class": trust_class, + "promotion_eligibility": promotion_eligibility, + "evidence_count": evidence_count, + "independent_source_count": independent_source_count, + "extracted_by_model": extracted_by_model, + "trust_reason": trust_reason, + "valid_from": valid_from, + "valid_to": valid_to, + "last_confirmed_at": last_confirmed_at, + "created_at": self.base_time, + "updated_at": self.base_time, + "deleted_at": None, + } + self.memories[(memory_key, agent_profile_id)] = memory + return memory + + def update_memory( + self, + *, + memory_id: UUID, + value, + status: str, + source_event_ids: list[str], + memory_type: str = "preference", + confidence: float | None = None, + salience: float | None = None, + confirmation_status: str = "unconfirmed", + trust_class: str = "deterministic", + promotion_eligibility: str = "promotable", + evidence_count: int | None = None, + independent_source_count: int | None = None, + extracted_by_model: str | None = None, + trust_reason: str | None = None, + valid_from: datetime | None = None, + valid_to: datetime | None = None, + last_confirmed_at: datetime | None = None, + ) -> dict[str, object]: + existing_memory = self._find_memory_by_id(memory_id) + assert existing_memory is not None + updated_at = self.base_time + timedelta(minutes=len(self.revisions) + 1) + updated_memory = { + **existing_memory, + "value": value, + "status": status, + "source_event_ids": source_event_ids, + "memory_type": memory_type, + "confidence": confidence, + "salience": salience, + "confirmation_status": confirmation_status, + "trust_class": trust_class, + "promotion_eligibility": promotion_eligibility, + "evidence_count": evidence_count, + "independent_source_count": independent_source_count, + "extracted_by_model": extracted_by_model, + "trust_reason": trust_reason, + "valid_from": valid_from, + "valid_to": valid_to, + "last_confirmed_at": last_confirmed_at, + "updated_at": updated_at, + "deleted_at": updated_at if status == "deleted" else None, + } + self.memories[(updated_memory["memory_key"], updated_memory["agent_profile_id"])] = updated_memory + return updated_memory + + def append_memory_revision( + self, + *, + memory_id: UUID, + action: str, + memory_key: str, + previous_value, + new_value, + source_event_ids: list[str], + candidate: dict[str, object], + ) -> dict[str, object]: + existing_memory = self._find_memory_by_id(memory_id) + revision = { + "id": uuid4(), + "user_id": existing_memory["user_id"] if existing_memory is not None else uuid4(), + "memory_id": memory_id, + "sequence_no": len(self.revisions) + 1, + "action": action, + "memory_key": memory_key, + "previous_value": previous_value, + "new_value": new_value, + "source_event_ids": source_event_ids, + "candidate": candidate, + "created_at": self.base_time + timedelta(minutes=len(self.revisions) + 1), + } + self.revisions.append(revision) + return revision + + def create_open_loop( + self, + *, + memory_id: UUID | None, + title: str, + status: str, + opened_at: datetime | None, + due_at: datetime | None, + resolved_at: datetime | None, + resolution_note: str | None, + ) -> dict[str, object]: + memory = None if memory_id is None else self._find_memory_by_id(memory_id) + created = { + "id": uuid4(), + "user_id": memory["user_id"] if memory is not None else uuid4(), + "memory_id": memory_id, + "title": title, + "status": status, + "opened_at": self.base_time if opened_at is None else opened_at, + "due_at": due_at, + "resolved_at": resolved_at, + "resolution_note": resolution_note, + "created_at": self.base_time, + "updated_at": self.base_time, + } + self.open_loops.append(created) + return created + + +def seed_event(store: MemoryStoreStub, *, agent_profile_id: str = "assistant_default") -> UUID: + event_id = uuid4() + thread_id = uuid4() + store.threads[thread_id] = { + "id": thread_id, + "agent_profile_id": agent_profile_id, + } + store.events[event_id] = { + "id": event_id, + "thread_id": thread_id, + "sequence_no": 1, + "kind": "message.user", + "payload": {"text": "evidence"}, + "created_at": store.base_time, + } + return event_id + + +def test_admit_memory_candidate_defaults_to_noop_when_value_is_missing() -> None: + store = MemoryStoreStub() + event_id = seed_event(store) + + decision = admit_memory_candidate( + store, # type: ignore[arg-type] + user_id=uuid4(), + candidate=MemoryCandidateInput( + memory_key="user.preference.coffee", + value=None, + source_event_ids=(event_id,), + ), + ) + + assert decision.action == "NOOP" + assert decision.reason == "candidate_value_missing" + assert decision.memory is None + assert decision.revision is None + + +def test_admit_memory_candidate_rejects_missing_source_events() -> None: + store = MemoryStoreStub() + + with pytest.raises( + MemoryAdmissionValidationError, + match="source_event_ids must all reference existing events owned by the user", + ): + admit_memory_candidate( + store, # type: ignore[arg-type] + user_id=uuid4(), + candidate=MemoryCandidateInput( + memory_key="user.preference.tea", + value={"likes": True}, + source_event_ids=(uuid4(),), + ), + ) + + +def test_admit_memory_candidate_rejects_empty_source_event_ids() -> None: + store = MemoryStoreStub() + + with pytest.raises( + MemoryAdmissionValidationError, + match="source_event_ids must include at least one existing event owned by the user", + ): + admit_memory_candidate( + store, # type: ignore[arg-type] + user_id=uuid4(), + candidate=MemoryCandidateInput( + memory_key="user.preference.tea", + value={"likes": True}, + source_event_ids=(), + ), + ) + + +def test_admit_memory_candidate_rejects_invalid_memory_type() -> None: + store = MemoryStoreStub() + event_id = seed_event(store) + + with pytest.raises( + MemoryAdmissionValidationError, + match="memory_type must be one of:", + ): + admit_memory_candidate( + store, # type: ignore[arg-type] + user_id=uuid4(), + candidate=MemoryCandidateInput( + memory_key="user.preference.tea", + value={"likes": True}, + source_event_ids=(event_id,), + memory_type="not_a_valid_type", + ), + ) + + +def test_admit_memory_candidate_defaults_llm_single_source_to_not_promotable() -> None: + store = MemoryStoreStub() + event_id = seed_event(store) + + decision = admit_memory_candidate( + store, # type: ignore[arg-type] + user_id=uuid4(), + candidate=MemoryCandidateInput( + memory_key="user.fact.weather", + value={"summary": "Rain tomorrow"}, + source_event_ids=(event_id,), + trust_class="llm_single_source", + ), + ) + + assert decision.action == "ADD" + assert decision.memory is not None + assert decision.memory["trust_class"] == "llm_single_source" + assert decision.memory["promotion_eligibility"] == "not_promotable" + + +def test_admit_memory_candidate_adds_new_memory_with_first_revision() -> None: + store = MemoryStoreStub() + event_id = seed_event(store) + + decision = admit_memory_candidate( + store, # type: ignore[arg-type] + user_id=uuid4(), + candidate=MemoryCandidateInput( + memory_key="user.preference.coffee", + value={"likes": "oat milk"}, + source_event_ids=(event_id,), + ), + ) + + assert decision.action == "ADD" + assert decision.reason == "source_backed_add" + assert decision.memory is not None + assert decision.memory["memory_key"] == "user.preference.coffee" + assert decision.memory["status"] == "active" + assert decision.revision is not None + assert decision.revision["sequence_no"] == 1 + assert decision.revision["action"] == "ADD" + assert decision.revision["new_value"] == {"likes": "oat milk"} + + +def test_admit_memory_candidate_creates_open_loop_when_requested() -> None: + store = MemoryStoreStub() + event_id = seed_event(store) + + decision = admit_memory_candidate( + store, # type: ignore[arg-type] + user_id=uuid4(), + candidate=MemoryCandidateInput( + memory_key="user.preference.coffee", + value={"likes": "oat milk"}, + source_event_ids=(event_id,), + open_loop=OpenLoopCandidateInput( + title="Confirm this preference before next reorder", + due_at=datetime(2026, 3, 20, 9, 0, tzinfo=UTC), + ), + ), + ) + + assert decision.action == "ADD" + assert decision.open_loop is not None + assert decision.open_loop["memory_id"] == decision.memory["id"] + assert decision.open_loop["title"] == "Confirm this preference before next reorder" + assert decision.open_loop["status"] == "open" + assert decision.open_loop["due_at"] == "2026-03-20T09:00:00+00:00" + + +def test_admit_memory_candidate_updates_existing_memory_and_appends_revision() -> None: + store = MemoryStoreStub() + event_id = seed_event(store) + created = store.create_memory( + memory_key="user.preference.coffee", + value={"likes": "black"}, + status="active", + source_event_ids=[str(event_id)], + ) + store.append_memory_revision( + memory_id=created["id"], + action="ADD", + memory_key="user.preference.coffee", + previous_value=None, + new_value={"likes": "black"}, + source_event_ids=[str(event_id)], + candidate={ + "memory_key": "user.preference.coffee", + "value": {"likes": "black"}, + "source_event_ids": [str(event_id)], + "delete_requested": False, + }, + ) + + decision = admit_memory_candidate( + store, # type: ignore[arg-type] + user_id=uuid4(), + candidate=MemoryCandidateInput( + memory_key="user.preference.coffee", + value={"likes": "oat milk"}, + source_event_ids=(event_id,), + ), + ) + + assert decision.action == "UPDATE" + assert decision.reason == "source_backed_update" + assert decision.memory is not None + assert decision.memory["value"] == {"likes": "oat milk"} + assert decision.revision is not None + assert decision.revision["sequence_no"] == 2 + assert decision.revision["previous_value"] == {"likes": "black"} + assert decision.revision["new_value"] == {"likes": "oat milk"} + + +def test_admit_memory_candidate_marks_memory_deleted_and_appends_revision() -> None: + store = MemoryStoreStub() + event_id = seed_event(store) + created = store.create_memory( + memory_key="user.preference.coffee", + value={"likes": "black"}, + status="active", + source_event_ids=[str(event_id)], + ) + + decision = admit_memory_candidate( + store, # type: ignore[arg-type] + user_id=uuid4(), + candidate=MemoryCandidateInput( + memory_key="user.preference.coffee", + value=None, + source_event_ids=(event_id,), + delete_requested=True, + ), + ) + + assert decision.action == "DELETE" + assert decision.reason == "source_backed_delete" + assert decision.memory is not None + assert UUID(decision.memory["id"]) == created["id"] + assert decision.memory["status"] == "deleted" + assert decision.revision is not None + assert decision.revision["sequence_no"] == 1 + assert decision.revision["action"] == "DELETE" + assert decision.revision["new_value"] is None + + +def test_admit_memory_candidate_scopes_upserts_by_derived_agent_profile() -> None: + store = MemoryStoreStub() + assistant_event_id = seed_event(store, agent_profile_id="assistant_default") + coach_event_id = seed_event(store, agent_profile_id="coach_default") + + assistant_decision = admit_memory_candidate( + store, # type: ignore[arg-type] + user_id=uuid4(), + candidate=MemoryCandidateInput( + memory_key="user.preference.coffee", + value={"likes": "black"}, + source_event_ids=(assistant_event_id,), + ), + ) + coach_add_decision = admit_memory_candidate( + store, # type: ignore[arg-type] + user_id=uuid4(), + candidate=MemoryCandidateInput( + memory_key="user.preference.coffee", + value={"likes": "oat milk"}, + source_event_ids=(coach_event_id,), + ), + ) + coach_update_decision = admit_memory_candidate( + store, # type: ignore[arg-type] + user_id=uuid4(), + candidate=MemoryCandidateInput( + memory_key="user.preference.coffee", + value={"likes": "macchiato"}, + source_event_ids=(coach_event_id,), + ), + ) + + assert assistant_decision.action == "ADD" + assert coach_add_decision.action == "ADD" + assert coach_update_decision.action == "UPDATE" + assert assistant_decision.memory is not None + assert coach_add_decision.memory is not None + assert coach_update_decision.memory is not None + assert assistant_decision.memory["id"] != coach_add_decision.memory["id"] + assert coach_update_decision.memory["id"] == coach_add_decision.memory["id"] + assert assistant_decision.memory["value"] == {"likes": "black"} + assert coach_update_decision.memory["value"] == {"likes": "macchiato"} + + +def test_admit_memory_candidate_rejects_mixed_profile_source_events() -> None: + store = MemoryStoreStub() + assistant_event_id = seed_event(store, agent_profile_id="assistant_default") + coach_event_id = seed_event(store, agent_profile_id="coach_default") + + with pytest.raises( + MemoryAdmissionValidationError, + match="source_event_ids must all belong to threads with the same agent_profile_id", + ): + admit_memory_candidate( + store, # type: ignore[arg-type] + user_id=uuid4(), + candidate=MemoryCandidateInput( + memory_key="user.preference.coffee", + value={"likes": "black"}, + source_event_ids=(assistant_event_id, coach_event_id), + ), + ) + + +def test_admit_memory_candidate_rejects_explicit_agent_profile_mismatch() -> None: + store = MemoryStoreStub() + assistant_event_id = seed_event(store, agent_profile_id="assistant_default") + + with pytest.raises( + MemoryAdmissionValidationError, + match="agent_profile_id must match the profile resolved from source_event_ids", + ): + admit_memory_candidate( + store, # type: ignore[arg-type] + user_id=uuid4(), + candidate=MemoryCandidateInput( + memory_key="user.preference.coffee", + value={"likes": "black"}, + source_event_ids=(assistant_event_id,), + agent_profile_id="coach_default", + ), + ) + + +def test_admit_memory_candidate_rejects_unknown_agent_profile_id() -> None: + store = MemoryStoreStub() + assistant_event_id = seed_event(store, agent_profile_id="assistant_default") + + with pytest.raises( + MemoryAdmissionValidationError, + match="agent_profile_id must reference an existing profile: unknown_profile", + ): + admit_memory_candidate( + store, # type: ignore[arg-type] + user_id=uuid4(), + candidate=MemoryCandidateInput( + memory_key="user.preference.coffee", + value={"likes": "black"}, + source_event_ids=(assistant_event_id,), + agent_profile_id="unknown_profile", + ), + ) + + +class MemoryReviewStoreStub: + def __init__(self) -> None: + self.memories: list[dict[str, object]] = [] + self.revisions: dict[UUID, list[dict[str, object]]] = {} + self.labels: dict[UUID, list[dict[str, object]]] = {} + + def count_memories(self, *, status: str | None = None) -> int: + return len(self._filtered_memories(status)) + + def list_review_memories(self, *, status: str | None = None, limit: int) -> list[dict[str, object]]: + return self._review_sorted_memories(self._filtered_memories(status))[:limit] + + def count_unlabeled_review_memories(self) -> int: + return len( + [memory for memory in self.memories if memory["status"] == "active" and not self.labels.get(memory["id"])] + ) + + def list_unlabeled_review_memories(self, *, limit: int | None = None) -> list[dict[str, object]]: + items = self._review_sorted_memories( + [ + memory + for memory in self.memories + if memory["status"] == "active" and not self.labels.get(memory["id"]) + ] + ) + if limit is None: + return items + return items[:limit] + + def get_memory_optional(self, memory_id: UUID) -> dict[str, object] | None: + for memory in self.memories: + if memory["id"] == memory_id: + return memory + return None + + def count_memory_revisions(self, memory_id: UUID) -> int: + return len(self.revisions.get(memory_id, [])) + + def list_memory_revisions(self, memory_id: UUID, *, limit: int | None = None) -> list[dict[str, object]]: + revisions = self.revisions.get(memory_id, []) + if limit is None: + return revisions + return revisions[:limit] + + def create_memory_review_label( + self, + *, + memory_id: UUID, + label: str, + note: str | None, + ) -> dict[str, object]: + memory = self.get_memory_optional(memory_id) + created = { + "id": uuid4(), + "user_id": uuid4() if memory is None else memory["user_id"], + "memory_id": memory_id, + "label": label, + "note": note, + "created_at": datetime(2026, 3, 11, 13, len(self.labels.get(memory_id, [])), tzinfo=UTC), + } + self.labels.setdefault(memory_id, []).append(created) + return created + + def list_memory_review_labels(self, memory_id: UUID) -> list[dict[str, object]]: + return list(self.labels.get(memory_id, [])) + + def list_memory_review_label_counts(self, memory_id: UUID) -> list[dict[str, object]]: + counts: dict[str, int] = {} + for label in self.labels.get(memory_id, []): + label_name = label["label"] + counts[label_name] = counts.get(label_name, 0) + 1 + return [{"label": label, "count": count} for label, count in sorted(counts.items())] + + def count_labeled_memories(self) -> int: + return len([memory for memory in self.memories if self.labels.get(memory["id"])]) + + def count_unlabeled_memories(self) -> int: + return len([memory for memory in self.memories if not self.labels.get(memory["id"])]) + + def list_all_memory_review_label_counts(self) -> list[dict[str, object]]: + counts: dict[str, int] = {} + for labels in self.labels.values(): + for label in labels: + label_name = label["label"] + counts[label_name] = counts.get(label_name, 0) + 1 + return [{"label": label, "count": count} for label, count in sorted(counts.items())] + + def list_active_memory_review_label_counts(self) -> list[dict[str, object]]: + counts: dict[str, int] = {} + for memory in self.memories: + if memory["status"] != "active": + continue + for label in self.labels.get(memory["id"], []): + label_name = label["label"] + counts[label_name] = counts.get(label_name, 0) + 1 + return [{"label": label, "count": count} for label, count in sorted(counts.items())] + + def list_continuity_recall_candidates(self) -> list[dict[str, object]]: + return [] + + def list_continuity_correction_events( + self, + *, + continuity_object_id: UUID, + limit: int | None = None, + ) -> list[dict[str, object]]: + del continuity_object_id + del limit + return [] + + def _filtered_memories(self, status: str | None) -> list[dict[str, object]]: + if status is None: + return list(self.memories) + return [memory for memory in self.memories if memory["status"] == status] + + def _review_sorted_memories(self, memories: list[dict[str, object]]) -> list[dict[str, object]]: + return sorted( + memories, + key=lambda memory: (memory["updated_at"], memory["created_at"], memory["id"]), + reverse=True, + ) + + +class OpenLoopStoreStub: + def __init__(self) -> None: + self.base_time = datetime(2026, 3, 23, 12, 0, tzinfo=UTC) + self.memories: dict[UUID, dict[str, object]] = {} + self.open_loops: dict[UUID, dict[str, object]] = {} + + def get_memory_optional(self, memory_id: UUID) -> dict[str, object] | None: + return self.memories.get(memory_id) + + def count_open_loops(self, *, status: str | None = None) -> int: + if status is None: + return len(self.open_loops) + return len([item for item in self.open_loops.values() if item["status"] == status]) + + def list_open_loops(self, *, status: str | None = None, limit: int | None = None) -> list[dict[str, object]]: + items = list(self.open_loops.values()) + if status is not None: + items = [item for item in items if item["status"] == status] + ordered = sorted( + items, + key=lambda item: (item["opened_at"], item["created_at"], item["id"]), + reverse=True, + ) + if limit is None: + return ordered + return ordered[:limit] + + def create_open_loop( + self, + *, + memory_id: UUID | None, + title: str, + status: str, + opened_at: datetime | None, + due_at: datetime | None, + resolved_at: datetime | None, + resolution_note: str | None, + ) -> dict[str, object]: + open_loop_id = uuid4() + loop = { + "id": open_loop_id, + "user_id": uuid4(), + "memory_id": memory_id, + "title": title, + "status": status, + "opened_at": self.base_time if opened_at is None else opened_at, + "due_at": due_at, + "resolved_at": resolved_at, + "resolution_note": resolution_note, + "created_at": self.base_time, + "updated_at": self.base_time, + } + self.open_loops[open_loop_id] = loop + return loop + + def get_open_loop_optional(self, open_loop_id: UUID) -> dict[str, object] | None: + return self.open_loops.get(open_loop_id) + + def update_open_loop_status_optional( + self, + *, + open_loop_id: UUID, + status: str, + resolved_at: datetime | None, + resolution_note: str | None, + ) -> dict[str, object] | None: + existing = self.open_loops.get(open_loop_id) + if existing is None: + return None + updated = { + **existing, + "status": status, + "resolved_at": self.base_time + timedelta(minutes=5) if resolved_at is None else resolved_at, + "resolution_note": resolution_note, + "updated_at": self.base_time + timedelta(minutes=5), + } + self.open_loops[open_loop_id] = updated + return updated + + +def test_open_loop_records_support_create_list_get_and_status_transition() -> None: + store = OpenLoopStoreStub() + memory_id = uuid4() + store.memories[memory_id] = {"id": memory_id} + + created = create_open_loop_record( + store, # type: ignore[arg-type] + user_id=uuid4(), + open_loop=OpenLoopCreateInput( + memory_id=memory_id, + title="Follow up with merchant confirmation", + due_at=datetime(2026, 3, 27, 10, 0, tzinfo=UTC), + ), + ) + open_loop_id = UUID(created["open_loop"]["id"]) + + listed = list_open_loop_records( + store, # type: ignore[arg-type] + user_id=uuid4(), + status="open", + limit=10, + ) + detail = get_open_loop_record( + store, # type: ignore[arg-type] + user_id=uuid4(), + open_loop_id=open_loop_id, + ) + updated = update_open_loop_status_record( + store, # type: ignore[arg-type] + user_id=uuid4(), + open_loop_id=open_loop_id, + request=OpenLoopStatusUpdateInput( + status="resolved", + resolution_note="Resolved in latest review pass.", + ), + ) + + assert created["open_loop"]["status"] == "open" + assert listed["summary"] == { + "status": "open", + "limit": 10, + "returned_count": 1, + "total_count": 1, + "has_more": False, + "order": ["opened_at_desc", "created_at_desc", "id_desc"], + } + assert detail["open_loop"]["id"] == str(open_loop_id) + assert updated["open_loop"]["status"] == "resolved" + assert updated["open_loop"]["resolution_note"] == "Resolved in latest review pass." + assert updated["open_loop"]["resolved_at"] is not None + + +def test_open_loop_records_support_dismissed_transition_with_audit_fields() -> None: + store = OpenLoopStoreStub() + memory_id = uuid4() + store.memories[memory_id] = {"id": memory_id} + + created = create_open_loop_record( + store, # type: ignore[arg-type] + user_id=uuid4(), + open_loop=OpenLoopCreateInput( + memory_id=memory_id, + title="Dismiss after confirming no further action is needed", + due_at=None, + ), + ) + + dismissed = update_open_loop_status_record( + store, # type: ignore[arg-type] + user_id=uuid4(), + open_loop_id=UUID(created["open_loop"]["id"]), + request=OpenLoopStatusUpdateInput( + status="dismissed", + resolution_note="No action required after review.", + ), + ) + + assert dismissed["open_loop"]["status"] == "dismissed" + assert dismissed["open_loop"]["resolved_at"] is not None + assert dismissed["open_loop"]["resolution_note"] == "No action required after review." + + +def test_open_loop_status_update_rejects_invalid_status_and_missing_records() -> None: + store = OpenLoopStoreStub() + loop_id = uuid4() + store.open_loops[loop_id] = { + "id": loop_id, + "user_id": uuid4(), + "memory_id": None, + "title": "Investigate", + "status": "open", + "opened_at": store.base_time, + "due_at": None, + "resolved_at": None, + "resolution_note": None, + "created_at": store.base_time, + "updated_at": store.base_time, + } + + with pytest.raises(OpenLoopValidationError, match="status must be one of"): + update_open_loop_status_record( + store, # type: ignore[arg-type] + user_id=uuid4(), + open_loop_id=loop_id, + request=OpenLoopStatusUpdateInput(status="invalid"), # type: ignore[arg-type] + ) + + with pytest.raises(OpenLoopNotFoundError, match="was not found"): + get_open_loop_record( + store, # type: ignore[arg-type] + user_id=uuid4(), + open_loop_id=uuid4(), + ) + + +def test_list_memory_review_records_returns_summary_and_stable_shape() -> None: + store = MemoryReviewStoreStub() + base_time = datetime(2026, 3, 11, 12, 0, tzinfo=UTC) + deleted_time = base_time + timedelta(minutes=1) + active_time = base_time + timedelta(minutes=2) + deleted_id = uuid4() + active_id = uuid4() + store.memories = [ + { + "id": active_id, + "user_id": uuid4(), + "memory_key": "user.preference.coffee", + "value": {"likes": "oat milk"}, + "status": "active", + "source_event_ids": ["event-2"], + "created_at": base_time, + "updated_at": active_time, + "deleted_at": None, + }, + { + "id": deleted_id, + "user_id": uuid4(), + "memory_key": "user.preference.tea", + "value": {"likes": "green"}, + "status": "deleted", + "source_event_ids": ["event-1"], + "created_at": base_time, + "updated_at": deleted_time, + "deleted_at": deleted_time, + }, + ] + + payload = list_memory_review_records( + store, # type: ignore[arg-type] + user_id=uuid4(), + status="all", + limit=1, + ) + + assert payload == { + "items": [ + { + "id": str(active_id), + "memory_key": "user.preference.coffee", + "value": {"likes": "oat milk"}, + "status": "active", + "source_event_ids": ["event-2"], + "created_at": "2026-03-11T12:00:00+00:00", + "updated_at": "2026-03-11T12:02:00+00:00", + "deleted_at": None, + } + ], + "summary": { + "status": "all", + "limit": 1, + "returned_count": 1, + "total_count": 2, + "has_more": True, + "order": ["updated_at_desc", "created_at_desc", "id_desc"], + }, + } + + +def test_get_memory_review_record_raises_not_found_for_inaccessible_memory() -> None: + store = MemoryReviewStoreStub() + + with pytest.raises(MemoryReviewNotFoundError, match="was not found"): + get_memory_review_record( + store, # type: ignore[arg-type] + user_id=uuid4(), + memory_id=uuid4(), + ) + + +def test_list_memory_review_queue_records_returns_only_active_unlabeled_memories_in_stable_order() -> None: + store = MemoryReviewStoreStub() + base_time = datetime(2026, 3, 11, 12, 0, tzinfo=UTC) + deleted_id = uuid4() + labeled_id = uuid4() + newest_unlabeled_id = uuid4() + older_unlabeled_id = uuid4() + store.memories = [ + { + "id": newest_unlabeled_id, + "user_id": uuid4(), + "memory_key": "user.preference.coffee", + "value": {"likes": "oat milk"}, + "status": "active", + "source_event_ids": ["event-4"], + "created_at": base_time + timedelta(minutes=3), + "updated_at": base_time + timedelta(minutes=6), + "deleted_at": None, + }, + { + "id": labeled_id, + "user_id": uuid4(), + "memory_key": "user.preference.snack", + "value": {"likes": "chips"}, + "status": "active", + "source_event_ids": ["event-3"], + "created_at": base_time + timedelta(minutes=2), + "updated_at": base_time + timedelta(minutes=5), + "deleted_at": None, + }, + { + "id": older_unlabeled_id, + "user_id": uuid4(), + "memory_key": "user.preference.book", + "value": {"genre": "science fiction"}, + "status": "active", + "source_event_ids": ["event-2"], + "created_at": base_time + timedelta(minutes=1), + "updated_at": base_time + timedelta(minutes=4), + "deleted_at": None, + }, + { + "id": deleted_id, + "user_id": uuid4(), + "memory_key": "user.preference.tea", + "value": {"likes": "green"}, + "status": "deleted", + "source_event_ids": ["event-1"], + "created_at": base_time, + "updated_at": base_time + timedelta(minutes=7), + "deleted_at": base_time + timedelta(minutes=7), + }, + ] + store.labels[labeled_id] = [ + { + "id": uuid4(), + "user_id": uuid4(), + "memory_id": labeled_id, + "label": "correct", + "note": "Already reviewed.", + "created_at": base_time + timedelta(minutes=8), + } + ] + + payload = list_memory_review_queue_records( + store, # type: ignore[arg-type] + user_id=uuid4(), + limit=2, + ) + + assert payload == { + "items": [ + { + "id": str(newest_unlabeled_id), + "memory_key": "user.preference.coffee", + "value": {"likes": "oat milk"}, + "status": "active", + "source_event_ids": ["event-4"], + "is_high_risk": True, + "is_stale_truth": False, + "is_promotable": True, + "queue_priority_mode": "recent_first", + "priority_reason": "recent_first", + "created_at": "2026-03-11T12:03:00+00:00", + "updated_at": "2026-03-11T12:06:00+00:00", + }, + { + "id": str(older_unlabeled_id), + "memory_key": "user.preference.book", + "value": {"genre": "science fiction"}, + "status": "active", + "source_event_ids": ["event-2"], + "is_high_risk": True, + "is_stale_truth": False, + "is_promotable": True, + "queue_priority_mode": "recent_first", + "priority_reason": "recent_first", + "created_at": "2026-03-11T12:01:00+00:00", + "updated_at": "2026-03-11T12:04:00+00:00", + }, + ], + "summary": { + "memory_status": "active", + "review_state": "unlabeled", + "priority_mode": "recent_first", + "available_priority_modes": [ + "oldest_first", + "recent_first", + "high_risk_first", + "stale_truth_first", + ], + "limit": 2, + "returned_count": 2, + "total_count": 2, + "has_more": False, + "order": ["updated_at_desc", "created_at_desc", "id_desc"], + }, + } + + +def test_list_memory_review_queue_records_marks_not_promotable_fact_as_high_risk() -> None: + store = MemoryReviewStoreStub() + base_time = datetime(2026, 3, 11, 12, 0, tzinfo=UTC) + memory_id = uuid4() + store.memories = [ + { + "id": memory_id, + "user_id": uuid4(), + "memory_key": "user.fact.single_source", + "value": {"claim": "single source"}, + "status": "active", + "source_event_ids": ["event-1"], + "trust_class": "llm_single_source", + "promotion_eligibility": "not_promotable", + "created_at": base_time, + "updated_at": base_time, + "deleted_at": None, + } + ] + + payload = list_memory_review_queue_records( + store, # type: ignore[arg-type] + user_id=uuid4(), + limit=1, + ) + + assert payload["items"][0]["id"] == str(memory_id) + assert payload["items"][0]["is_promotable"] is False + assert payload["items"][0]["is_high_risk"] is True + assert payload["items"][0]["priority_reason"] == "recent_not_promotable" + + +def test_list_memory_review_queue_records_supports_all_priority_modes_with_deterministic_order() -> None: + store = MemoryReviewStoreStub() + base_time = datetime(2026, 3, 11, 12, 0, tzinfo=UTC) + oldest_id = uuid4() + middle_id = uuid4() + newest_id = uuid4() + store.memories = [ + { + "id": oldest_id, + "user_id": uuid4(), + "memory_key": "user.preference.oldest", + "value": {"value": "oldest"}, + "status": "active", + "source_event_ids": ["event-1"], + "confirmation_status": "contested", + "confidence": 0.9, + "valid_to": datetime(2026, 3, 1, tzinfo=UTC), + "created_at": base_time, + "updated_at": base_time, + "deleted_at": None, + }, + { + "id": middle_id, + "user_id": uuid4(), + "memory_key": "user.preference.middle", + "value": {"value": "middle"}, + "status": "active", + "source_event_ids": ["event-2"], + "confirmation_status": "confirmed", + "confidence": 0.95, + "created_at": base_time + timedelta(minutes=1), + "updated_at": base_time + timedelta(minutes=1), + "deleted_at": None, + }, + { + "id": newest_id, + "user_id": uuid4(), + "memory_key": "user.preference.newest", + "value": {"value": "newest"}, + "status": "active", + "source_event_ids": ["event-3"], + "confirmation_status": "confirmed", + "confidence": 0.2, + "created_at": base_time + timedelta(minutes=2), + "updated_at": base_time + timedelta(minutes=2), + "deleted_at": None, + }, + ] + + oldest_first = list_memory_review_queue_records( + store, # type: ignore[arg-type] + user_id=uuid4(), + limit=3, + priority_mode="oldest_first", + ) + recent_first = list_memory_review_queue_records( + store, # type: ignore[arg-type] + user_id=uuid4(), + limit=3, + priority_mode="recent_first", + ) + high_risk_first = list_memory_review_queue_records( + store, # type: ignore[arg-type] + user_id=uuid4(), + limit=3, + priority_mode="high_risk_first", + ) + stale_truth_first = list_memory_review_queue_records( + store, # type: ignore[arg-type] + user_id=uuid4(), + limit=3, + priority_mode="stale_truth_first", + ) + + assert [item["id"] for item in oldest_first["items"]] == [ + str(oldest_id), + str(middle_id), + str(newest_id), + ] + assert [item["id"] for item in recent_first["items"]] == [ + str(newest_id), + str(middle_id), + str(oldest_id), + ] + assert [item["id"] for item in high_risk_first["items"]] == [ + str(newest_id), + str(oldest_id), + str(middle_id), + ] + assert [item["id"] for item in stale_truth_first["items"]] == [ + str(oldest_id), + str(newest_id), + str(middle_id), + ] + assert high_risk_first["summary"]["priority_mode"] == "high_risk_first" + assert high_risk_first["summary"]["order"] == [ + "is_high_risk_desc", + "confidence_asc_nulls_first", + "updated_at_desc", + "created_at_desc", + "id_desc", + ] + assert stale_truth_first["items"][0]["is_stale_truth"] is True + + +def test_get_memory_quality_gate_summary_returns_canonical_status_transitions() -> None: + store = MemoryReviewStoreStub() + base_time = datetime(2026, 3, 11, 12, 0, tzinfo=UTC) + + def build_active_memory(memory_id: UUID, index: int) -> dict[str, object]: + return { + "id": memory_id, + "user_id": uuid4(), + "memory_key": f"user.preference.item_{index}", + "value": {"index": index}, + "status": "active", + "source_event_ids": [f"event-{index}"], + "confirmation_status": "confirmed", + "confidence": 0.95, + "valid_to": None, + "created_at": base_time + timedelta(minutes=index), + "updated_at": base_time + timedelta(minutes=index), + "deleted_at": None, + } + + memory_ids = [uuid4() for _ in range(11)] + store.memories = [build_active_memory(memory_id, index) for index, memory_id in enumerate(memory_ids)] + + def assign_labels(*, correct: int, incorrect: int, outdated_memory_ids: set[UUID] | None = None) -> None: + store.labels = {} + outdated_memory_ids = outdated_memory_ids or set() + cursor = 0 + for _ in range(correct): + memory_id = memory_ids[cursor] + store.labels.setdefault(memory_id, []).append( + { + "id": uuid4(), + "user_id": uuid4(), + "memory_id": memory_id, + "label": "correct", + "note": None, + "created_at": base_time + timedelta(hours=1, minutes=cursor), + } + ) + cursor += 1 + for _ in range(incorrect): + memory_id = memory_ids[cursor] + store.labels.setdefault(memory_id, []).append( + { + "id": uuid4(), + "user_id": uuid4(), + "memory_id": memory_id, + "label": "incorrect", + "note": None, + "created_at": base_time + timedelta(hours=2, minutes=cursor), + } + ) + cursor += 1 + for memory_id in outdated_memory_ids: + store.labels.setdefault(memory_id, []).append( + { + "id": uuid4(), + "user_id": uuid4(), + "memory_id": memory_id, + "label": "outdated", + "note": "Superseded.", + "created_at": base_time + timedelta(hours=3), + } + ) + + assign_labels(correct=1, incorrect=0) + insufficient = get_memory_quality_gate_summary(store, user_id=uuid4()) # type: ignore[arg-type] + assert insufficient["summary"]["status"] == "insufficient_sample" + assert insufficient["summary"]["adjudicated_sample_count"] == 1 + assert insufficient["summary"]["remaining_to_minimum_sample"] == 9 + + assign_labels(correct=7, incorrect=3) + degraded_precision = get_memory_quality_gate_summary(store, user_id=uuid4()) # type: ignore[arg-type] + assert degraded_precision["summary"]["status"] == "degraded" + assert degraded_precision["summary"]["precision"] == 0.7 + + assign_labels(correct=10, incorrect=0, outdated_memory_ids={memory_ids[0]}) + degraded_conflict = get_memory_quality_gate_summary(store, user_id=uuid4()) # type: ignore[arg-type] + assert degraded_conflict["summary"]["status"] == "degraded" + assert degraded_conflict["summary"]["superseded_active_conflict_count"] == 1 + + assign_labels(correct=10, incorrect=0) + needs_review_memory = next(memory for memory in store.memories if memory["id"] == memory_ids[10]) + needs_review_memory["confirmation_status"] = "unconfirmed" + needs_review = get_memory_quality_gate_summary(store, user_id=uuid4()) # type: ignore[arg-type] + assert needs_review["summary"]["status"] == "needs_review" + assert needs_review["summary"]["high_risk_memory_count"] >= 1 + + assign_labels(correct=11, incorrect=0) + needs_review_memory["confirmation_status"] = "confirmed" + healthy = get_memory_quality_gate_summary(store, user_id=uuid4()) # type: ignore[arg-type] + assert healthy["summary"]["status"] == "healthy" + assert healthy["summary"]["precision"] == 1.0 + assert healthy["summary"]["high_risk_memory_count"] == 0 + assert healthy["summary"]["stale_truth_count"] == 0 + assert healthy["summary"]["superseded_active_conflict_count"] == 0 + + +def test_get_memory_trust_dashboard_summary_is_deterministic_and_uses_canonical_components() -> None: + store = MemoryReviewStoreStub() + base_time = datetime(2026, 3, 11, 12, 0, tzinfo=UTC) + + labeled_memory_ids = [uuid4() for _ in range(10)] + for index, memory_id in enumerate(labeled_memory_ids): + store.memories.append( + { + "id": memory_id, + "user_id": uuid4(), + "memory_key": f"user.preference.reviewed_{index}", + "value": {"index": index}, + "status": "active", + "source_event_ids": [f"event-reviewed-{index}"], + "confirmation_status": "confirmed", + "confidence": 0.95, + "valid_to": None, + "created_at": base_time + timedelta(minutes=index), + "updated_at": base_time + timedelta(minutes=index), + "deleted_at": None, + } + ) + store.labels[memory_id] = [ + { + "id": uuid4(), + "user_id": uuid4(), + "memory_id": memory_id, + "label": "correct", + "note": None, + "created_at": base_time + timedelta(hours=1, minutes=index), + } + ] + + high_risk_memory_id = uuid4() + stale_truth_memory_id = uuid4() + store.memories.extend( + [ + { + "id": high_risk_memory_id, + "user_id": uuid4(), + "memory_key": "user.preference.high_risk", + "value": {"state": "high_risk"}, + "status": "active", + "source_event_ids": ["event-high-risk"], + "confirmation_status": "unconfirmed", + "confidence": 0.2, + "valid_to": None, + "created_at": base_time + timedelta(hours=2), + "updated_at": base_time + timedelta(hours=2), + "deleted_at": None, + }, + { + "id": stale_truth_memory_id, + "user_id": uuid4(), + "memory_key": "user.preference.stale_truth", + "value": {"state": "stale_truth"}, + "status": "active", + "source_event_ids": ["event-stale-truth"], + "confirmation_status": "contested", + "confidence": 0.95, + "valid_to": base_time - timedelta(days=1), + "created_at": base_time, + "updated_at": base_time, + "deleted_at": None, + }, + ] + ) + + first = get_memory_trust_dashboard_summary(store, user_id=uuid4()) # type: ignore[arg-type] + second = get_memory_trust_dashboard_summary(store, user_id=uuid4()) # type: ignore[arg-type] + + assert first == second + assert first["dashboard"]["quality_gate"]["status"] == "needs_review" + assert first["dashboard"]["queue_posture"]["total_count"] == 2 + assert first["dashboard"]["queue_posture"]["high_risk_count"] == 2 + assert first["dashboard"]["queue_posture"]["stale_truth_count"] == 1 + assert first["dashboard"]["retrieval_quality"]["fixture_count"] == 3 + assert first["dashboard"]["correction_freshness"] == { + "total_open_loop_count": 0, + "stale_open_loop_count": 0, + "correction_recurrence_count": 0, + "freshness_drift_count": 0, + } + assert first["dashboard"]["recommended_review"]["priority_mode"] == "high_risk_first" + assert first["dashboard"]["recommended_review"]["action"] == "review_high_risk_queue" + + +def test_get_memory_trust_dashboard_summary_handles_missing_continuity_tables( + monkeypatch: pytest.MonkeyPatch, +) -> None: + store = MemoryReviewStoreStub() + + def _raise_missing_continuity_table(*args, **kwargs): # type: ignore[no-untyped-def] + del args + del kwargs + raise psycopg.errors.UndefinedTable('relation "continuity_objects" does not exist') + + monkeypatch.setattr(memory_module, "compile_continuity_weekly_review", _raise_missing_continuity_table) + + payload = get_memory_trust_dashboard_summary(store, user_id=uuid4()) # type: ignore[arg-type] + + assert payload["dashboard"]["correction_freshness"] == { + "total_open_loop_count": 0, + "stale_open_loop_count": 0, + "correction_recurrence_count": 0, + "freshness_drift_count": 0, + } + + +def test_list_memory_revision_review_records_returns_deterministic_revision_order() -> None: + store = MemoryReviewStoreStub() + memory_id = uuid4() + base_time = datetime(2026, 3, 11, 12, 0, tzinfo=UTC) + store.memories = [ + { + "id": memory_id, + "user_id": uuid4(), + "memory_key": "user.preference.coffee", + "value": {"likes": "oat milk"}, + "status": "active", + "source_event_ids": ["event-2"], + "created_at": base_time, + "updated_at": base_time + timedelta(minutes=2), + "deleted_at": None, + } + ] + store.revisions[memory_id] = [ + { + "id": uuid4(), + "user_id": uuid4(), + "memory_id": memory_id, + "sequence_no": 1, + "action": "ADD", + "memory_key": "user.preference.coffee", + "previous_value": None, + "new_value": {"likes": "black"}, + "source_event_ids": ["event-1"], + "candidate": {"memory_key": "user.preference.coffee"}, + "created_at": base_time, + }, + { + "id": uuid4(), + "user_id": uuid4(), + "memory_id": memory_id, + "sequence_no": 2, + "action": "UPDATE", + "memory_key": "user.preference.coffee", + "previous_value": {"likes": "black"}, + "new_value": {"likes": "oat milk"}, + "source_event_ids": ["event-2"], + "candidate": {"memory_key": "user.preference.coffee"}, + "created_at": base_time + timedelta(minutes=1), + }, + ] + + payload = list_memory_revision_review_records( + store, # type: ignore[arg-type] + user_id=uuid4(), + memory_id=memory_id, + limit=10, + ) + + assert payload == { + "items": [ + { + "id": str(store.revisions[memory_id][0]["id"]), + "memory_id": str(memory_id), + "sequence_no": 1, + "action": "ADD", + "memory_key": "user.preference.coffee", + "previous_value": None, + "new_value": {"likes": "black"}, + "source_event_ids": ["event-1"], + "created_at": "2026-03-11T12:00:00+00:00", + }, + { + "id": str(store.revisions[memory_id][1]["id"]), + "memory_id": str(memory_id), + "sequence_no": 2, + "action": "UPDATE", + "memory_key": "user.preference.coffee", + "previous_value": {"likes": "black"}, + "new_value": {"likes": "oat milk"}, + "source_event_ids": ["event-2"], + "created_at": "2026-03-11T12:01:00+00:00", + }, + ], + "summary": { + "memory_id": str(memory_id), + "limit": 10, + "returned_count": 2, + "total_count": 2, + "has_more": False, + "order": ["sequence_no_asc"], + }, + } + + +def test_create_memory_review_label_record_returns_created_label_and_summary_counts() -> None: + store = MemoryReviewStoreStub() + memory_id = uuid4() + reviewer_user_id = uuid4() + base_time = datetime(2026, 3, 11, 12, 0, tzinfo=UTC) + store.memories = [ + { + "id": memory_id, + "user_id": reviewer_user_id, + "memory_key": "user.preference.coffee", + "value": {"likes": "oat milk"}, + "status": "active", + "source_event_ids": ["event-2"], + "created_at": base_time, + "updated_at": base_time, + "deleted_at": None, + } + ] + store.labels[memory_id] = [ + { + "id": uuid4(), + "user_id": reviewer_user_id, + "memory_id": memory_id, + "label": "correct", + "note": "Matches the latest cited event.", + "created_at": datetime(2026, 3, 11, 12, 30, tzinfo=UTC), + } + ] + + payload = create_memory_review_label_record( + store, # type: ignore[arg-type] + user_id=reviewer_user_id, + memory_id=memory_id, + label="outdated", + note="Superseded by the newer milk preference.", + ) + + assert payload == { + "label": { + "id": payload["label"]["id"], + "memory_id": str(memory_id), + "reviewer_user_id": payload["label"]["reviewer_user_id"], + "label": "outdated", + "note": "Superseded by the newer milk preference.", + "created_at": "2026-03-11T13:01:00+00:00", + }, + "summary": { + "memory_id": str(memory_id), + "total_count": 2, + "counts_by_label": { + "correct": 1, + "incorrect": 0, + "outdated": 1, + "insufficient_evidence": 0, + }, + "order": ["created_at_asc", "id_asc"], + }, + } + + +def test_create_memory_review_label_record_raises_not_found_for_inaccessible_memory() -> None: + store = MemoryReviewStoreStub() + + with pytest.raises(MemoryReviewNotFoundError, match="was not found"): + create_memory_review_label_record( + store, # type: ignore[arg-type] + user_id=uuid4(), + memory_id=uuid4(), + label="correct", + note=None, + ) + + +def test_list_memory_review_label_records_returns_deterministic_order_and_zero_filled_counts() -> None: + store = MemoryReviewStoreStub() + memory_id = uuid4() + reviewer_user_id = uuid4() + base_time = datetime(2026, 3, 11, 12, 0, tzinfo=UTC) + store.memories = [ + { + "id": memory_id, + "user_id": reviewer_user_id, + "memory_key": "user.preference.coffee", + "value": {"likes": "oat milk"}, + "status": "active", + "source_event_ids": ["event-2"], + "created_at": base_time, + "updated_at": base_time, + "deleted_at": None, + } + ] + store.labels[memory_id] = [ + { + "id": uuid4(), + "user_id": reviewer_user_id, + "memory_id": memory_id, + "label": "incorrect", + "note": "The source event only mentions tea.", + "created_at": datetime(2026, 3, 11, 12, 15, tzinfo=UTC), + }, + { + "id": uuid4(), + "user_id": reviewer_user_id, + "memory_id": memory_id, + "label": "insufficient_evidence", + "note": None, + "created_at": datetime(2026, 3, 11, 12, 16, tzinfo=UTC), + }, + ] + + payload = list_memory_review_label_records( + store, # type: ignore[arg-type] + user_id=reviewer_user_id, + memory_id=memory_id, + ) + + assert payload == { + "items": [ + { + "id": str(store.labels[memory_id][0]["id"]), + "memory_id": str(memory_id), + "reviewer_user_id": str(reviewer_user_id), + "label": "incorrect", + "note": "The source event only mentions tea.", + "created_at": "2026-03-11T12:15:00+00:00", + }, + { + "id": str(store.labels[memory_id][1]["id"]), + "memory_id": str(memory_id), + "reviewer_user_id": str(reviewer_user_id), + "label": "insufficient_evidence", + "note": None, + "created_at": "2026-03-11T12:16:00+00:00", + }, + ], + "summary": { + "memory_id": str(memory_id), + "total_count": 2, + "counts_by_label": { + "correct": 0, + "incorrect": 1, + "outdated": 0, + "insufficient_evidence": 1, + }, + "order": ["created_at_asc", "id_asc"], + }, + } + + +def test_get_memory_evaluation_summary_returns_explicit_consistent_counts() -> None: + store = MemoryReviewStoreStub() + base_time = datetime(2026, 3, 11, 12, 0, tzinfo=UTC) + active_labeled_id = uuid4() + active_unlabeled_id = uuid4() + deleted_labeled_id = uuid4() + deleted_unlabeled_id = uuid4() + store.memories = [ + { + "id": active_labeled_id, + "user_id": uuid4(), + "memory_key": "user.preference.coffee", + "value": {"likes": "oat milk"}, + "status": "active", + "source_event_ids": ["event-1"], + "created_at": base_time, + "updated_at": base_time, + "deleted_at": None, + }, + { + "id": active_unlabeled_id, + "user_id": uuid4(), + "memory_key": "user.preference.book", + "value": {"genre": "science fiction"}, + "status": "active", + "source_event_ids": ["event-2"], + "created_at": base_time + timedelta(minutes=1), + "updated_at": base_time + timedelta(minutes=1), + "deleted_at": None, + }, + { + "id": deleted_labeled_id, + "user_id": uuid4(), + "memory_key": "user.preference.snack", + "value": {"likes": "chips"}, + "status": "deleted", + "source_event_ids": ["event-3"], + "created_at": base_time + timedelta(minutes=2), + "updated_at": base_time + timedelta(minutes=2), + "deleted_at": base_time + timedelta(minutes=2), + }, + { + "id": deleted_unlabeled_id, + "user_id": uuid4(), + "memory_key": "user.preference.tea", + "value": {"likes": "green"}, + "status": "deleted", + "source_event_ids": ["event-4"], + "created_at": base_time + timedelta(minutes=3), + "updated_at": base_time + timedelta(minutes=3), + "deleted_at": base_time + timedelta(minutes=3), + }, + ] + store.labels[active_labeled_id] = [ + { + "id": uuid4(), + "user_id": uuid4(), + "memory_id": active_labeled_id, + "label": "correct", + "note": "Looks right.", + "created_at": base_time + timedelta(minutes=4), + }, + { + "id": uuid4(), + "user_id": uuid4(), + "memory_id": active_labeled_id, + "label": "insufficient_evidence", + "note": "Needs another source.", + "created_at": base_time + timedelta(minutes=5), + }, + ] + store.labels[deleted_labeled_id] = [ + { + "id": uuid4(), + "user_id": uuid4(), + "memory_id": deleted_labeled_id, + "label": "outdated", + "note": None, + "created_at": base_time + timedelta(minutes=6), + } + ] + + payload = get_memory_evaluation_summary( + store, # type: ignore[arg-type] + user_id=uuid4(), + ) + + assert payload == { + "summary": { + "total_memory_count": 4, + "active_memory_count": 2, + "deleted_memory_count": 2, + "labeled_memory_count": 2, + "unlabeled_memory_count": 2, + "total_label_row_count": 3, + "label_row_counts_by_value": { + "correct": 1, + "incorrect": 0, + "outdated": 1, + "insufficient_evidence": 1, + }, + "label_value_order": [ + "correct", + "incorrect", + "outdated", + "insufficient_evidence", + ], + } + } diff --git a/tests/unit/test_memory_store.py b/tests/unit/test_memory_store.py new file mode 100644 index 0000000..d22ced2 --- /dev/null +++ b/tests/unit/test_memory_store.py @@ -0,0 +1,711 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any +from uuid import uuid4 + +from psycopg.types.json import Jsonb +import pytest + +from alicebot_api.store import ContinuityStore, ContinuityStoreInvariantError + + +class RecordingCursor: + def __init__( + self, + fetchone_results: list[dict[str, Any]], + fetchall_results: list[list[dict[str, Any]]] | None = None, + ) -> None: + self.executed: list[tuple[str, tuple[object, ...] | None]] = [] + self.fetchone_results = list(fetchone_results) + self.fetchall_results = list(fetchall_results or []) + + def __enter__(self) -> "RecordingCursor": + return self + + def __exit__(self, exc_type, exc, tb) -> None: + return None + + def execute(self, query: str, params: tuple[object, ...] | None = None) -> None: + self.executed.append((query, params)) + + def fetchone(self) -> dict[str, Any] | None: + if not self.fetchone_results: + return None + return self.fetchone_results.pop(0) + + def fetchall(self) -> list[dict[str, Any]]: + if not self.fetchall_results: + return [] + return self.fetchall_results.pop(0) + + +class RecordingConnection: + def __init__(self, cursor: RecordingCursor) -> None: + self.cursor_instance = cursor + + def cursor(self) -> RecordingCursor: + return self.cursor_instance + + +def test_memory_methods_use_expected_queries_and_payload_serialization() -> None: + memory_id = uuid4() + event_id = uuid4() + cursor = RecordingCursor( + fetchone_results=[ + { + "id": memory_id, + "user_id": uuid4(), + "memory_key": "user.preference.coffee", + "value": {"likes": "black"}, + "status": "active", + "source_event_ids": [str(event_id)], + }, + { + "id": memory_id, + "user_id": uuid4(), + "memory_key": "user.preference.coffee", + "value": {"likes": "oat milk"}, + "status": "active", + "source_event_ids": [str(event_id)], + }, + { + "id": uuid4(), + "memory_id": memory_id, + "sequence_no": 1, + "action": "ADD", + "memory_key": "user.preference.coffee", + "previous_value": None, + "new_value": {"likes": "black"}, + "source_event_ids": [str(event_id)], + "candidate": {"memory_key": "user.preference.coffee"}, + }, + ], + fetchall_results=[ + [{"id": event_id, "sequence_no": 1}], + [{"sequence_no": 1, "action": "ADD"}], + ], + ) + store = ContinuityStore(RecordingConnection(cursor)) + + created = store.create_memory( + memory_key="user.preference.coffee", + value={"likes": "black"}, + status="active", + source_event_ids=[str(event_id)], + ) + updated = store.update_memory( + memory_id=memory_id, + value={"likes": "oat milk"}, + status="active", + source_event_ids=[str(event_id)], + ) + revision = store.append_memory_revision( + memory_id=memory_id, + action="ADD", + memory_key="user.preference.coffee", + previous_value=None, + new_value={"likes": "black"}, + source_event_ids=[str(event_id)], + candidate={"memory_key": "user.preference.coffee"}, + ) + listed_events = store.list_events_by_ids([event_id]) + listed_revisions = store.list_memory_revisions(memory_id) + listed_context_memories = store.list_context_memories() + + assert created["id"] == memory_id + assert updated["value"] == {"likes": "oat milk"} + assert revision["sequence_no"] == 1 + assert listed_events == [{"id": event_id, "sequence_no": 1}] + assert listed_revisions == [{"sequence_no": 1, "action": "ADD"}] + assert listed_context_memories == [] + + create_query, create_params = cursor.executed[0] + assert "INSERT INTO memories" in create_query + assert "clock_timestamp()" in create_query + assert create_params is not None + assert create_params[0] == "user.preference.coffee" + assert isinstance(create_params[1], Jsonb) + assert create_params[1].obj == {"likes": "black"} + assert create_params[2] == "active" + assert isinstance(create_params[3], Jsonb) + assert create_params[3].obj == [str(event_id)] + assert create_params[4] == "preference" + assert create_params[5] is None + assert create_params[6] is None + assert create_params[7] == "unconfirmed" + assert create_params[8] == "deterministic" + assert create_params[9] == "promotable" + assert create_params[10] is None + assert create_params[11] is None + assert create_params[12] is None + assert create_params[13] is None + assert create_params[14] is None + assert create_params[15] is None + assert create_params[16] is None + assert create_params[17] == "assistant_default" + + update_query, update_params = cursor.executed[1] + assert "UPDATE memories" in update_query + assert "updated_at = clock_timestamp()" in update_query + assert "THEN clock_timestamp()" in update_query + assert update_params is not None + assert isinstance(update_params[0], Jsonb) + assert update_params[0].obj == {"likes": "oat milk"} + assert update_params[1] == "active" + assert isinstance(update_params[2], Jsonb) + assert update_params[2].obj == [str(event_id)] + assert update_params[3] == "preference" + assert update_params[4] is None + assert update_params[5] is None + assert update_params[6] == "unconfirmed" + assert update_params[7] == "deterministic" + assert update_params[8] == "promotable" + assert update_params[9] is None + assert update_params[10] is None + assert update_params[11] is None + assert update_params[12] is None + assert update_params[13] is None + assert update_params[14] is None + assert update_params[15] is None + assert update_params[16] == "active" + assert update_params[17] == memory_id + + assert cursor.executed[2] == ( + "SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 1))", + (str(memory_id),), + ) + append_revision_query, append_revision_params = cursor.executed[3] + assert "INSERT INTO memory_revisions" in append_revision_query + assert append_revision_params is not None + assert append_revision_params[:4] == ( + memory_id, + memory_id, + "ADD", + "user.preference.coffee", + ) + assert isinstance(append_revision_params[4], Jsonb) + assert append_revision_params[4].obj is None + assert isinstance(append_revision_params[5], Jsonb) + assert append_revision_params[5].obj == {"likes": "black"} + assert isinstance(append_revision_params[6], Jsonb) + assert append_revision_params[6].obj == [str(event_id)] + assert isinstance(append_revision_params[7], Jsonb) + assert append_revision_params[7].obj == {"memory_key": "user.preference.coffee"} + assert cursor.executed[6] == ( + """ + SELECT + id, + user_id, + agent_profile_id, + memory_key, + value, + status, + source_event_ids, + memory_type, + confidence, + salience, + confirmation_status, + trust_class, + promotion_eligibility, + evidence_count, + independent_source_count, + extracted_by_model, + trust_reason, + valid_from, + valid_to, + last_confirmed_at, + created_at, + updated_at, + deleted_at + FROM memories + ORDER BY updated_at ASC, created_at ASC, id ASC + """, + None, + ) + + +def test_get_memory_by_key_returns_none_when_row_is_missing() -> None: + cursor = RecordingCursor(fetchone_results=[]) + store = ContinuityStore(RecordingConnection(cursor)) + + assert store.get_memory_by_key("user.preference.coffee") is None + + +def test_append_memory_revision_raises_clear_error_when_returning_row_is_missing() -> None: + cursor = RecordingCursor(fetchone_results=[]) + store = ContinuityStore(RecordingConnection(cursor)) + + with pytest.raises( + ContinuityStoreInvariantError, + match="append_memory_revision did not return a row", + ): + store.append_memory_revision( + memory_id=uuid4(), + action="ADD", + memory_key="user.preference.coffee", + previous_value=None, + new_value={"likes": "black"}, + source_event_ids=["event-1"], + candidate={"memory_key": "user.preference.coffee"}, + ) + + +def test_memory_review_read_methods_use_explicit_order_filter_and_limit() -> None: + memory_id = uuid4() + cursor = RecordingCursor( + fetchone_results=[ + { + "id": memory_id, + "user_id": uuid4(), + "memory_key": "user.preference.coffee", + "value": {"likes": "black"}, + "status": "active", + "source_event_ids": ["event-1"], + }, + {"count": 2}, + {"count": 3}, + ], + fetchall_results=[ + [{"id": memory_id, "memory_key": "user.preference.coffee"}], + [{"sequence_no": 1, "action": "ADD"}], + ], + ) + store = ContinuityStore(RecordingConnection(cursor)) + + memory = store.get_memory_optional(memory_id) + memory_count = store.count_memories(status="active") + listed_memories = store.list_review_memories(status="active", limit=5) + revision_count = store.count_memory_revisions(memory_id) + listed_revisions = store.list_memory_revisions(memory_id, limit=2) + + assert memory is not None + assert memory["id"] == memory_id + assert memory_count == 2 + assert listed_memories == [{"id": memory_id, "memory_key": "user.preference.coffee"}] + assert revision_count == 3 + assert listed_revisions == [{"sequence_no": 1, "action": "ADD"}] + assert cursor.executed == [ + ( + """ + SELECT + id, + user_id, + agent_profile_id, + memory_key, + value, + status, + source_event_ids, + memory_type, + confidence, + salience, + confirmation_status, + trust_class, + promotion_eligibility, + evidence_count, + independent_source_count, + extracted_by_model, + trust_reason, + valid_from, + valid_to, + last_confirmed_at, + created_at, + updated_at, + deleted_at + FROM memories + WHERE id = %s + """, + (memory_id,), + ), + ( + """ + SELECT COUNT(*) AS count + FROM memories + WHERE status = %s + """, + ("active",), + ), + ( + """ + SELECT + id, + user_id, + agent_profile_id, + memory_key, + value, + status, + source_event_ids, + memory_type, + confidence, + salience, + confirmation_status, + trust_class, + promotion_eligibility, + evidence_count, + independent_source_count, + extracted_by_model, + trust_reason, + valid_from, + valid_to, + last_confirmed_at, + created_at, + updated_at, + deleted_at + FROM memories + WHERE status = %s + ORDER BY updated_at DESC, created_at DESC, id DESC + LIMIT %s + """, + ("active", 5), + ), + ( + """ + SELECT COUNT(*) AS count + FROM memory_revisions + WHERE memory_id = %s + """, + (memory_id,), + ), + ( + """ + SELECT id, user_id, memory_id, sequence_no, action, memory_key, previous_value, new_value, source_event_ids, candidate, created_at + FROM memory_revisions + WHERE memory_id = %s + ORDER BY sequence_no ASC + LIMIT %s + """, + (memory_id, 2), + ), + ] + + +def test_memory_review_label_methods_use_append_only_queries_and_deterministic_order() -> None: + memory_id = uuid4() + reviewer_user_id = uuid4() + cursor = RecordingCursor( + fetchone_results=[ + { + "id": uuid4(), + "user_id": reviewer_user_id, + "memory_id": memory_id, + "label": "correct", + "note": "Supported by the latest event.", + "created_at": "2026-03-12T09:00:00+00:00", + } + ], + fetchall_results=[ + [ + { + "id": uuid4(), + "user_id": reviewer_user_id, + "memory_id": memory_id, + "label": "correct", + "note": "Supported by the latest event.", + "created_at": "2026-03-12T09:00:00+00:00", + } + ], + [ + {"label": "correct", "count": 1}, + {"label": "outdated", "count": 2}, + ], + ], + ) + store = ContinuityStore(RecordingConnection(cursor)) + + created = store.create_memory_review_label( + memory_id=memory_id, + label="correct", + note="Supported by the latest event.", + ) + listed = store.list_memory_review_labels(memory_id) + counts = store.list_memory_review_label_counts(memory_id) + + assert created["memory_id"] == memory_id + assert listed[0]["label"] == "correct" + assert counts == [{"label": "correct", "count": 1}, {"label": "outdated", "count": 2}] + assert cursor.executed == [ + ( + """ + INSERT INTO memory_review_labels (user_id, memory_id, label, note) + VALUES (app.current_user_id(), %s, %s, %s) + RETURNING id, user_id, memory_id, label, note, created_at + """, + (memory_id, "correct", "Supported by the latest event."), + ), + ( + """ + SELECT id, user_id, memory_id, label, note, created_at + FROM memory_review_labels + WHERE memory_id = %s + ORDER BY created_at ASC, id ASC + """, + (memory_id,), + ), + ( + """ + SELECT label, COUNT(*) AS count + FROM memory_review_labels + WHERE memory_id = %s + GROUP BY label + ORDER BY label ASC + """, + (memory_id,), + ), + ] + + +def test_open_loop_methods_use_expected_queries_and_lifecycle_serialization() -> None: + memory_id = uuid4() + open_loop_id = uuid4() + opened_at = datetime(2026, 3, 23, 11, 0, tzinfo=UTC) + due_at = datetime(2026, 3, 25, 9, 0, tzinfo=UTC) + resolved_at = datetime(2026, 3, 24, 9, 0, tzinfo=UTC) + cursor = RecordingCursor( + fetchone_results=[ + { + "id": open_loop_id, + "user_id": uuid4(), + "memory_id": memory_id, + "title": "Confirm magnesium reorder", + "status": "open", + "opened_at": opened_at, + "due_at": due_at, + "resolved_at": None, + "resolution_note": None, + "created_at": opened_at, + "updated_at": opened_at, + }, + { + "id": open_loop_id, + "user_id": uuid4(), + "memory_id": memory_id, + "title": "Confirm magnesium reorder", + "status": "open", + "opened_at": opened_at, + "due_at": due_at, + "resolved_at": None, + "resolution_note": None, + "created_at": opened_at, + "updated_at": opened_at, + }, + {"count": 1}, + {"count": 1}, + { + "id": open_loop_id, + "user_id": uuid4(), + "memory_id": memory_id, + "title": "Confirm magnesium reorder", + "status": "resolved", + "opened_at": opened_at, + "due_at": due_at, + "resolved_at": resolved_at, + "resolution_note": "Resolved after order confirmation.", + "created_at": opened_at, + "updated_at": resolved_at, + }, + ], + fetchall_results=[ + [ + { + "id": open_loop_id, + "memory_id": memory_id, + "status": "open", + "opened_at": opened_at, + } + ], + [ + { + "id": open_loop_id, + "memory_id": memory_id, + "status": "open", + "opened_at": opened_at, + } + ], + ], + ) + store = ContinuityStore(RecordingConnection(cursor)) + + created = store.create_open_loop( + memory_id=memory_id, + title="Confirm magnesium reorder", + status="open", + opened_at=None, + due_at=due_at, + resolved_at=None, + resolution_note=None, + ) + detail = store.get_open_loop_optional(open_loop_id) + listed_all = store.list_open_loops(limit=5) + listed_open = store.list_open_loops(status="open", limit=3) + count_all = store.count_open_loops() + count_open = store.count_open_loops(status="open") + updated = store.update_open_loop_status_optional( + open_loop_id=open_loop_id, + status="resolved", + resolved_at=None, + resolution_note="Resolved after order confirmation.", + ) + + assert created["id"] == open_loop_id + assert detail is not None + assert detail["status"] == "open" + assert listed_all[0]["id"] == open_loop_id + assert listed_open[0]["status"] == "open" + assert count_all == 1 + assert count_open == 1 + assert updated is not None + assert updated["status"] == "resolved" + assert updated["resolved_at"] == resolved_at + + assert cursor.executed[0] == ( + """ + INSERT INTO open_loops ( + user_id, + memory_id, + title, + status, + opened_at, + due_at, + resolved_at, + resolution_note, + created_at, + updated_at + ) + VALUES ( + app.current_user_id(), + %s, + %s, + %s, + COALESCE(%s, clock_timestamp()), + %s, + %s, + %s, + clock_timestamp(), + clock_timestamp() + ) + RETURNING + id, + user_id, + memory_id, + title, + status, + opened_at, + due_at, + resolved_at, + resolution_note, + created_at, + updated_at + """, + (memory_id, "Confirm magnesium reorder", "open", None, due_at, None, None), + ) + assert cursor.executed[1] == ( + """ + SELECT + id, + user_id, + memory_id, + title, + status, + opened_at, + due_at, + resolved_at, + resolution_note, + created_at, + updated_at + FROM open_loops + WHERE id = %s + """, + (open_loop_id,), + ) + assert cursor.executed[2] == ( + """ + SELECT + id, + user_id, + memory_id, + title, + status, + opened_at, + due_at, + resolved_at, + resolution_note, + created_at, + updated_at + FROM open_loops + ORDER BY opened_at DESC, created_at DESC, id DESC + LIMIT %s + """, + (5,), + ) + assert cursor.executed[3] == ( + """ + SELECT + id, + user_id, + memory_id, + title, + status, + opened_at, + due_at, + resolved_at, + resolution_note, + created_at, + updated_at + FROM open_loops + WHERE status = %s + ORDER BY opened_at DESC, created_at DESC, id DESC + LIMIT %s + """, + ("open", 3), + ) + assert cursor.executed[4] == ( + """ + SELECT COUNT(*) AS count + FROM open_loops + """, + None, + ) + assert cursor.executed[5] == ( + """ + SELECT COUNT(*) AS count + FROM open_loops + WHERE status = %s + """, + ("open",), + ) + assert cursor.executed[6] == ( + """ + UPDATE open_loops + SET status = %s, + resolved_at = CASE + WHEN %s = 'open' THEN NULL + ELSE COALESCE(%s, clock_timestamp()) + END, + resolution_note = CASE + WHEN %s = 'open' THEN NULL + ELSE %s + END, + updated_at = clock_timestamp() + WHERE id = %s + RETURNING + id, + user_id, + memory_id, + title, + status, + opened_at, + due_at, + resolved_at, + resolution_note, + created_at, + updated_at + """, + ( + "resolved", + "resolved", + None, + "resolved", + "Resolved after order confirmation.", + open_loop_id, + ), + ) diff --git a/tests/unit/test_openclaw_adapter.py b/tests/unit/test_openclaw_adapter.py new file mode 100644 index 0000000..554f647 --- /dev/null +++ b/tests/unit/test_openclaw_adapter.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from alicebot_api.openclaw_adapter import OpenClawAdapterValidationError, load_openclaw_payload + + +REPO_ROOT = Path(__file__).resolve().parents[2] +FIXTURE_PATH = REPO_ROOT / "fixtures" / "openclaw" / "workspace_v1.json" +DIRECTORY_FIXTURE_PATH = REPO_ROOT / "fixtures" / "openclaw" / "workspace_dir_v1" + + +def test_openclaw_adapter_loads_fixture_with_deterministic_mapping() -> None: + batch = load_openclaw_payload(FIXTURE_PATH) + + assert batch.context.fixture_id == "openclaw-s36-workspace-v1" + assert batch.context.workspace_id == "openclaw-workspace-demo-001" + assert batch.context.workspace_name == "OpenClaw Interop Demo" + assert len(batch.items) == 5 + + first = batch.items[0] + assert first.source_item_id == "oc-memory-001" + assert first.object_type == "Decision" + assert first.status == "active" + assert first.raw_content == "Decision: Keep MCP tool surface narrow during Phase 9 interop rollout." + assert first.title == "Decision: Keep MCP tool surface narrow during Phase 9 interop rollout." + assert first.body["decision_text"] == "Keep MCP tool surface narrow during Phase 9 interop rollout." + assert first.source_provenance["thread_id"] == "cccccccc-cccc-4ccc-8ccc-cccccccccccc" + assert first.source_provenance["task_id"] == "dddddddd-dddd-4ddd-8ddd-dddddddddddd" + assert first.source_provenance["project"] == "Alice Public Core" + assert first.source_provenance["person"] == "Interop Owner" + assert first.source_provenance["source_event_ids"] == ["openclaw-event-0001"] + assert first.confidence == 0.97 + assert len(first.dedupe_key) == 64 + + +def test_openclaw_adapter_emits_stable_dedupe_keys() -> None: + first = load_openclaw_payload(FIXTURE_PATH) + second = load_openclaw_payload(FIXTURE_PATH) + + assert [item.dedupe_key for item in first.items] == [item.dedupe_key for item in second.items] + + +def test_openclaw_adapter_supports_directory_workspace_contract(tmp_path: Path) -> None: + workspace_payload = { + "workspace": { + "id": "oc-ws-dir-1", + "name": "Directory Workspace", + } + } + memory_payload = { + "durable_memory": [ + { + "id": "oc-dir-001", + "type": "next_action", + "content": "Ship directory contract parsing.", + "thread_id": "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + } + ] + } + + (tmp_path / "workspace.json").write_text(json.dumps(workspace_payload), encoding="utf-8") + (tmp_path / "durable_memory.json").write_text(json.dumps(memory_payload), encoding="utf-8") + + batch = load_openclaw_payload(tmp_path) + + assert batch.context.workspace_id == "oc-ws-dir-1" + assert batch.context.workspace_name == "Directory Workspace" + assert len(batch.items) == 1 + assert batch.items[0].object_type == "NextAction" + + +def test_openclaw_adapter_loads_shipped_directory_fixture_with_multiple_memory_keys() -> None: + first = load_openclaw_payload(DIRECTORY_FIXTURE_PATH) + second = load_openclaw_payload(DIRECTORY_FIXTURE_PATH) + + assert first.context.fixture_id == "openclaw-s39-workspace-dir-v1" + assert first.context.workspace_id == "openclaw-workspace-dir-demo-001" + assert first.context.workspace_name == "OpenClaw Directory Interop Demo" + assert len(first.items) == 4 + + item_ids = [item.source_item_id for item in first.items] + assert item_ids == ["oc-dir-memory-001", "oc-dir-memory-002", "oc-dir-memory-003", "oc-dir-memory-003"] + assert [item.dedupe_key for item in first.items] == [item.dedupe_key for item in second.items] + + +def test_openclaw_adapter_rejects_invalid_payload() -> None: + with pytest.raises(OpenClawAdapterValidationError, match="invalid JSON"): + load_openclaw_payload(REPO_ROOT / "pyproject.toml") + + +def test_openclaw_adapter_rejects_unknown_status_value(tmp_path: Path) -> None: + payload = { + "workspace": { + "id": "oc-ws-status-1", + "name": "Status Validation Workspace", + }, + "durable_memory": [ + { + "id": "oc-status-001", + "type": "decision", + "status": "paused", + "content": "Do not silently coerce unknown statuses.", + } + ], + } + source = tmp_path / "workspace.json" + source.write_text(json.dumps(payload), encoding="utf-8") + + with pytest.raises(OpenClawAdapterValidationError, match="status must be one of"): + load_openclaw_payload(source) diff --git a/tests/unit/test_ops_assets.py b/tests/unit/test_ops_assets.py new file mode 100644 index 0000000..ddabcbb --- /dev/null +++ b/tests/unit/test_ops_assets.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def test_dev_up_waits_for_postgres_and_role_bootstrap() -> None: + script = (REPO_ROOT / "scripts" / "dev_up.sh").read_text() + + assert "Timed out waiting for Postgres readiness and alicebot_app bootstrap" in script + assert "SELECT 1 FROM pg_roles WHERE rolname = %s" in script + + +def test_runtime_role_init_only_grants_connect_on_alicebot_database() -> None: + init_sql = (REPO_ROOT / "infra" / "postgres" / "init" / "001_roles.sql").read_text() + + assert "GRANT CONNECT ON DATABASE alicebot TO alicebot_app;" in init_sql + assert "GRANT CONNECT ON DATABASE postgres TO alicebot_app;" not in init_sql diff --git a/tests/unit/test_phase10_beta_hardening_helpers.py b/tests/unit/test_phase10_beta_hardening_helpers.py new file mode 100644 index 0000000..cf3910e --- /dev/null +++ b/tests/unit/test_phase10_beta_hardening_helpers.py @@ -0,0 +1,342 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from typing import Any +from uuid import uuid4 + +import pytest + +from alicebot_api.config import Settings +import alicebot_api.hosted_rate_limits as hosted_rate_limits +import alicebot_api.hosted_rollout as hosted_rollout +import alicebot_api.hosted_telemetry as hosted_telemetry + + +class RecordingCursor: + def __init__( + self, + *, + fetchone_results: list[dict[str, Any] | None] | None = None, + fetchall_results: list[list[dict[str, Any]]] | None = None, + ) -> None: + self.executed: list[tuple[str, tuple[object, ...] | None]] = [] + self._fetchone_results = list(fetchone_results or []) + self._fetchall_results = list(fetchall_results or []) + + def __enter__(self) -> "RecordingCursor": + return self + + def __exit__(self, exc_type, exc, tb) -> None: + return None + + def execute(self, query: str, params: tuple[object, ...] | None = None) -> None: + self.executed.append((query, params)) + + def fetchone(self) -> dict[str, Any] | None: + if not self._fetchone_results: + return None + return self._fetchone_results.pop(0) + + def fetchall(self) -> list[dict[str, Any]]: + if not self._fetchall_results: + return [] + return self._fetchall_results.pop(0) + + +class RecordingConnection: + def __init__(self, cursor: RecordingCursor) -> None: + self.cursor_instance = cursor + + def cursor(self) -> RecordingCursor: + return self.cursor_instance + + +def _base_settings() -> Settings: + return Settings( + app_env="test", + hosted_chat_rate_limit_window_seconds=60, + hosted_chat_rate_limit_max_requests=1, + hosted_scheduler_rate_limit_window_seconds=300, + hosted_scheduler_rate_limit_max_requests=1, + hosted_abuse_window_seconds=120, + hosted_abuse_block_threshold=2, + hosted_rate_limits_enabled_by_default=True, + hosted_abuse_controls_enabled_by_default=True, + ) + + +def test_resolve_rollout_flag_returns_missing_when_flag_is_absent() -> None: + cursor = RecordingCursor( + fetchone_results=[ + {"beta_cohort_key": "p10-beta"}, + None, + ] + ) + conn = RecordingConnection(cursor) + + resolved = hosted_rollout.resolve_rollout_flag( + conn, + user_account_id=uuid4(), + flag_key="hosted_chat_handle_enabled", + ) + + assert resolved == { + "flag_key": "hosted_chat_handle_enabled", + "enabled": False, + "source_scope": "missing", + "source_cohort_key": None, + "description": None, + "updated_at": "", + } + assert "FROM user_accounts" in cursor.executed[0][0] + assert "FROM feature_flags" in cursor.executed[1][0] + + +def test_ensure_rollout_flag_enabled_raises_when_disabled(monkeypatch) -> None: + monkeypatch.setattr( + hosted_rollout, + "resolve_rollout_flag", + lambda *_args, **_kwargs: { + "flag_key": "hosted_scheduler_delivery_enabled", + "enabled": False, + "source_scope": "global", + "source_cohort_key": None, + "description": None, + "updated_at": "2026-04-09T00:00:00+00:00", + }, + ) + + with pytest.raises(hosted_rollout.RolloutFlagBlockedError, match="disabled"): + hosted_rollout.ensure_rollout_flag_enabled( + object(), + user_account_id=uuid4(), + flag_key="hosted_scheduler_delivery_enabled", + ) + + +def test_list_rollout_flags_for_admin_prefers_cohort_over_global() -> None: + now = datetime(2026, 4, 9, 8, 0, tzinfo=UTC) + cursor = RecordingCursor( + fetchone_results=[{"beta_cohort_key": "p10-beta"}], + fetchall_results=[ + [ + { + "id": uuid4(), + "flag_key": "hosted_chat_handle_enabled", + "cohort_key": "p10-beta", + "enabled": True, + "description": "cohort override", + "created_at": now, + "updated_at": now, + }, + { + "id": uuid4(), + "flag_key": "hosted_chat_handle_enabled", + "cohort_key": None, + "enabled": False, + "description": "global", + "created_at": now, + "updated_at": now - timedelta(minutes=1), + }, + { + "id": uuid4(), + "flag_key": "hosted_rate_limits_enabled", + "cohort_key": None, + "enabled": True, + "description": "global", + "created_at": now, + "updated_at": now, + }, + ] + ], + ) + conn = RecordingConnection(cursor) + + items = hosted_rollout.list_rollout_flags_for_admin(conn, user_account_id=uuid4()) + + assert [item["flag_key"] for item in items] == [ + "hosted_chat_handle_enabled", + "hosted_rate_limits_enabled", + ] + assert items[0]["enabled"] is True + assert items[0]["source_scope"] == "cohort" + + +def test_patch_rollout_flags_rejects_unknown_cohort() -> None: + cursor = RecordingCursor(fetchone_results=[None]) + conn = RecordingConnection(cursor) + + with pytest.raises(ValueError, match="was not found"): + hosted_rollout.patch_rollout_flags( + conn, + patches=[ + { + "flag_key": "hosted_admin_read", + "enabled": True, + "cohort_key": "missing-cohort", + "description": "test", + } + ], + allowed_cohort_key="missing-cohort", + ) + + +def test_patch_rollout_flags_rejects_non_hosted_flag_keys() -> None: + cursor = RecordingCursor() + conn = RecordingConnection(cursor) + + with pytest.raises(ValueError, match="must start with 'hosted_'"): + hosted_rollout.patch_rollout_flags( + conn, + patches=[ + { + "flag_key": "calendar_ingest_enabled", + "enabled": True, + "cohort_key": "p10-beta", + "description": "out-of-scope flag", + } + ], + allowed_cohort_key="p10-beta", + ) + + +def test_evaluate_hosted_flow_limits_blocks_when_rate_limit_is_exceeded() -> None: + now = datetime(2026, 4, 9, 9, 0, tzinfo=UTC) + cursor = RecordingCursor( + fetchone_results=[ + { + "total_count": 1, + "oldest_created_at": now - timedelta(seconds=10), + }, + { + "blocked_count": 0, + "oldest_created_at": None, + }, + ] + ) + conn = RecordingConnection(cursor) + + decision = hosted_rate_limits.evaluate_hosted_flow_limits( + conn, + settings=_base_settings(), + user_account_id=uuid4(), + workspace_id=uuid4(), + flow_kind="chat_handle", + now=now, + ) + + assert decision["allowed"] is False + assert decision["code"] == "hosted_rate_limit_exceeded" + assert decision["observed_requests"] == 1 + assert decision["retry_after_seconds"] == 50 + + +def test_evaluate_hosted_flow_limits_blocks_when_abuse_threshold_is_reached() -> None: + now = datetime(2026, 4, 9, 9, 0, tzinfo=UTC) + settings = Settings( + app_env="test", + hosted_chat_rate_limit_window_seconds=60, + hosted_chat_rate_limit_max_requests=1, + hosted_scheduler_rate_limit_window_seconds=300, + hosted_scheduler_rate_limit_max_requests=1, + hosted_abuse_window_seconds=120, + hosted_abuse_block_threshold=1, + hosted_rate_limits_enabled_by_default=True, + hosted_abuse_controls_enabled_by_default=True, + ) + + cursor = RecordingCursor( + fetchone_results=[ + { + "total_count": 0, + "oldest_created_at": None, + }, + { + "blocked_count": 1, + "oldest_created_at": now - timedelta(seconds=15), + }, + ] + ) + conn = RecordingConnection(cursor) + + decision = hosted_rate_limits.evaluate_hosted_flow_limits( + conn, + settings=settings, + user_account_id=uuid4(), + workspace_id=uuid4(), + flow_kind="chat_handle", + now=now, + ) + + assert decision["allowed"] is False + assert decision["code"] == "hosted_abuse_limit_exceeded" + assert decision["abuse_signal"] == "repeated_rate_limit_violations" + assert decision["retry_after_seconds"] == 105 + + +def test_record_chat_telemetry_requires_non_empty_route_path() -> None: + with pytest.raises(ValueError, match="route_path is required"): + hosted_telemetry.record_chat_telemetry( + object(), + user_account_id=uuid4(), + workspace_id=None, + flow_kind="chat_handle", + event_kind="attempt", + status="ok", + route_path=" ", + ) + + +def test_aggregate_chat_telemetry_rolls_up_flow_status_and_hourly_rows(monkeypatch) -> None: + now = datetime(2026, 4, 9, 10, 0, tzinfo=UTC) + cursor = RecordingCursor( + fetchall_results=[ + [ + { + "flow_kind": "chat_handle", + "status": "ok", + "total_count": 3, + }, + { + "flow_kind": "chat_handle", + "status": "failed", + "total_count": 1, + }, + { + "flow_kind": "scheduler_daily_brief", + "status": "rate_limited", + "total_count": 2, + }, + ], + [ + { + "hour_bucket": now - timedelta(hours=1), + "total_count": 6, + "ok_count": 3, + "failed_count": 1, + "blocked_rollout_count": 0, + "rate_limited_count": 2, + "abuse_blocked_count": 0, + } + ], + ] + ) + conn = RecordingConnection(cursor) + + monkeypatch.setattr(hosted_telemetry, "utc_now", lambda: now) + + payload = hosted_telemetry.aggregate_chat_telemetry(conn, window_hours=24) + + assert payload["window_hours"] == 24 + assert payload["total_events"] == 6 + assert payload["flow_counts"] == { + "chat_handle": 4, + "scheduler_daily_brief": 2, + } + assert payload["status_counts"] == { + "ok": 3, + "failed": 1, + "rate_limited": 2, + } + assert payload["flow_status_matrix"]["chat_handle"]["ok"] == 3 + assert payload["hourly"][0]["total_count"] == 6 diff --git a/tests/unit/test_phase10_hosted_modules.py b/tests/unit/test_phase10_hosted_modules.py new file mode 100644 index 0000000..ed97441 --- /dev/null +++ b/tests/unit/test_phase10_hosted_modules.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import pytest + +from alicebot_api.hosted_auth import hash_token, normalize_email +from alicebot_api.hosted_preferences import HostedPreferencesValidationError, validate_timezone +from alicebot_api.hosted_workspace import slugify_workspace_name + + +def test_normalize_email_and_hash_token_are_deterministic() -> None: + assert normalize_email(" Builder@Example.COM ") == "builder@example.com" + assert hash_token("phase10-token") == hash_token("phase10-token") + assert len(hash_token("phase10-token")) == 64 + + +def test_normalize_email_rejects_invalid_shape() -> None: + with pytest.raises(ValueError, match="valid"): + normalize_email("missing-at-symbol") + + +def test_slugify_workspace_name_collapses_symbols_and_whitespace() -> None: + assert slugify_workspace_name(" Builder Workspace: Alpha / Beta ") == "builder-workspace-alpha-beta" + assert slugify_workspace_name("!!!") == "alice-workspace" + + +def test_validate_timezone_requires_known_zoneinfo_key() -> None: + assert validate_timezone("Europe/Stockholm") == "Europe/Stockholm" + with pytest.raises(HostedPreferencesValidationError, match="not recognized"): + validate_timezone("Mars/Olympus") diff --git a/tests/unit/test_phase2_gate_wrappers.py b/tests/unit/test_phase2_gate_wrappers.py new file mode 100644 index 0000000..289a96b --- /dev/null +++ b/tests/unit/test_phase2_gate_wrappers.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path +from types import ModuleType, SimpleNamespace + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[2] + +MVP_ALIAS_CASES = ( + ("run_mvp_acceptance.py", "run_phase2_acceptance.py"), + ("run_mvp_readiness_gates.py", "run_phase2_readiness_gates.py"), + ("run_mvp_validation_matrix.py", "run_phase2_validation_matrix.py"), +) + + +PHASE2_CANONICAL_SCRIPTS = ( + "run_phase2_acceptance.py", + "run_phase2_readiness_gates.py", + "run_phase2_validation_matrix.py", +) + + +def _load_script_module(script_name: str) -> ModuleType: + script_path = REPO_ROOT / "scripts" / script_name + spec = importlib.util.spec_from_file_location(f"test_{script_name.replace('.', '_')}", script_path) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.mark.parametrize(("alias_script", "target_script"), MVP_ALIAS_CASES) +def test_mvp_alias_target_mapping_is_stable(alias_script: str, target_script: str) -> None: + module = _load_script_module(alias_script) + + assert module.TARGET_SCRIPT == module.ROOT_DIR / "scripts" / target_script + + +@pytest.mark.parametrize(("alias_script", "target_script"), MVP_ALIAS_CASES) +def test_mvp_alias_main_forwards_args_and_exit_code( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, alias_script: str, target_script: str +) -> None: + module = _load_script_module(alias_script) + fake_root = tmp_path / "repo" + fake_root.mkdir() + fake_target = fake_root / "scripts" / target_script + fake_python = str(fake_root / ".venv" / "bin" / "python") + forwarded_args = ["--dry-run", "--limit=3", "value with spaces", "--flag=true"] + call: dict[str, object] = {} + + def fake_run(command, cwd, check): # noqa: ANN001 + call["command"] = command + call["cwd"] = cwd + call["check"] = check + return SimpleNamespace(returncode=23) + + monkeypatch.setattr(module, "ROOT_DIR", fake_root) + monkeypatch.setattr(module, "TARGET_SCRIPT", fake_target) + monkeypatch.setattr(module, "_resolve_python_executable", lambda: fake_python) + monkeypatch.setattr( + module.sys, + "argv", + [str(REPO_ROOT / "scripts" / alias_script), *forwarded_args], + ) + monkeypatch.setattr(module.subprocess, "run", fake_run) + + assert module.main() == 23 + assert call == { + "command": [fake_python, str(fake_target), *forwarded_args], + "cwd": fake_root, + "check": False, + } + + +@pytest.mark.parametrize(("alias_script", "_target_script"), MVP_ALIAS_CASES) +def test_mvp_alias_resolve_python_executable_prefers_repo_venv( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, alias_script: str, _target_script: str +) -> None: + module = _load_script_module(alias_script) + fake_root = tmp_path / "repo" + venv_python = fake_root / ".venv" / "bin" / "python" + venv_python.parent.mkdir(parents=True) + venv_python.write_text("#!/usr/bin/env python3\n") + + monkeypatch.setattr(module, "ROOT_DIR", fake_root) + monkeypatch.setattr(module.sys, "executable", "/usr/bin/system-python") + + assert module._resolve_python_executable() == str(venv_python) + + +@pytest.mark.parametrize(("alias_script", "_target_script"), MVP_ALIAS_CASES) +def test_mvp_alias_resolve_python_executable_falls_back_to_sys_executable( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, alias_script: str, _target_script: str +) -> None: + module = _load_script_module(alias_script) + fake_root = tmp_path / "repo" + fake_root.mkdir() + fallback_python = "/usr/local/bin/fallback-python" + + monkeypatch.setattr(module, "ROOT_DIR", fake_root) + monkeypatch.setattr(module.sys, "executable", fallback_python) + + assert module._resolve_python_executable() == fallback_python + + +@pytest.mark.parametrize("phase2_script", PHASE2_CANONICAL_SCRIPTS) +def test_phase2_scripts_do_not_delegate_to_mvp_scripts(phase2_script: str) -> None: + script_path = REPO_ROOT / "scripts" / phase2_script + script_text = script_path.read_text() + + assert "run_mvp_" not in script_text + + +def test_phase2_readiness_gate_calls_phase2_acceptance() -> None: + script_path = REPO_ROOT / "scripts" / "run_phase2_readiness_gates.py" + script_text = script_path.read_text() + + assert "scripts/run_phase2_acceptance.py" in script_text + + +def test_phase2_validation_matrix_calls_phase2_readiness() -> None: + script_path = REPO_ROOT / "scripts" / "run_phase2_validation_matrix.py" + script_text = script_path.read_text() + + assert "scripts/run_phase2_readiness_gates.py" in script_text diff --git a/tests/unit/test_phase4_gate_wrappers.py b/tests/unit/test_phase4_gate_wrappers.py new file mode 100644 index 0000000..db549fc --- /dev/null +++ b/tests/unit/test_phase4_gate_wrappers.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path +import sys +from types import ModuleType + + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def _load_script_module(script_name: str) -> ModuleType: + script_path = REPO_ROOT / "scripts" / script_name + spec = importlib.util.spec_from_file_location(f"test_{script_name.replace('.', '_')}", script_path) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def test_phase4_acceptance_is_not_phase3_wrapper_delegate() -> None: + script_text = (REPO_ROOT / "scripts" / "run_phase4_acceptance.py").read_text() + + assert "TARGET_SCRIPT" not in script_text + assert "scripts/run_phase3_acceptance.py" not in script_text + + +def test_phase4_readiness_is_not_phase3_wrapper_delegate() -> None: + script_text = (REPO_ROOT / "scripts" / "run_phase4_readiness_gates.py").read_text() + + assert "TARGET_SCRIPT" not in script_text + + +def test_phase4_acceptance_includes_canonical_magnesium_mapping() -> None: + module = _load_script_module("run_phase4_acceptance.py") + + scenario_ids = [scenario.scenario for scenario in module.ACCEPTANCE_SCENARIOS] + assert scenario_ids == [ + "response_memory", + "capture_resumption", + "approval_execution", + "magnesium_reorder", + ] + + magnesium = next( + scenario for scenario in module.ACCEPTANCE_SCENARIOS if scenario.scenario == "magnesium_reorder" + ) + assert "canonical MVP ship gate" in magnesium.evidence + assert ( + magnesium.node_id + == "tests/integration/test_mvp_acceptance_suite.py::" + "test_acceptance_canonical_magnesium_reorder_flow_with_memory_write_back_evidence" + ) + + +def test_phase4_readiness_gate_contract_sequence_is_stable() -> None: + module = _load_script_module("run_phase4_readiness_gates.py") + + gate_steps = module.build_readiness_gate_steps(python_executable="/usr/bin/python3") + assert [gate.gate for gate in gate_steps] == [ + module.GATE_PHASE4_ACCEPTANCE, + module.GATE_MAGNESIUM_SHIP_GATE, + module.GATE_PHASE3_COMPAT, + ] + + assert gate_steps[0].command == ("/usr/bin/python3", "scripts/run_phase4_acceptance.py") + assert gate_steps[1].command == ( + "/usr/bin/python3", + "-m", + "pytest", + "-q", + module.MAGNESIUM_NODE_ID, + ) + assert gate_steps[2].command == ("/usr/bin/python3", "scripts/run_phase3_readiness_gates.py") + + +def test_phase4_release_candidate_rehearsal_contract_sequence_is_stable() -> None: + module = _load_script_module("run_phase4_release_candidate.py") + + steps = module.build_release_candidate_steps(python_executable="/usr/bin/python3") + assert [step.step for step in steps] == [ + module.STEP_CONTROL_DOC_TRUTH, + module.STEP_PHASE4_ACCEPTANCE, + module.STEP_PHASE4_READINESS, + module.STEP_PHASE4_VALIDATION_MATRIX, + module.STEP_PHASE3_COMPAT_VALIDATION, + module.STEP_PHASE2_COMPAT_VALIDATION, + module.STEP_MVP_COMPAT_VALIDATION, + ] + + assert steps[0].command == ("/usr/bin/python3", "scripts/check_control_doc_truth.py") + assert steps[1].command == ("/usr/bin/python3", "scripts/run_phase4_acceptance.py") + assert steps[2].command == ("/usr/bin/python3", "scripts/run_phase4_readiness_gates.py") + assert steps[3].command == ("/usr/bin/python3", "scripts/run_phase4_validation_matrix.py") + assert steps[4].command == ("/usr/bin/python3", "scripts/run_phase3_validation_matrix.py") + assert steps[5].command == ("/usr/bin/python3", "scripts/run_phase2_validation_matrix.py") + assert steps[6].command == ("/usr/bin/python3", "scripts/run_mvp_validation_matrix.py") + + +def test_phase4_release_candidate_lock_timeout_exit_contract_is_explicit( + monkeypatch, + capsys, +) -> None: + module = _load_script_module("run_phase4_release_candidate.py") + step_result = module.ReleaseCandidateStepResult( + step=module.STEP_CONTROL_DOC_TRUTH, + description="Validate control-doc truth markers.", + status="PASS", + exit_code=0, + duration_seconds=0.1, + command=("/usr/bin/python3", "scripts/check_control_doc_truth.py"), + induced_failure=False, + ) + + def _fake_run_release_candidate(*, induce_step=None, execute_command=module._execute_command): + del induce_step, execute_command + return [step_result] + + def _fake_write_release_candidate_summary(**kwargs): + del kwargs + raise module.ArchiveIndexLockTimeoutError( + "Timed out acquiring archive index lock at artifacts/release/archive/index.lock after 0.02s." + ) + + monkeypatch.setattr(module, "run_release_candidate", _fake_run_release_candidate) + monkeypatch.setattr(module, "write_release_candidate_summary", _fake_write_release_candidate_summary) + + exit_code = module.main([]) + assert exit_code == module.ARCHIVE_INDEX_LOCK_TIMEOUT_EXIT_CODE + stdout = capsys.readouterr().out + assert "Phase 4 release-candidate archive update failed:" in stdout + assert "Timed out acquiring archive index lock" in stdout + + +def test_phase4_mvp_exit_manifest_generator_contract_is_stable() -> None: + module = _load_script_module("generate_phase4_mvp_exit_manifest.py") + + assert module.MANIFEST_ARTIFACT_VERSION == "phase4_mvp_exit_manifest.v1" + assert module.DEFAULT_MANIFEST_PATH == ( + REPO_ROOT / "artifacts" / "release" / "phase4_mvp_exit_manifest.json" + ) + assert module.REQUIRED_COMPATIBILITY_COMMANDS == ( + "python3 scripts/run_phase4_validation_matrix.py", + "python3 scripts/run_phase3_validation_matrix.py", + "python3 scripts/run_phase2_validation_matrix.py", + "python3 scripts/run_mvp_validation_matrix.py", + ) + + +def test_phase4_mvp_exit_manifest_verifier_default_path_contract_is_stable() -> None: + module = _load_script_module("verify_phase4_mvp_exit_manifest.py") + + assert module.DEFAULT_MANIFEST_PATH == ( + REPO_ROOT / "artifacts" / "release" / "phase4_mvp_exit_manifest.json" + ) + + +def test_phase4_mvp_qualification_contract_sequence_is_stable() -> None: + module = _load_script_module("run_phase4_mvp_qualification.py") + + steps = module.build_qualification_steps( + python_executable="/usr/bin/python3", + rc_summary_path=Path("/tmp/phase4_rc_summary.json"), + rc_archive_index_path=Path("/tmp/archive/index.json"), + mvp_exit_manifest_path=Path("/tmp/phase4_mvp_exit_manifest.json"), + ) + + assert [step.step for step in steps] == [ + module.STEP_RELEASE_CANDIDATE_REHEARSAL, + module.STEP_RELEASE_CANDIDATE_ARCHIVE_VERIFY, + module.STEP_MVP_EXIT_MANIFEST_GENERATE, + module.STEP_MVP_EXIT_MANIFEST_VERIFY, + ] + assert steps[0].command == ("/usr/bin/python3", "scripts/run_phase4_release_candidate.py") + assert steps[1].command == ("/usr/bin/python3", "scripts/verify_phase4_rc_archive.py") + assert steps[2].command == ("/usr/bin/python3", "scripts/generate_phase4_mvp_exit_manifest.py") + assert steps[3].command == ("/usr/bin/python3", "scripts/verify_phase4_mvp_exit_manifest.py") + + +def test_phase4_mvp_signoff_verifier_default_path_contract_is_stable() -> None: + module = _load_script_module("verify_phase4_mvp_signoff_record.py") + + assert module.DEFAULT_SIGNOFF_PATH == ( + REPO_ROOT / "artifacts" / "release" / "phase4_mvp_signoff_record.json" + ) diff --git a/tests/unit/test_phase9_eval.py b/tests/unit/test_phase9_eval.py new file mode 100644 index 0000000..aecee1e --- /dev/null +++ b/tests/unit/test_phase9_eval.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from alicebot_api.retrieval_evaluation import ( + _public_source_path, + calculate_phase9_metric_ratio, + write_phase9_evaluation_report, +) + + +def test_phase9_ratio_handles_zero_total() -> None: + assert calculate_phase9_metric_ratio(passed_count=0, total_count=0) == 0.0 + + +def test_phase9_ratio_calculates_fraction() -> None: + assert calculate_phase9_metric_ratio(passed_count=2, total_count=4) == 0.5 + + +def test_phase9_report_writer_persists_json(tmp_path: Path) -> None: + report = { + "schema_version": "phase9_eval_v1", + "summary": { + "status": "pass", + "importer_count": 3, + }, + } + + output_path = write_phase9_evaluation_report( + report=report, + report_path=tmp_path / "phase9_eval.json", + ) + + assert output_path.exists() + saved = json.loads(output_path.read_text(encoding="utf-8")) + assert saved == report + + +def test_public_source_path_uses_repo_relative_path_for_repo_files() -> None: + repo_fixture = Path("fixtures/openclaw/workspace_v1.json").resolve() + assert _public_source_path(repo_fixture) == "fixtures/openclaw/workspace_v1.json" + + +def test_public_source_path_redacts_external_paths(tmp_path: Path) -> None: + external = tmp_path / "sensitive-source.json" + external.write_text("{}", encoding="utf-8") + assert _public_source_path(external) == "external/sensitive-source.json" diff --git a/tests/unit/test_policy.py b/tests/unit/test_policy.py new file mode 100644 index 0000000..49918f7 --- /dev/null +++ b/tests/unit/test_policy.py @@ -0,0 +1,519 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from uuid import UUID, uuid4 + +import pytest + +from alicebot_api.contracts import ConsentUpsertInput, PolicyCreateInput, PolicyEvaluationRequestInput +from alicebot_api.policy import ( + PolicyEvaluationValidationError, + PolicyNotFoundError, + create_policy_record, + evaluate_policy_request, + get_policy_record, + list_consent_records, + list_policy_records, + upsert_consent_record, +) + + +class PolicyStoreStub: + def __init__(self) -> None: + self.base_time = datetime(2026, 3, 12, 9, 0, tzinfo=UTC) + self.user_id = uuid4() + self.thread_id = uuid4() + self.thread_agent_profile_id = "assistant_default" + self.consents: dict[str, dict[str, object]] = {} + self.policies: list[dict[str, object]] = [] + self.traces: list[dict[str, object]] = [] + self.trace_events: list[dict[str, object]] = [] + + def create_consent(self, *, consent_key: str, status: str, metadata: dict[str, object]) -> dict[str, object]: + consent = { + "id": uuid4(), + "user_id": self.user_id, + "consent_key": consent_key, + "status": status, + "metadata": metadata, + "created_at": self.base_time + timedelta(minutes=len(self.consents)), + "updated_at": self.base_time + timedelta(minutes=len(self.consents)), + } + self.consents[consent_key] = consent + return consent + + def get_consent_by_key_optional(self, consent_key: str) -> dict[str, object] | None: + return self.consents.get(consent_key) + + def list_consents(self) -> list[dict[str, object]]: + return sorted( + self.consents.values(), + key=lambda consent: (consent["consent_key"], consent["created_at"], consent["id"]), + ) + + def update_consent(self, *, consent_id: UUID, status: str, metadata: dict[str, object]) -> dict[str, object]: + for consent in self.consents.values(): + if consent["id"] != consent_id: + continue + consent["status"] = status + consent["metadata"] = metadata + consent["updated_at"] = consent["updated_at"] + timedelta(minutes=5) + return consent + raise AssertionError("missing consent") + + def create_policy( + self, + *, + agent_profile_id: str | None = None, + name: str, + action: str, + scope: str, + effect: str, + priority: int, + active: bool, + conditions: dict[str, object], + required_consents: list[str], + ) -> dict[str, object]: + policy = { + "id": uuid4(), + "user_id": self.user_id, + "agent_profile_id": agent_profile_id, + "name": name, + "action": action, + "scope": scope, + "effect": effect, + "priority": priority, + "active": active, + "conditions": conditions, + "required_consents": required_consents, + "created_at": self.base_time + timedelta(minutes=len(self.policies)), + "updated_at": self.base_time + timedelta(minutes=len(self.policies)), + } + self.policies.append(policy) + return policy + + def list_policies(self) -> list[dict[str, object]]: + return sorted( + self.policies, + key=lambda policy: (policy["priority"], policy["created_at"], policy["id"]), + ) + + def get_policy_optional(self, policy_id: UUID) -> dict[str, object] | None: + return next((policy for policy in self.policies if policy["id"] == policy_id), None) + + def list_active_policies(self, *, agent_profile_id: str | None = None) -> list[dict[str, object]]: + active = [policy for policy in self.list_policies() if policy["active"] is True] + if agent_profile_id is None: + return active + return [ + policy + for policy in active + if policy["agent_profile_id"] is None or policy["agent_profile_id"] == agent_profile_id + ] + + def get_thread_optional(self, thread_id: UUID) -> dict[str, object] | None: + if thread_id != self.thread_id: + return None + return { + "id": self.thread_id, + "user_id": self.user_id, + "title": "Policy thread", + "agent_profile_id": self.thread_agent_profile_id, + "created_at": self.base_time, + "updated_at": self.base_time, + } + + def create_trace( + self, + *, + user_id: UUID, + thread_id: UUID, + kind: str, + compiler_version: str, + status: str, + limits: dict[str, object], + ) -> dict[str, object]: + trace = { + "id": uuid4(), + "user_id": user_id, + "thread_id": thread_id, + "kind": kind, + "compiler_version": compiler_version, + "status": status, + "limits": limits, + "created_at": self.base_time, + } + self.traces.append(trace) + return trace + + def append_trace_event( + self, + *, + trace_id: UUID, + sequence_no: int, + kind: str, + payload: dict[str, object], + ) -> dict[str, object]: + event = { + "id": uuid4(), + "trace_id": trace_id, + "sequence_no": sequence_no, + "kind": kind, + "payload": payload, + "created_at": self.base_time, + } + self.trace_events.append(event) + return event + + +def test_upsert_consent_record_creates_and_updates_in_place() -> None: + store = PolicyStoreStub() + + created = upsert_consent_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + consent=ConsentUpsertInput( + consent_key="email_marketing", + status="granted", + metadata={"source": "settings"}, + ), + ) + updated = upsert_consent_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + consent=ConsentUpsertInput( + consent_key="email_marketing", + status="revoked", + metadata={"source": "banner"}, + ), + ) + + assert created["write_mode"] == "created" + assert updated["write_mode"] == "updated" + assert updated["consent"]["id"] == created["consent"]["id"] + assert updated["consent"]["status"] == "revoked" + assert updated["consent"]["metadata"] == {"source": "banner"} + + +def test_list_consent_records_returns_deterministic_shape() -> None: + store = PolicyStoreStub() + zeta = store.create_consent(consent_key="zeta", status="granted", metadata={}) + alpha = store.create_consent(consent_key="alpha", status="revoked", metadata={"reason": "user"}) + + payload = list_consent_records( + store, # type: ignore[arg-type] + user_id=store.user_id, + ) + + assert payload == { + "items": [ + { + "id": str(alpha["id"]), + "consent_key": "alpha", + "status": "revoked", + "metadata": {"reason": "user"}, + "created_at": alpha["created_at"].isoformat(), + "updated_at": alpha["updated_at"].isoformat(), + }, + { + "id": str(zeta["id"]), + "consent_key": "zeta", + "status": "granted", + "metadata": {}, + "created_at": zeta["created_at"].isoformat(), + "updated_at": zeta["updated_at"].isoformat(), + }, + ], + "summary": { + "total_count": 2, + "order": ["consent_key_asc", "created_at_asc", "id_asc"], + }, + } + + +def test_create_and_list_policy_records_preserve_priority_order_and_shape() -> None: + store = PolicyStoreStub() + first = create_policy_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + policy=PolicyCreateInput( + name="Require approval for exports", + action="memory.export", + scope="profile", + effect="require_approval", + priority=20, + active=True, + conditions={"channel": "email"}, + required_consents=("email_marketing", "email_marketing"), + agent_profile_id="assistant_default", + ), + ) + second_policy = store.create_policy( + name="Allow low risk read", + action="memory.read", + scope="profile", + effect="allow", + priority=10, + active=True, + conditions={}, + required_consents=[], + ) + + list_payload = list_policy_records( + store, # type: ignore[arg-type] + user_id=store.user_id, + ) + detail_payload = get_policy_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + policy_id=UUID(first["policy"]["id"]), + ) + + assert first["policy"]["required_consents"] == ["email_marketing"] + assert first["policy"]["agent_profile_id"] == "assistant_default" + assert [item["id"] for item in list_payload["items"]] == [ + str(second_policy["id"]), + first["policy"]["id"], + ] + assert list_payload["summary"] == { + "total_count": 2, + "order": ["priority_asc", "created_at_asc", "id_asc"], + } + assert detail_payload == {"policy": first["policy"]} + + +def test_get_policy_record_raises_not_found_for_inaccessible_policy() -> None: + with pytest.raises(PolicyNotFoundError, match="policy .* was not found"): + get_policy_record( + PolicyStoreStub(), # type: ignore[arg-type] + user_id=uuid4(), + policy_id=uuid4(), + ) + + +def test_evaluate_policy_request_uses_first_matching_policy_and_emits_trace() -> None: + store = PolicyStoreStub() + store.create_consent(consent_key="email_marketing", status="granted", metadata={"source": "settings"}) + higher_priority_match = store.create_policy( + name="Allow email export", + action="memory.export", + scope="profile", + effect="allow", + priority=10, + active=True, + conditions={"channel": "email"}, + required_consents=["email_marketing"], + ) + store.create_policy( + name="Deny fallback export", + action="memory.export", + scope="profile", + effect="deny", + priority=20, + active=True, + conditions={"channel": "email"}, + required_consents=[], + ) + + payload = evaluate_policy_request( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=PolicyEvaluationRequestInput( + thread_id=store.thread_id, + action="memory.export", + scope="profile", + attributes={"channel": "email"}, + ), + ) + + assert payload["decision"] == "allow" + assert payload["matched_policy"]["id"] == str(higher_priority_match["id"]) + assert [reason["code"] for reason in payload["reasons"]] == [ + "matched_policy", + "policy_effect_allow", + ] + assert payload["evaluation"] == { + "action": "memory.export", + "scope": "profile", + "evaluated_policy_count": 2, + "matched_policy_id": str(higher_priority_match["id"]), + "order": ["priority_asc", "created_at_asc", "id_asc"], + } + assert payload["trace"]["trace_event_count"] == 3 + assert [event["kind"] for event in store.trace_events] == [ + "policy.evaluate.request", + "policy.evaluate.order", + "policy.evaluate.decision", + ] + + +def test_evaluate_policy_request_denies_when_required_consent_is_missing() -> None: + store = PolicyStoreStub() + matched_policy = store.create_policy( + name="Allow export with consent", + action="memory.export", + scope="profile", + effect="allow", + priority=10, + active=True, + conditions={}, + required_consents=["email_marketing"], + ) + + payload = evaluate_policy_request( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=PolicyEvaluationRequestInput( + thread_id=store.thread_id, + action="memory.export", + scope="profile", + attributes={}, + ), + ) + + assert payload["decision"] == "deny" + assert payload["matched_policy"]["id"] == str(matched_policy["id"]) + assert [reason["code"] for reason in payload["reasons"]] == [ + "matched_policy", + "consent_missing", + ] + + +def test_evaluate_policy_request_denies_when_required_consent_is_revoked() -> None: + store = PolicyStoreStub() + matched_policy = store.create_policy( + name="Allow export with consent", + action="memory.export", + scope="profile", + effect="allow", + priority=10, + active=True, + conditions={}, + required_consents=["email_marketing"], + ) + store.create_consent( + consent_key="email_marketing", + status="revoked", + metadata={"source": "settings"}, + ) + + payload = evaluate_policy_request( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=PolicyEvaluationRequestInput( + thread_id=store.thread_id, + action="memory.export", + scope="profile", + attributes={}, + ), + ) + + assert payload["decision"] == "deny" + assert payload["matched_policy"]["id"] == str(matched_policy["id"]) + assert [reason["code"] for reason in payload["reasons"]] == [ + "matched_policy", + "consent_revoked", + ] + + +def test_evaluate_policy_request_returns_require_approval_and_validates_thread_scope() -> None: + store = PolicyStoreStub() + matched_policy = store.create_policy( + name="Escalate export", + action="memory.export", + scope="profile", + effect="require_approval", + priority=10, + active=True, + conditions={}, + required_consents=[], + ) + + payload = evaluate_policy_request( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=PolicyEvaluationRequestInput( + thread_id=store.thread_id, + action="memory.export", + scope="profile", + attributes={}, + ), + ) + + assert payload["decision"] == "require_approval" + assert payload["matched_policy"]["id"] == str(matched_policy["id"]) + assert payload["reasons"][-1]["code"] == "policy_effect_require_approval" + + with pytest.raises( + PolicyEvaluationValidationError, + match="thread_id must reference an existing thread owned by the user", + ): + evaluate_policy_request( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=PolicyEvaluationRequestInput( + thread_id=uuid4(), + action="memory.export", + scope="profile", + attributes={}, + ), + ) + + +def test_evaluate_policy_request_filters_to_global_and_thread_profile_policies() -> None: + store = PolicyStoreStub() + store.thread_agent_profile_id = "coach_default" + mismatched = store.create_policy( + agent_profile_id="assistant_default", + name="Mismatched deny", + action="memory.export", + scope="profile", + effect="deny", + priority=1, + active=True, + conditions={}, + required_consents=[], + ) + global_policy = store.create_policy( + agent_profile_id=None, + name="Global approval", + action="memory.export", + scope="profile", + effect="require_approval", + priority=5, + active=True, + conditions={}, + required_consents=[], + ) + matched = store.create_policy( + agent_profile_id="coach_default", + name="Matched allow", + action="memory.export", + scope="profile", + effect="allow", + priority=10, + active=True, + conditions={}, + required_consents=[], + ) + + payload = evaluate_policy_request( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=PolicyEvaluationRequestInput( + thread_id=store.thread_id, + action="memory.export", + scope="profile", + attributes={}, + ), + ) + + assert payload["decision"] == "require_approval" + assert payload["matched_policy"] is not None + assert payload["matched_policy"]["id"] == str(global_policy["id"]) + assert payload["evaluation"]["evaluated_policy_count"] == 2 + + order_event = store.trace_events[1] + assert order_event["kind"] == "policy.evaluate.order" + assert order_event["payload"]["policy_ids"] == [str(global_policy["id"]), str(matched["id"])] + assert str(mismatched["id"]) not in order_event["payload"]["policy_ids"] diff --git a/tests/unit/test_policy_main.py b/tests/unit/test_policy_main.py new file mode 100644 index 0000000..fa3e4e5 --- /dev/null +++ b/tests/unit/test_policy_main.py @@ -0,0 +1,206 @@ +from __future__ import annotations + +import json +from contextlib import contextmanager +from uuid import uuid4 + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.policy import PolicyEvaluationValidationError, PolicyNotFoundError + + +def test_upsert_consent_endpoint_translates_request_and_returns_created_status(monkeypatch) -> None: + user_id = uuid4() + settings = Settings(database_url="postgresql://app") + captured: dict[str, object] = {} + + @contextmanager + def fake_user_connection(database_url: str, current_user_id): + captured["database_url"] = database_url + captured["current_user_id"] = current_user_id + yield object() + + def fake_upsert_consent_record(store, *, user_id, consent): + captured["store_type"] = type(store).__name__ + captured["user_id"] = user_id + captured["consent"] = consent + return { + "consent": { + "id": "consent-123", + "consent_key": "email_marketing", + "status": "granted", + "metadata": {"source": "settings"}, + "created_at": "2026-03-12T09:00:00+00:00", + "updated_at": "2026-03-12T09:00:00+00:00", + }, + "write_mode": "created", + } + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "upsert_consent_record", fake_upsert_consent_record) + + response = main_module.upsert_consent( + main_module.UpsertConsentRequest( + user_id=user_id, + consent_key="email_marketing", + status="granted", + metadata={"source": "settings"}, + ) + ) + + assert response.status_code == 201 + assert json.loads(response.body) == { + "consent": { + "id": "consent-123", + "consent_key": "email_marketing", + "status": "granted", + "metadata": {"source": "settings"}, + "created_at": "2026-03-12T09:00:00+00:00", + "updated_at": "2026-03-12T09:00:00+00:00", + }, + "write_mode": "created", + } + assert captured["database_url"] == "postgresql://app" + assert captured["current_user_id"] == user_id + assert captured["store_type"] == "ContinuityStore" + assert captured["user_id"] == user_id + assert captured["consent"].consent_key == "email_marketing" + assert captured["consent"].status == "granted" + assert captured["consent"].metadata == {"source": "settings"} + + +def test_get_policy_endpoint_maps_not_found_to_404(monkeypatch) -> None: + user_id = uuid4() + policy_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + def fake_get_policy_record(*_args, **_kwargs): + raise PolicyNotFoundError(f"policy {policy_id} was not found") + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "get_policy_record", fake_get_policy_record) + + response = main_module.get_policy(policy_id, user_id) + + assert response.status_code == 404 + assert json.loads(response.body) == {"detail": f"policy {policy_id} was not found"} + + +def test_evaluate_policy_endpoint_translates_request_and_returns_trace_payload(monkeypatch) -> None: + user_id = uuid4() + thread_id = uuid4() + settings = Settings(database_url="postgresql://app") + captured: dict[str, object] = {} + + @contextmanager + def fake_user_connection(database_url: str, current_user_id): + captured["database_url"] = database_url + captured["current_user_id"] = current_user_id + yield object() + + def fake_evaluate_policy_request(store, *, user_id, request): + captured["store_type"] = type(store).__name__ + captured["user_id"] = user_id + captured["request"] = request + return { + "decision": "allow", + "matched_policy": { + "id": "policy-123", + "name": "Allow export", + "action": "memory.export", + "scope": "profile", + "effect": "allow", + "priority": 10, + "active": True, + "conditions": {"channel": "email"}, + "required_consents": ["email_marketing"], + "created_at": "2026-03-12T09:00:00+00:00", + "updated_at": "2026-03-12T09:00:00+00:00", + }, + "reasons": [ + { + "code": "matched_policy", + "source": "policy", + "message": "Matched policy 'Allow export' at priority 10.", + "policy_id": "policy-123", + "consent_key": None, + }, + { + "code": "policy_effect_allow", + "source": "policy", + "message": "Policy effect resolved the decision to 'allow'.", + "policy_id": "policy-123", + "consent_key": None, + }, + ], + "evaluation": { + "action": "memory.export", + "scope": "profile", + "evaluated_policy_count": 1, + "matched_policy_id": "policy-123", + "order": ["priority_asc", "created_at_asc", "id_asc"], + }, + "trace": {"trace_id": "trace-123", "trace_event_count": 3}, + } + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "evaluate_policy_request", fake_evaluate_policy_request) + + response = main_module.evaluate_policy( + main_module.EvaluatePolicyRequest( + user_id=user_id, + thread_id=thread_id, + action="memory.export", + scope="profile", + attributes={"channel": "email"}, + ) + ) + + assert response.status_code == 200 + assert json.loads(response.body)["trace"] == {"trace_id": "trace-123", "trace_event_count": 3} + assert captured["database_url"] == "postgresql://app" + assert captured["current_user_id"] == user_id + assert captured["store_type"] == "ContinuityStore" + assert captured["user_id"] == user_id + assert captured["request"].thread_id == thread_id + assert captured["request"].action == "memory.export" + assert captured["request"].scope == "profile" + assert captured["request"].attributes == {"channel": "email"} + + +def test_evaluate_policy_endpoint_maps_validation_errors_to_400(monkeypatch) -> None: + user_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + def fake_evaluate_policy_request(*_args, **_kwargs): + raise PolicyEvaluationValidationError("thread_id must reference an existing thread owned by the user") + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "evaluate_policy_request", fake_evaluate_policy_request) + + response = main_module.evaluate_policy( + main_module.EvaluatePolicyRequest( + user_id=user_id, + thread_id=uuid4(), + action="memory.export", + scope="profile", + attributes={}, + ) + ) + + assert response.status_code == 400 + assert json.loads(response.body) == { + "detail": "thread_id must reference an existing thread owned by the user" + } diff --git a/tests/unit/test_policy_store.py b/tests/unit/test_policy_store.py new file mode 100644 index 0000000..07b0726 --- /dev/null +++ b/tests/unit/test_policy_store.py @@ -0,0 +1,193 @@ +from __future__ import annotations + +from typing import Any +from uuid import uuid4 + +from psycopg.types.json import Jsonb + +from alicebot_api.store import ContinuityStore + + +class RecordingCursor: + def __init__(self, fetchone_results: list[dict[str, Any]], fetchall_result: list[dict[str, Any]] | None = None) -> None: + self.executed: list[tuple[str, tuple[object, ...] | None]] = [] + self.fetchone_results = list(fetchone_results) + self.fetchall_result = fetchall_result or [] + + def __enter__(self) -> "RecordingCursor": + return self + + def __exit__(self, exc_type, exc, tb) -> None: + return None + + def execute(self, query: str, params: tuple[object, ...] | None = None) -> None: + self.executed.append((query, params)) + + def fetchone(self) -> dict[str, Any] | None: + if not self.fetchone_results: + return None + return self.fetchone_results.pop(0) + + def fetchall(self) -> list[dict[str, Any]]: + return self.fetchall_result + + +class RecordingConnection: + def __init__(self, cursor: RecordingCursor) -> None: + self.cursor_instance = cursor + + def cursor(self) -> RecordingCursor: + return self.cursor_instance + + +def test_consent_store_methods_use_expected_queries_and_jsonb_parameters() -> None: + consent_id = uuid4() + cursor = RecordingCursor( + fetchone_results=[ + {"id": consent_id, "consent_key": "email_marketing", "status": "granted", "metadata": {}}, + {"id": consent_id, "consent_key": "email_marketing", "status": "revoked", "metadata": {"source": "banner"}}, + ], + fetchall_result=[{"id": consent_id, "consent_key": "email_marketing", "status": "revoked", "metadata": {}}], + ) + store = ContinuityStore(RecordingConnection(cursor)) + + created = store.create_consent( + consent_key="email_marketing", + status="granted", + metadata={"source": "settings"}, + ) + updated = store.update_consent( + consent_id=consent_id, + status="revoked", + metadata={"source": "banner"}, + ) + listed = store.list_consents() + + assert created["id"] == consent_id + assert updated["status"] == "revoked" + assert listed == [{"id": consent_id, "consent_key": "email_marketing", "status": "revoked", "metadata": {}}] + + create_query, create_params = cursor.executed[0] + assert "INSERT INTO consents" in create_query + assert create_params is not None + assert create_params[:2] == ("email_marketing", "granted") + assert isinstance(create_params[2], Jsonb) + assert create_params[2].obj == {"source": "settings"} + + update_query, update_params = cursor.executed[1] + assert "UPDATE consents" in update_query + assert update_params is not None + assert update_params[0] == "revoked" + assert isinstance(update_params[1], Jsonb) + assert update_params[1].obj == {"source": "banner"} + assert update_params[2] == consent_id + + assert cursor.executed[2] == ( + """ + SELECT id, user_id, consent_key, status, metadata, created_at, updated_at + FROM consents + ORDER BY consent_key ASC, created_at ASC, id ASC + """, + None, + ) + + +def test_policy_store_methods_use_expected_queries_and_jsonb_parameters() -> None: + policy_id = uuid4() + cursor = RecordingCursor( + fetchone_results=[ + { + "id": policy_id, + "agent_profile_id": "coach_default", + "name": "Allow export", + "action": "memory.export", + "scope": "profile", + "effect": "allow", + "priority": 10, + "active": True, + "conditions": {"channel": "email"}, + "required_consents": ["email_marketing"], + }, + { + "id": policy_id, + "agent_profile_id": "coach_default", + "name": "Allow export", + "action": "memory.export", + "scope": "profile", + "effect": "allow", + "priority": 10, + "active": True, + "conditions": {"channel": "email"}, + "required_consents": ["email_marketing"], + }, + ], + fetchall_result=[ + { + "id": policy_id, + "agent_profile_id": "coach_default", + "name": "Allow export", + "action": "memory.export", + "scope": "profile", + "effect": "allow", + "priority": 10, + "active": True, + "conditions": {"channel": "email"}, + "required_consents": ["email_marketing"], + } + ], + ) + store = ContinuityStore(RecordingConnection(cursor)) + + created = store.create_policy( + agent_profile_id="coach_default", + name="Allow export", + action="memory.export", + scope="profile", + effect="allow", + priority=10, + active=True, + conditions={"channel": "email"}, + required_consents=["email_marketing"], + ) + fetched = store.get_policy_optional(policy_id) + listed = store.list_active_policies(agent_profile_id="coach_default") + + assert created["id"] == policy_id + assert created["agent_profile_id"] == "coach_default" + assert fetched is not None + assert listed[0]["id"] == policy_id + + create_query, create_params = cursor.executed[0] + assert "INSERT INTO policies" in create_query + assert create_params is not None + assert create_params[:7] == ("coach_default", "Allow export", "memory.export", "profile", "allow", 10, True) + assert isinstance(create_params[7], Jsonb) + assert create_params[7].obj == {"channel": "email"} + assert isinstance(create_params[8], Jsonb) + assert create_params[8].obj == ["email_marketing"] + + assert cursor.executed[1] == ( + """ + SELECT + id, + user_id, + agent_profile_id, + name, + action, + scope, + effect, + priority, + active, + conditions, + required_consents, + created_at, + updated_at + FROM policies + WHERE id = %s + """, + (policy_id,), + ) + list_active_query, list_active_params = cursor.executed[2] + assert "WHERE active = TRUE" in list_active_query + assert "agent_profile_id IS NULL OR agent_profile_id = %s" in list_active_query + assert list_active_params == ("coach_default",) diff --git a/tests/unit/test_proxy_execution.py b/tests/unit/test_proxy_execution.py new file mode 100644 index 0000000..01783ef --- /dev/null +++ b/tests/unit/test_proxy_execution.py @@ -0,0 +1,1052 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from uuid import UUID, uuid4 + +import pytest + +from alicebot_api.approvals import ApprovalNotFoundError +from alicebot_api.contracts import DEFAULT_AGENT_PROFILE_ID, ProxyExecutionRequestInput +from alicebot_api.proxy_execution import ( + PROXY_EXECUTION_REQUEST_EVENT_KIND, + PROXY_EXECUTION_RESULT_EVENT_KIND, + ProxyExecutionApprovalStateError, + ProxyExecutionHandlerNotFoundError, + execute_approved_proxy_request, + registered_proxy_handler_keys, +) + + +class ProxyExecutionStoreStub: + def __init__(self) -> None: + self.base_time = datetime(2026, 3, 13, 9, 0, tzinfo=UTC) + self.user_id = uuid4() + self.thread_id = uuid4() + self.agent_profiles = {DEFAULT_AGENT_PROFILE_ID} + self.thread_profiles: dict[UUID, str] = { + self.thread_id: DEFAULT_AGENT_PROFILE_ID, + } + self.locked_task_ids: list[UUID] = [] + self.approvals: dict[UUID, dict[str, object]] = {} + self.tasks: list[dict[str, object]] = [] + self.task_runs: list[dict[str, object]] = [] + self.task_steps: list[dict[str, object]] = [] + self.events: list[dict[str, object]] = [] + self.tool_executions: list[dict[str, object]] = [] + self.execution_budgets: list[dict[str, object]] = [] + self.traces: list[dict[str, object]] = [] + self.trace_events: list[dict[str, object]] = [] + + def current_time(self) -> datetime: + return self.base_time + timedelta(minutes=len(self.tool_executions)) + + def seed_approval(self, *, status: str, tool_key: str) -> dict[str, object]: + approval_id = uuid4() + tool_id = uuid4() + created_at = self.base_time + timedelta(minutes=len(self.approvals)) + approval = { + "id": approval_id, + "user_id": self.user_id, + "thread_id": self.thread_id, + "tool_id": tool_id, + "task_step_id": None, + "status": status, + "request": { + "thread_id": str(self.thread_id), + "tool_id": str(tool_id), + "action": "tool.run", + "scope": "workspace", + "domain_hint": None, + "risk_hint": None, + "attributes": {"message": "hello", "count": 2}, + }, + "tool": { + "id": str(tool_id), + "tool_key": tool_key, + "name": "Proxy Echo" if tool_key == "proxy.echo" else "Unregistered Proxy", + "description": "Deterministic proxy handler.", + "version": "1.0.0", + "metadata_version": "tool_metadata_v0", + "active": True, + "tags": ["proxy"], + "action_hints": ["tool.run"], + "scope_hints": ["workspace"], + "domain_hints": [], + "risk_hints": [], + "metadata": {"transport": "proxy"}, + "created_at": created_at.isoformat(), + }, + "routing": { + "decision": "approval_required", + "reasons": [], + "trace": {"trace_id": str(uuid4()), "trace_event_count": 3}, + }, + "routing_trace_id": uuid4(), + "created_at": created_at, + "resolved_at": None if status == "pending" else created_at + timedelta(minutes=30), + "resolved_by_user_id": None if status == "pending" else self.user_id, + } + self.approvals[approval_id] = approval + task = self.create_task( + thread_id=self.thread_id, + tool_id=tool_id, + status={ + "pending": "pending_approval", + "approved": "approved", + "rejected": "denied", + }[status], + request=approval["request"], + tool=approval["tool"], + latest_approval_id=approval_id, + latest_execution_id=None, + ) + task_step = self.create_task_step( + task_id=task["id"], + sequence_no=1, + kind="governed_request", + status={ + "pending": "created", + "approved": "approved", + "rejected": "denied", + }[status], + request=approval["request"], + outcome={ + "routing_decision": "approval_required", + "approval_id": str(approval_id), + "approval_status": status, + "execution_id": None, + "execution_status": None, + "blocked_reason": None, + }, + trace_id=uuid4(), + trace_kind="approval.request" if status == "pending" else "approval.resolve", + ) + approval["task_step_id"] = task_step["id"] + return approval + + def seed_execution_budget( + self, + *, + agent_profile_id: str | None = None, + tool_key: str | None, + domain_hint: str | None, + max_completed_executions: int, + rolling_window_seconds: int | None = None, + supersedes_budget_id: UUID | None = None, + ) -> dict[str, object]: + row = { + "id": uuid4(), + "user_id": self.user_id, + "agent_profile_id": agent_profile_id, + "tool_key": tool_key, + "domain_hint": domain_hint, + "max_completed_executions": max_completed_executions, + "rolling_window_seconds": rolling_window_seconds, + "status": "active", + "deactivated_at": None, + "superseded_by_budget_id": None, + "supersedes_budget_id": supersedes_budget_id, + "created_at": self.base_time + timedelta(minutes=len(self.execution_budgets)), + } + self.execution_budgets.append(row) + self.execution_budgets.sort(key=lambda item: (item["created_at"], item["id"])) + return row + + def get_approval_optional(self, approval_id: UUID) -> dict[str, object] | None: + return self.approvals.get(approval_id) + + def get_thread_optional(self, thread_id: UUID) -> dict[str, object] | None: + profile_id = self.thread_profiles.get(thread_id) + if profile_id is None: + return None + return { + "id": thread_id, + "user_id": self.user_id, + "title": "Proxy execution thread", + "agent_profile_id": profile_id, + "created_at": self.base_time, + "updated_at": self.base_time, + } + + def get_agent_profile_optional(self, profile_id: str) -> dict[str, object] | None: + if profile_id not in self.agent_profiles: + return None + return { + "id": profile_id, + "name": profile_id, + "description": "", + "model_provider": None, + "model_name": None, + } + + def create_trace( + self, + *, + user_id: UUID, + thread_id: UUID, + kind: str, + compiler_version: str, + status: str, + limits: dict[str, object], + ) -> dict[str, object]: + trace = { + "id": uuid4(), + "user_id": user_id, + "thread_id": thread_id, + "kind": kind, + "compiler_version": compiler_version, + "status": status, + "limits": limits, + "created_at": self.base_time + timedelta(minutes=len(self.traces)), + } + self.traces.append(trace) + return trace + + def append_trace_event( + self, + *, + trace_id: UUID, + sequence_no: int, + kind: str, + payload: dict[str, object], + ) -> dict[str, object]: + event = { + "id": uuid4(), + "trace_id": trace_id, + "sequence_no": sequence_no, + "kind": kind, + "payload": payload, + "created_at": self.base_time + timedelta(minutes=len(self.trace_events)), + } + self.trace_events.append(event) + return event + + def create_task( + self, + *, + thread_id: UUID, + tool_id: UUID, + status: str, + request: dict[str, object], + tool: dict[str, object], + latest_approval_id: UUID | None, + latest_execution_id: UUID | None, + ) -> dict[str, object]: + task = { + "id": uuid4(), + "user_id": self.user_id, + "thread_id": thread_id, + "tool_id": tool_id, + "status": status, + "request": request, + "tool": tool, + "latest_approval_id": latest_approval_id, + "latest_execution_id": latest_execution_id, + "created_at": self.base_time + timedelta(minutes=len(self.tasks)), + "updated_at": self.base_time + timedelta(minutes=len(self.tasks)), + } + self.tasks.append(task) + return task + + def get_task_by_approval_optional(self, approval_id: UUID) -> dict[str, object] | None: + return next((task for task in self.tasks if task["latest_approval_id"] == approval_id), None) + + def get_task_optional(self, task_id: UUID) -> dict[str, object] | None: + return next((task for task in self.tasks if task["id"] == task_id), None) + + def create_task_run( + self, + *, + task_id: UUID, + status: str, + checkpoint: dict[str, object], + tick_count: int, + step_count: int, + max_ticks: int, + retry_count: int = 0, + retry_cap: int = 1, + retry_posture: str = "none", + failure_class: str | None = None, + stop_reason: str | None = None, + ) -> dict[str, object]: + row = { + "id": uuid4(), + "user_id": self.user_id, + "task_id": task_id, + "status": status, + "checkpoint": checkpoint, + "tick_count": tick_count, + "step_count": step_count, + "max_ticks": max_ticks, + "retry_count": retry_count, + "retry_cap": retry_cap, + "retry_posture": retry_posture, + "failure_class": failure_class, + "stop_reason": stop_reason, + "last_transitioned_at": self.base_time + timedelta(minutes=len(self.task_runs)), + "created_at": self.base_time + timedelta(minutes=len(self.task_runs)), + "updated_at": self.base_time + timedelta(minutes=len(self.task_runs)), + } + self.task_runs.append(row) + return row + + def get_task_run_optional(self, task_run_id: UUID) -> dict[str, object] | None: + return next((run for run in self.task_runs if run["id"] == task_run_id), None) + + def update_task_run_optional( + self, + *, + task_run_id: UUID, + status: str, + checkpoint: dict[str, object], + tick_count: int, + step_count: int, + retry_count: int, + retry_cap: int, + retry_posture: str, + failure_class: str | None, + stop_reason: str | None, + ) -> dict[str, object] | None: + run = self.get_task_run_optional(task_run_id) + if run is None: + return None + run["status"] = status + run["checkpoint"] = checkpoint + run["tick_count"] = tick_count + run["step_count"] = step_count + run["retry_count"] = retry_count + run["retry_cap"] = retry_cap + run["retry_posture"] = retry_posture + run["failure_class"] = failure_class + run["stop_reason"] = stop_reason + run["last_transitioned_at"] = self.base_time + timedelta(hours=1, minutes=len(self.trace_events)) + run["updated_at"] = self.base_time + timedelta(hours=1, minutes=len(self.trace_events)) + return run + + def get_task_step_optional(self, task_step_id: UUID) -> dict[str, object] | None: + return next((task_step for task_step in self.task_steps if task_step["id"] == task_step_id), None) + + def lock_task_steps(self, task_id: UUID) -> None: + self.locked_task_ids.append(task_id) + + def update_task_execution_by_approval_optional( + self, + *, + approval_id: UUID, + latest_execution_id: UUID, + status: str, + ) -> dict[str, object] | None: + task = self.get_task_by_approval_optional(approval_id) + if task is None: + return None + task["status"] = status + task["latest_execution_id"] = latest_execution_id + task["updated_at"] = self.base_time + timedelta(hours=1, minutes=len(self.trace_events)) + return task + + def append_event( + self, + thread_id: UUID, + session_id: UUID | None, + kind: str, + payload: dict[str, object], + ) -> dict[str, object]: + event = { + "id": uuid4(), + "user_id": self.user_id, + "thread_id": thread_id, + "session_id": session_id, + "sequence_no": len(self.events) + 1, + "kind": kind, + "payload": payload, + "created_at": self.base_time + timedelta(minutes=len(self.events)), + } + self.events.append(event) + return event + + def create_task_step( + self, + *, + task_id: UUID, + sequence_no: int, + parent_step_id: UUID | None = None, + source_approval_id: UUID | None = None, + source_execution_id: UUID | None = None, + kind: str, + status: str, + request: dict[str, object], + outcome: dict[str, object], + trace_id: UUID, + trace_kind: str, + ) -> dict[str, object]: + task_step = { + "id": uuid4(), + "user_id": self.user_id, + "task_id": task_id, + "sequence_no": sequence_no, + "parent_step_id": parent_step_id, + "source_approval_id": source_approval_id, + "source_execution_id": source_execution_id, + "kind": kind, + "status": status, + "request": request, + "outcome": outcome, + "trace_id": trace_id, + "trace_kind": trace_kind, + "created_at": self.base_time + timedelta(minutes=len(self.task_steps)), + "updated_at": self.base_time + timedelta(minutes=len(self.task_steps)), + } + self.task_steps.append(task_step) + return task_step + + def get_task_step_for_task_sequence_optional( + self, + *, + task_id: UUID, + sequence_no: int, + ) -> dict[str, object] | None: + return next( + ( + task_step + for task_step in self.task_steps + if task_step["task_id"] == task_id and task_step["sequence_no"] == sequence_no + ), + None, + ) + + def list_task_steps_for_task(self, task_id: UUID) -> list[dict[str, object]]: + return sorted( + [task_step for task_step in self.task_steps if task_step["task_id"] == task_id], + key=lambda task_step: (task_step["sequence_no"], task_step["created_at"], task_step["id"]), + ) + + def update_task_step_for_task_sequence_optional( + self, + *, + task_id: UUID, + sequence_no: int, + status: str, + outcome: dict[str, object], + trace_id: UUID, + trace_kind: str, + ) -> dict[str, object] | None: + task_step = self.get_task_step_for_task_sequence_optional(task_id=task_id, sequence_no=sequence_no) + if task_step is None: + return None + task_step["status"] = status + task_step["outcome"] = outcome + task_step["trace_id"] = trace_id + task_step["trace_kind"] = trace_kind + task_step["updated_at"] = self.base_time + timedelta(hours=1, minutes=len(self.trace_events)) + return task_step + + def update_task_step_optional( + self, + *, + task_step_id: UUID, + status: str, + outcome: dict[str, object], + trace_id: UUID, + trace_kind: str, + ) -> dict[str, object] | None: + task_step = self.get_task_step_optional(task_step_id) + if task_step is None: + return None + task_step["status"] = status + task_step["outcome"] = outcome + task_step["trace_id"] = trace_id + task_step["trace_kind"] = trace_kind + task_step["updated_at"] = self.base_time + timedelta(hours=1, minutes=len(self.trace_events)) + return task_step + + def create_tool_execution( + self, + *, + approval_id: UUID, + task_step_id: UUID, + thread_id: UUID, + tool_id: UUID, + trace_id: UUID, + request_event_id: UUID | None, + result_event_id: UUID | None, + status: str, + handler_key: str | None, + request: dict[str, object], + tool: dict[str, object], + result: dict[str, object], + ) -> dict[str, object]: + execution = { + "id": uuid4(), + "user_id": self.user_id, + "approval_id": approval_id, + "task_step_id": task_step_id, + "thread_id": thread_id, + "tool_id": tool_id, + "trace_id": trace_id, + "request_event_id": request_event_id, + "result_event_id": result_event_id, + "status": status, + "handler_key": handler_key, + "request": request, + "tool": tool, + "result": result, + "executed_at": self.base_time + timedelta(minutes=len(self.tool_executions)), + } + self.tool_executions.append(execution) + return execution + + def list_execution_budgets(self) -> list[dict[str, object]]: + return list(self.execution_budgets) + + def list_tool_executions(self) -> list[dict[str, object]]: + return list(self.tool_executions) + + +def test_execute_approved_proxy_request_returns_result_and_persists_events() -> None: + store = ProxyExecutionStoreStub() + approval = store.seed_approval(status="approved", tool_key="proxy.echo") + + payload = execute_approved_proxy_request( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=ProxyExecutionRequestInput(approval_id=approval["id"]), + ) + + assert list(payload) == ["request", "approval", "tool", "result", "events", "trace"] + assert payload["request"] == { + "approval_id": str(approval["id"]), + "task_step_id": str(approval["task_step_id"]), + } + assert payload["approval"]["status"] == "approved" + assert payload["tool"]["tool_key"] == "proxy.echo" + assert payload["result"] == { + "handler_key": "proxy.echo", + "status": "completed", + "output": { + "mode": "no_side_effect", + "tool_key": "proxy.echo", + "action": "tool.run", + "scope": "workspace", + "domain_hint": None, + "risk_hint": None, + "attributes": {"message": "hello", "count": 2}, + }, + } + assert payload["events"]["request_sequence_no"] == 1 + assert payload["events"]["result_sequence_no"] == 2 + assert payload["trace"]["trace_event_count"] == 9 + assert len(store.tool_executions) == 1 + assert store.tool_executions[0]["approval_id"] == approval["id"] + assert store.tool_executions[0]["task_step_id"] == approval["task_step_id"] + assert store.tool_executions[0]["trace_id"] == UUID(payload["trace"]["trace_id"]) + assert store.tool_executions[0]["handler_key"] == "proxy.echo" + assert store.tasks[0]["status"] == "executed" + assert store.task_steps[0]["status"] == "executed" + assert store.tasks[0]["latest_execution_id"] == store.tool_executions[0]["id"] + assert store.tool_executions[0]["result"] == { + "handler_key": "proxy.echo", + "status": "completed", + "output": payload["result"]["output"], + "reason": None, + } + assert [event["kind"] for event in store.events] == [ + PROXY_EXECUTION_REQUEST_EVENT_KIND, + PROXY_EXECUTION_RESULT_EVENT_KIND, + ] + assert [event["kind"] for event in store.trace_events] == [ + "tool.proxy.execute.request", + "tool.proxy.execute.approval", + "tool.proxy.execute.budget", + "tool.proxy.execute.dispatch", + "tool.proxy.execute.summary", + "task.lifecycle.state", + "task.lifecycle.summary", + "task.step.lifecycle.state", + "task.step.lifecycle.summary", + ] + + +def test_execute_approved_proxy_request_locks_task_steps_before_persisting_execution_state() -> None: + class LockingProxyExecutionStoreStub(ProxyExecutionStoreStub): + def list_task_steps_for_task(self, task_id: UUID) -> list[dict[str, object]]: + if task_id not in self.locked_task_ids: + raise AssertionError("task-step boundary was checked before the task-step lock was taken") + return super().list_task_steps_for_task(task_id) + + def create_tool_execution( + self, + *, + approval_id: UUID, + task_step_id: UUID, + thread_id: UUID, + tool_id: UUID, + trace_id: UUID, + request_event_id: UUID | None, + result_event_id: UUID | None, + status: str, + handler_key: str | None, + request: dict[str, object], + tool: dict[str, object], + result: dict[str, object], + ) -> dict[str, object]: + task = self.get_task_by_approval_optional(approval_id) + if task is None: + raise AssertionError("expected task for approval before execution persistence") + if task["id"] not in self.locked_task_ids: + raise AssertionError("tool execution persisted before the task-step lock was taken") + return super().create_tool_execution( + approval_id=approval_id, + task_step_id=task_step_id, + thread_id=thread_id, + tool_id=tool_id, + trace_id=trace_id, + request_event_id=request_event_id, + result_event_id=result_event_id, + status=status, + handler_key=handler_key, + request=request, + tool=tool, + result=result, + ) + + store = LockingProxyExecutionStoreStub() + approval = store.seed_approval(status="approved", tool_key="proxy.echo") + + payload = execute_approved_proxy_request( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=ProxyExecutionRequestInput(approval_id=approval["id"]), + ) + + assert payload["result"]["status"] == "completed" + assert store.tasks[0]["id"] in store.locked_task_ids + + +def test_execute_approved_proxy_request_updates_the_linked_later_step_without_mutating_the_original_step() -> None: + store = ProxyExecutionStoreStub() + approval = store.seed_approval(status="approved", tool_key="proxy.echo") + task = store.tasks[0] + first_step = store.task_steps[0] + initial_execution_id = uuid4() + task["status"] = "pending_approval" + task["latest_execution_id"] = None + first_step["status"] = "executed" + first_step["outcome"] = { + "routing_decision": "approval_required", + "approval_id": str(approval["id"]), + "approval_status": "approved", + "execution_id": str(initial_execution_id), + "execution_status": "completed", + "blocked_reason": None, + } + later_step = store.create_task_step( + task_id=task["id"], + sequence_no=2, + parent_step_id=first_step["id"], + source_approval_id=approval["id"], + source_execution_id=initial_execution_id, + kind="governed_request", + status="created", + request=approval["request"], + outcome={ + "routing_decision": "approval_required", + "approval_id": str(approval["id"]), + "approval_status": "approved", + "execution_id": None, + "execution_status": None, + "blocked_reason": None, + }, + trace_id=uuid4(), + trace_kind="task.step.continuation", + ) + + original_first_trace_id = first_step["trace_id"] + original_first_outcome = dict(first_step["outcome"]) + original_later_trace_id = later_step["trace_id"] + approval["task_step_id"] = later_step["id"] + + payload = execute_approved_proxy_request( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=ProxyExecutionRequestInput(approval_id=approval["id"]), + ) + + assert payload["result"]["status"] == "completed" + assert task["status"] == "executed" + assert task["latest_execution_id"] == store.tool_executions[0]["id"] + assert first_step["status"] == "executed" + assert first_step["trace_id"] == original_first_trace_id + assert first_step["outcome"] == original_first_outcome + assert later_step["status"] == "executed" + assert later_step["trace_id"] == UUID(payload["trace"]["trace_id"]) + assert later_step["trace_id"] != original_later_trace_id + assert later_step["outcome"]["execution_id"] == str(store.tool_executions[0]["id"]) + assert later_step["outcome"]["execution_status"] == "completed" + assert store.tool_executions[0]["task_step_id"] == later_step["id"] + assert store.events[0]["payload"]["task_step_id"] == str(later_step["id"]) + assert store.events[1]["payload"]["task_step_id"] == str(later_step["id"]) + assert store.trace_events[0]["payload"] == { + "approval_id": str(approval["id"]), + "task_step_id": str(later_step["id"]), + } + assert store.trace_events[3]["payload"]["task_step_id"] == str(later_step["id"]) + assert store.trace_events[4]["payload"]["task_step_id"] == str(later_step["id"]) + + +@pytest.mark.parametrize("status", ["pending", "rejected"]) +def test_execute_approved_proxy_request_rejects_non_approved_statuses(status: str) -> None: + store = ProxyExecutionStoreStub() + approval = store.seed_approval(status=status, tool_key="proxy.echo") + + with pytest.raises( + ProxyExecutionApprovalStateError, + match=rf"approval {approval['id']} is {status} and cannot be executed", + ): + execute_approved_proxy_request( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=ProxyExecutionRequestInput(approval_id=approval["id"]), + ) + + assert store.events == [] + assert store.tool_executions == [] + assert [event["kind"] for event in store.trace_events] == [ + "tool.proxy.execute.request", + "tool.proxy.execute.approval", + "tool.proxy.execute.dispatch", + "tool.proxy.execute.summary", + ] + assert store.trace_events[2]["payload"]["dispatch_status"] == "blocked" + assert store.trace_events[3]["payload"]["execution_status"] == "blocked" + + +def test_execute_approved_proxy_request_rejects_missing_handlers() -> None: + store = ProxyExecutionStoreStub() + approval = store.seed_approval(status="approved", tool_key="proxy.missing") + + with pytest.raises( + ProxyExecutionHandlerNotFoundError, + match="tool 'proxy.missing' has no registered proxy handler", + ): + execute_approved_proxy_request( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=ProxyExecutionRequestInput(approval_id=approval["id"]), + ) + + assert store.events == [] + assert len(store.tool_executions) == 1 + assert store.tool_executions[0]["status"] == "blocked" + assert store.tool_executions[0]["task_step_id"] == approval["task_step_id"] + assert store.tool_executions[0]["handler_key"] is None + assert store.tool_executions[0]["request_event_id"] is None + assert store.tool_executions[0]["result_event_id"] is None + assert store.tasks[0]["status"] == "blocked" + assert store.task_steps[0]["status"] == "blocked" + assert store.tasks[0]["latest_execution_id"] == store.tool_executions[0]["id"] + assert store.tool_executions[0]["result"] == { + "handler_key": None, + "status": "blocked", + "output": None, + "reason": "tool 'proxy.missing' has no registered proxy handler", + } + assert store.trace_events[2]["payload"]["decision"] == "allow" + assert store.trace_events[3]["payload"] == { + "approval_id": str(approval["id"]), + "task_step_id": str(approval["task_step_id"]), + "tool_id": approval["tool"]["id"], + "tool_key": "proxy.missing", + "handler_key": None, + "dispatch_status": "blocked", + "reason": "tool 'proxy.missing' has no registered proxy handler", + "result_status": "blocked", + "output": None, + } + assert [event["kind"] for event in store.trace_events] == [ + "tool.proxy.execute.request", + "tool.proxy.execute.approval", + "tool.proxy.execute.budget", + "tool.proxy.execute.dispatch", + "tool.proxy.execute.summary", + "task.lifecycle.state", + "task.lifecycle.summary", + "task.step.lifecycle.state", + "task.step.lifecycle.summary", + ] + + +def test_execute_approved_proxy_request_returns_blocked_budget_response_and_persists_review_record() -> None: + store = ProxyExecutionStoreStub() + approval = store.seed_approval(status="approved", tool_key="proxy.echo") + budget = store.seed_execution_budget( + tool_key="proxy.echo", + domain_hint=None, + max_completed_executions=1, + ) + store.create_tool_execution( + approval_id=uuid4(), + task_step_id=uuid4(), + thread_id=store.thread_id, + tool_id=UUID(approval["tool"]["id"]), + trace_id=uuid4(), + request_event_id=uuid4(), + result_event_id=uuid4(), + status="completed", + handler_key="proxy.echo", + request={ + "thread_id": str(store.thread_id), + "tool_id": approval["tool"]["id"], + "action": "tool.run", + "scope": "workspace", + "domain_hint": None, + "risk_hint": None, + "attributes": {"message": "seed"}, + }, + tool=approval["tool"], + result={ + "handler_key": "proxy.echo", + "status": "completed", + "output": {"mode": "no_side_effect"}, + "reason": None, + }, + ) + + payload = execute_approved_proxy_request( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=ProxyExecutionRequestInput(approval_id=approval["id"]), + ) + + assert payload["events"] is None + assert payload["trace"]["trace_event_count"] == 9 + assert payload["result"] == { + "handler_key": None, + "status": "blocked", + "output": None, + "reason": ( + f"execution budget {budget['id']} blocks execution: projected completed executions " + "2 would exceed limit 1" + ), + "budget_decision": { + "matched_budget_id": str(budget["id"]), + "tool_key": "proxy.echo", + "domain_hint": None, + "budget_tool_key": "proxy.echo", + "budget_domain_hint": None, + "max_completed_executions": 1, + "rolling_window_seconds": None, + "count_scope": "lifetime", + "window_started_at": None, + "completed_execution_count": 1, + "projected_completed_execution_count": 2, + "decision": "block", + "reason": "budget_exceeded", + "order": ["specificity_desc", "created_at_asc", "id_asc"], + "history_order": ["executed_at_asc", "id_asc"], + }, + } + assert len(store.events) == 0 + assert len(store.tool_executions) == 2 + assert store.tool_executions[-1]["status"] == "blocked" + assert store.tool_executions[-1]["request_event_id"] is None + assert store.tool_executions[-1]["result_event_id"] is None + assert store.tasks[0]["status"] == "blocked" + assert store.task_steps[0]["status"] == "blocked" + assert store.tasks[0]["latest_execution_id"] == store.tool_executions[-1]["id"] + assert store.tool_executions[-1]["result"] == payload["result"] + assert [event["kind"] for event in store.trace_events] == [ + "tool.proxy.execute.request", + "tool.proxy.execute.approval", + "tool.proxy.execute.budget", + "tool.proxy.execute.dispatch", + "tool.proxy.execute.summary", + "task.lifecycle.state", + "task.lifecycle.summary", + "task.step.lifecycle.state", + "task.step.lifecycle.summary", + ] + assert store.trace_events[2]["payload"] == payload["result"]["budget_decision"] + + +def test_execute_approved_proxy_request_fail_closes_when_budget_context_is_invalid() -> None: + store = ProxyExecutionStoreStub() + approval = store.seed_approval(status="approved", tool_key="proxy.echo") + approval["request"]["thread_id"] = "not-a-uuid" # type: ignore[index] + + payload = execute_approved_proxy_request( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=ProxyExecutionRequestInput(approval_id=approval["id"]), + ) + + assert payload["events"] is None + assert payload["result"] == { + "handler_key": None, + "status": "blocked", + "output": None, + "reason": ( + "execution budget invariance blocks execution: invalid request thread/profile " + "context: request.thread_id 'not-a-uuid' is not a valid UUID" + ), + "budget_decision": { + "matched_budget_id": None, + "tool_key": "proxy.echo", + "domain_hint": None, + "budget_tool_key": None, + "budget_domain_hint": None, + "max_completed_executions": None, + "rolling_window_seconds": None, + "count_scope": "lifetime", + "window_started_at": None, + "completed_execution_count": 0, + "projected_completed_execution_count": 1, + "decision": "block", + "reason": "invalid_request_context", + "order": ["specificity_desc", "created_at_asc", "id_asc"], + "history_order": ["executed_at_asc", "id_asc"], + "request_thread_id": "not-a-uuid", + "context_resolution": "invalid", + "context_reason": "request.thread_id 'not-a-uuid' is not a valid UUID", + }, + } + assert store.events == [] + assert len(store.tool_executions) == 1 + assert store.tool_executions[0]["status"] == "blocked" + assert store.tool_executions[0]["result"] == payload["result"] + assert [event["kind"] for event in store.trace_events] == [ + "tool.proxy.execute.request", + "tool.proxy.execute.approval", + "tool.proxy.execute.budget", + "tool.proxy.execute.dispatch", + "tool.proxy.execute.summary", + "task.lifecycle.state", + "task.lifecycle.summary", + "task.step.lifecycle.state", + "task.step.lifecycle.summary", + ] + assert store.trace_events[2]["payload"] == payload["result"]["budget_decision"] + assert store.trace_events[3]["payload"]["budget_context"] == { + "request_thread_id": "not-a-uuid", + "context_resolution": "invalid", + "context_reason": "request.thread_id 'not-a-uuid' is not a valid UUID", + } + + +def test_execute_approved_proxy_request_rejects_missing_visible_approval() -> None: + store = ProxyExecutionStoreStub() + + with pytest.raises(ApprovalNotFoundError, match="was not found"): + execute_approved_proxy_request( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=ProxyExecutionRequestInput(approval_id=uuid4()), + ) + + +def test_execute_approved_proxy_request_marks_linked_run_budget_blocked_as_failed() -> None: + store = ProxyExecutionStoreStub() + approval = store.seed_approval(status="approved", tool_key="proxy.echo") + run = store.create_task_run( + task_id=store.tasks[0]["id"], + status="queued", + checkpoint={ + "cursor": 0, + "target_steps": 1, + "wait_for_signal": False, + }, + tick_count=1, + step_count=0, + max_ticks=3, + stop_reason=None, + ) + approval["task_run_id"] = run["id"] + store.seed_execution_budget( + tool_key="proxy.echo", + domain_hint=None, + max_completed_executions=1, + ) + store.create_tool_execution( + approval_id=uuid4(), + task_step_id=uuid4(), + thread_id=store.thread_id, + tool_id=UUID(approval["tool"]["id"]), + trace_id=uuid4(), + request_event_id=uuid4(), + result_event_id=uuid4(), + status="completed", + handler_key="proxy.echo", + request={ + "thread_id": str(store.thread_id), + "tool_id": approval["tool"]["id"], + "action": "tool.run", + "scope": "workspace", + "domain_hint": None, + "risk_hint": None, + "attributes": {"message": "seed"}, + }, + tool=approval["tool"], + result={ + "handler_key": "proxy.echo", + "status": "completed", + "output": {"mode": "no_side_effect"}, + "reason": None, + }, + ) + + payload = execute_approved_proxy_request( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=ProxyExecutionRequestInput(approval_id=approval["id"]), + ) + + assert payload["result"]["status"] == "blocked" + assert store.task_runs[0]["status"] == "failed" + assert store.task_runs[0]["stop_reason"] == "budget_exhausted" + assert store.task_runs[0]["failure_class"] == "budget" + assert store.task_runs[0]["retry_posture"] == "terminal" + assert store.task_runs[0]["checkpoint"]["last_execution_status"] == "blocked" + assert store.task_runs[0]["checkpoint"]["resolved_approval_id"] == str(approval["id"]) + + +def test_execute_approved_proxy_request_marks_linked_run_missing_handler_as_failed() -> None: + store = ProxyExecutionStoreStub() + approval = store.seed_approval(status="approved", tool_key="proxy.missing") + run = store.create_task_run( + task_id=store.tasks[0]["id"], + status="queued", + checkpoint={ + "cursor": 0, + "target_steps": 1, + "wait_for_signal": False, + }, + tick_count=1, + step_count=0, + max_ticks=3, + stop_reason=None, + ) + approval["task_run_id"] = run["id"] + + with pytest.raises( + ProxyExecutionHandlerNotFoundError, + match="tool 'proxy.missing' has no registered proxy handler", + ): + execute_approved_proxy_request( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=ProxyExecutionRequestInput(approval_id=approval["id"]), + ) + + assert store.task_runs[0]["status"] == "failed" + assert store.task_runs[0]["stop_reason"] == "policy_blocked" + assert store.task_runs[0]["failure_class"] == "policy" + assert store.task_runs[0]["retry_posture"] == "terminal" + assert store.task_runs[0]["checkpoint"]["last_execution_status"] == "blocked" + assert store.task_runs[0]["checkpoint"]["resolved_approval_id"] == str(approval["id"]) + + +def test_registered_proxy_handler_keys_are_sorted_and_explicit() -> None: + assert registered_proxy_handler_keys() == ( + "proxy.calendar.draft_event", + "proxy.echo", + "proxy.thread_audit", + ) diff --git a/tests/unit/test_proxy_execution_main.py b/tests/unit/test_proxy_execution_main.py new file mode 100644 index 0000000..6e67e43 --- /dev/null +++ b/tests/unit/test_proxy_execution_main.py @@ -0,0 +1,377 @@ +from __future__ import annotations + +import json +from contextlib import contextmanager +from uuid import uuid4 + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.approvals import ApprovalNotFoundError +from alicebot_api.proxy_execution import ( + ProxyExecutionApprovalStateError, + ProxyExecutionHandlerNotFoundError, +) +from alicebot_api.tasks import TaskStepApprovalLinkageError + + +def test_execute_approved_proxy_endpoint_returns_payload(monkeypatch) -> None: + user_id = uuid4() + approval_id = uuid4() + settings = Settings(database_url="postgresql://app") + captured: dict[str, object] = {} + + @contextmanager + def fake_user_connection(database_url: str, current_user_id): + captured["database_url"] = database_url + captured["current_user_id"] = current_user_id + yield object() + + def fake_execute_approved_proxy_request(store, *, user_id, request): + captured["store_type"] = type(store).__name__ + captured["user_id"] = user_id + captured["request"] = request + return { + "request": {"approval_id": str(approval_id), "task_step_id": "task-step-123"}, + "approval": { + "id": str(approval_id), + "thread_id": "thread-123", + "task_step_id": "task-step-123", + "status": "approved", + "request": { + "thread_id": "thread-123", + "tool_id": "tool-123", + "action": "tool.run", + "scope": "workspace", + "domain_hint": None, + "risk_hint": None, + "attributes": {"message": "hello"}, + }, + "tool": {"id": "tool-123", "tool_key": "proxy.echo"}, + "routing": { + "decision": "approval_required", + "reasons": [], + "trace": {"trace_id": "routing-trace-123", "trace_event_count": 3}, + }, + "created_at": "2026-03-13T09:00:00+00:00", + "resolution": { + "resolved_at": "2026-03-13T09:30:00+00:00", + "resolved_by_user_id": str(user_id), + }, + }, + "tool": {"id": "tool-123", "tool_key": "proxy.echo"}, + "result": { + "handler_key": "proxy.echo", + "status": "completed", + "output": {"mode": "no_side_effect"}, + }, + "events": { + "request_event_id": "event-request-123", + "request_sequence_no": 1, + "result_event_id": "event-result-123", + "result_sequence_no": 2, + }, + "trace": {"trace_id": "proxy-trace-123", "trace_event_count": 5}, + } + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "execute_approved_proxy_request", fake_execute_approved_proxy_request) + + response = main_module.execute_approved_proxy( + approval_id, + main_module.ExecuteApprovedProxyRequest(user_id=user_id), + ) + + assert response.status_code == 200 + assert json.loads(response.body)["request"] == { + "approval_id": str(approval_id), + "task_step_id": "task-step-123", + } + assert json.loads(response.body)["trace"] == { + "trace_id": "proxy-trace-123", + "trace_event_count": 5, + } + assert captured["database_url"] == "postgresql://app" + assert captured["current_user_id"] == user_id + assert captured["store_type"] == "ContinuityStore" + assert captured["user_id"] == user_id + assert captured["request"].approval_id == approval_id + + +def test_execute_approved_proxy_endpoint_maps_missing_approval_to_404(monkeypatch) -> None: + user_id = uuid4() + approval_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + def fake_execute_approved_proxy_request(*_args, **_kwargs): + raise ApprovalNotFoundError(f"approval {approval_id} was not found") + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "execute_approved_proxy_request", fake_execute_approved_proxy_request) + + response = main_module.execute_approved_proxy( + approval_id, + main_module.ExecuteApprovedProxyRequest(user_id=user_id), + ) + + assert response.status_code == 404 + assert json.loads(response.body) == {"detail": f"approval {approval_id} was not found"} + + +def test_execute_approved_proxy_endpoint_maps_blocked_approval_to_409(monkeypatch) -> None: + user_id = uuid4() + approval_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + def fake_execute_approved_proxy_request(*_args, **_kwargs): + raise ProxyExecutionApprovalStateError( + f"approval {approval_id} is pending and cannot be executed" + ) + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "execute_approved_proxy_request", fake_execute_approved_proxy_request) + + response = main_module.execute_approved_proxy( + approval_id, + main_module.ExecuteApprovedProxyRequest(user_id=user_id), + ) + + assert response.status_code == 409 + assert json.loads(response.body) == { + "detail": f"approval {approval_id} is pending and cannot be executed" + } + + +def test_execute_approved_proxy_endpoint_maps_missing_handler_to_409(monkeypatch) -> None: + user_id = uuid4() + approval_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + def fake_execute_approved_proxy_request(*_args, **_kwargs): + raise ProxyExecutionHandlerNotFoundError( + "tool 'proxy.missing' has no registered proxy handler" + ) + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "execute_approved_proxy_request", fake_execute_approved_proxy_request) + + response = main_module.execute_approved_proxy( + approval_id, + main_module.ExecuteApprovedProxyRequest(user_id=user_id), + ) + + assert response.status_code == 409 + assert json.loads(response.body) == { + "detail": "tool 'proxy.missing' has no registered proxy handler" + } + + +def test_execute_approved_proxy_endpoint_maps_linkage_error_to_409(monkeypatch) -> None: + user_id = uuid4() + approval_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + def fake_execute_approved_proxy_request(*_args, **_kwargs): + raise TaskStepApprovalLinkageError( + f"approval {approval_id} is missing linked task_step_id" + ) + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "execute_approved_proxy_request", fake_execute_approved_proxy_request) + + response = main_module.execute_approved_proxy( + approval_id, + main_module.ExecuteApprovedProxyRequest(user_id=user_id), + ) + + assert response.status_code == 409 + assert json.loads(response.body) == { + "detail": f"approval {approval_id} is missing linked task_step_id" + } + + +def test_execute_approved_proxy_endpoint_returns_budget_blocked_payload(monkeypatch) -> None: + user_id = uuid4() + approval_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + def fake_execute_approved_proxy_request(*_args, **_kwargs): + return { + "request": {"approval_id": str(approval_id), "task_step_id": "task-step-123"}, + "approval": { + "id": str(approval_id), + "thread_id": "thread-123", + "task_step_id": "task-step-123", + "status": "approved", + "request": { + "thread_id": "thread-123", + "tool_id": "tool-123", + "action": "tool.run", + "scope": "workspace", + "domain_hint": None, + "risk_hint": None, + "attributes": {"message": "hello"}, + }, + "tool": {"id": "tool-123", "tool_key": "proxy.echo"}, + "routing": { + "decision": "approval_required", + "reasons": [], + "trace": {"trace_id": "routing-trace-123", "trace_event_count": 3}, + }, + "created_at": "2026-03-13T09:00:00+00:00", + "resolution": { + "resolved_at": "2026-03-13T09:30:00+00:00", + "resolved_by_user_id": str(user_id), + }, + }, + "tool": {"id": "tool-123", "tool_key": "proxy.echo"}, + "result": { + "handler_key": None, + "status": "blocked", + "output": None, + "reason": "execution budget budget-123 blocks execution: projected completed executions 2 would exceed limit 1", + "budget_decision": { + "matched_budget_id": "budget-123", + "tool_key": "proxy.echo", + "domain_hint": None, + "budget_tool_key": "proxy.echo", + "budget_domain_hint": None, + "max_completed_executions": 1, + "rolling_window_seconds": None, + "count_scope": "lifetime", + "window_started_at": None, + "completed_execution_count": 1, + "projected_completed_execution_count": 2, + "decision": "block", + "reason": "budget_exceeded", + "order": ["specificity_desc", "created_at_asc", "id_asc"], + "history_order": ["executed_at_asc", "id_asc"], + }, + }, + "events": None, + "trace": {"trace_id": "proxy-trace-456", "trace_event_count": 5}, + } + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "execute_approved_proxy_request", fake_execute_approved_proxy_request) + + response = main_module.execute_approved_proxy( + approval_id, + main_module.ExecuteApprovedProxyRequest(user_id=user_id), + ) + + assert response.status_code == 200 + assert json.loads(response.body)["events"] is None + + +def test_execute_approved_proxy_endpoint_returns_invalid_context_budget_blocked_payload(monkeypatch) -> None: + user_id = uuid4() + approval_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + def fake_execute_approved_proxy_request(*_args, **_kwargs): + return { + "request": {"approval_id": str(approval_id), "task_step_id": "task-step-123"}, + "approval": { + "id": str(approval_id), + "thread_id": "thread-123", + "task_step_id": "task-step-123", + "status": "approved", + "request": { + "thread_id": "thread-123", + "tool_id": "tool-123", + "action": "tool.run", + "scope": "workspace", + "domain_hint": None, + "risk_hint": None, + "attributes": {"message": "hello"}, + }, + "tool": {"id": "tool-123", "tool_key": "proxy.echo"}, + "routing": { + "decision": "approval_required", + "reasons": [], + "trace": {"trace_id": "routing-trace-123", "trace_event_count": 3}, + }, + "created_at": "2026-03-13T09:00:00+00:00", + "resolution": { + "resolved_at": "2026-03-13T09:30:00+00:00", + "resolved_by_user_id": str(user_id), + }, + }, + "tool": {"id": "tool-123", "tool_key": "proxy.echo"}, + "result": { + "handler_key": None, + "status": "blocked", + "output": None, + "reason": ( + "execution budget invariance blocks execution: invalid request thread/profile " + "context: request.thread_id 'not-a-uuid' is not a valid UUID" + ), + "budget_decision": { + "matched_budget_id": None, + "tool_key": "proxy.echo", + "domain_hint": None, + "budget_tool_key": None, + "budget_domain_hint": None, + "max_completed_executions": None, + "rolling_window_seconds": None, + "count_scope": "lifetime", + "window_started_at": None, + "completed_execution_count": 0, + "projected_completed_execution_count": 1, + "decision": "block", + "reason": "invalid_request_context", + "order": ["specificity_desc", "created_at_asc", "id_asc"], + "history_order": ["executed_at_asc", "id_asc"], + "request_thread_id": "not-a-uuid", + "context_resolution": "invalid", + "context_reason": "request.thread_id 'not-a-uuid' is not a valid UUID", + }, + }, + "events": None, + "trace": {"trace_id": "proxy-trace-456", "trace_event_count": 5}, + } + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "execute_approved_proxy_request", fake_execute_approved_proxy_request) + + response = main_module.execute_approved_proxy( + approval_id, + main_module.ExecuteApprovedProxyRequest(user_id=user_id), + ) + + assert response.status_code == 200 + payload = json.loads(response.body) + assert payload["events"] is None + assert payload["result"]["status"] == "blocked" + assert payload["result"]["budget_decision"]["reason"] == "invalid_request_context" diff --git a/tests/unit/test_response_generation.py b/tests/unit/test_response_generation.py new file mode 100644 index 0000000..f342ead --- /dev/null +++ b/tests/unit/test_response_generation.py @@ -0,0 +1,459 @@ +from __future__ import annotations + +import json + +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.contracts import ( + ModelInvocationRequest, + ModelInvocationResponse, + PROMPT_ASSEMBLY_VERSION_V0, + PromptAssemblyInput, +) +from alicebot_api.response_generation import ( + assemble_prompt, + build_assistant_response_payload, + invoke_model, + resolve_thread_model_runtime, +) + + +def make_context_pack() -> dict[str, object]: + return { + "compiler_version": "continuity_v0", + "scope": { + "user_id": "11111111-1111-1111-8111-111111111111", + "thread_id": "22222222-2222-2222-8222-222222222222", + }, + "limits": { + "max_sessions": 3, + "max_events": 8, + "max_memories": 5, + "max_entities": 5, + "max_entity_edges": 10, + }, + "user": { + "id": "11111111-1111-1111-8111-111111111111", + "email": "owner@example.com", + "display_name": "Owner", + "created_at": "2026-03-12T09:00:00+00:00", + }, + "thread": { + "id": "22222222-2222-2222-8222-222222222222", + "title": "Thread", + "created_at": "2026-03-12T09:00:00+00:00", + "updated_at": "2026-03-12T09:05:00+00:00", + }, + "sessions": [], + "events": [ + { + "id": "33333333-3333-3333-8333-333333333333", + "session_id": None, + "sequence_no": 1, + "kind": "message.user", + "payload": {"text": "Hello"}, + "created_at": "2026-03-12T09:06:00+00:00", + } + ], + "memories": [ + { + "id": "44444444-4444-4444-8444-444444444444", + "memory_key": "user.preference.coffee", + "value": {"likes": "oat milk"}, + "status": "active", + "source_event_ids": ["33333333-3333-3333-8333-333333333333"], + "created_at": "2026-03-12T09:04:00+00:00", + "updated_at": "2026-03-12T09:05:00+00:00", + "source_provenance": {"sources": ["symbolic"], "semantic_score": None}, + } + ], + "memory_summary": { + "candidate_count": 1, + "included_count": 1, + "excluded_deleted_count": 0, + "excluded_limit_count": 0, + "hybrid_retrieval": { + "requested": False, + "embedding_config_id": None, + "query_vector_dimensions": 0, + "semantic_limit": 0, + "symbolic_selected_count": 1, + "semantic_selected_count": 0, + "merged_candidate_count": 1, + "deduplicated_count": 0, + "included_symbolic_only_count": 1, + "included_semantic_only_count": 0, + "included_dual_source_count": 0, + "similarity_metric": None, + "source_precedence": ["symbolic", "semantic"], + "symbolic_order": ["updated_at_asc", "created_at_asc", "id_asc"], + "semantic_order": ["score_desc", "created_at_asc", "id_asc"], + }, + }, + "artifact_chunks": [], + "artifact_chunk_summary": { + "requested": False, + "lexical_requested": False, + "semantic_requested": False, + "scope": None, + "query": None, + "query_terms": [], + "embedding_config_id": None, + "query_vector_dimensions": 0, + "limit": 0, + "lexical_limit": 0, + "semantic_limit": 0, + "searched_artifact_count": 0, + "lexical_candidate_count": 0, + "semantic_candidate_count": 0, + "merged_candidate_count": 0, + "deduplicated_count": 0, + "included_count": 0, + "included_lexical_only_count": 0, + "included_semantic_only_count": 0, + "included_dual_source_count": 0, + "excluded_uningested_artifact_count": 0, + "excluded_limit_count": 0, + "matching_rule": None, + "similarity_metric": None, + "source_precedence": ["lexical", "semantic"], + "lexical_order": [ + "matched_query_term_count_desc", + "first_match_char_start_asc", + "relative_path_asc", + "sequence_no_asc", + "id_asc", + ], + "semantic_order": ["score_desc", "relative_path_asc", "sequence_no_asc", "id_asc"], + "merged_order": [ + "source_precedence_asc", + "lexical_rank_asc", + "semantic_rank_asc", + "relative_path_asc", + "sequence_no_asc", + "id_asc", + ], + }, + "entities": [], + "entity_summary": { + "candidate_count": 0, + "included_count": 0, + "excluded_limit_count": 0, + }, + "entity_edges": [], + "entity_edge_summary": { + "anchor_entity_count": 0, + "candidate_count": 0, + "included_count": 0, + "excluded_limit_count": 0, + }, + } + + +def test_assemble_prompt_is_deterministic_and_explicit() -> None: + first = assemble_prompt( + request=PromptAssemblyInput( + context_pack=make_context_pack(), + system_instruction="System instruction", + developer_instruction="Developer instruction", + ), + compile_trace_id="compile-trace-123", + ) + second = assemble_prompt( + request=PromptAssemblyInput( + context_pack=make_context_pack(), + system_instruction="System instruction", + developer_instruction="Developer instruction", + ), + compile_trace_id="compile-trace-123", + ) + + assert first.prompt_text == second.prompt_text + assert first.prompt_sha256 == second.prompt_sha256 + assert first.trace_payload == second.trace_payload + assert [section.name for section in first.sections] == [ + "system", + "developer", + "context", + "conversation", + ] + assert "[SYSTEM]\nSystem instruction" in first.prompt_text + assert "[DEVELOPER]\nDeveloper instruction" in first.prompt_text + assert '"memory_key":"user.preference.coffee"' in first.prompt_text + assert first.trace_payload["version"] == PROMPT_ASSEMBLY_VERSION_V0 + assert first.trace_payload["compile_trace_id"] == "compile-trace-123" + assert first.trace_payload["included_event_count"] == 1 + assert first.trace_payload["included_memory_count"] == 1 + + +class FakeHTTPResponse: + def __init__(self, body: bytes) -> None: + self.body = body + + def __enter__(self) -> "FakeHTTPResponse": + return self + + def __exit__(self, exc_type, exc, tb) -> None: + return None + + def read(self) -> bytes: + return self.body + + +def test_invoke_model_sends_tools_disabled_request_and_parses_response(monkeypatch) -> None: + captured: dict[str, object] = {} + + def fake_urlopen(request, timeout): + captured["url"] = request.full_url + captured["timeout"] = timeout + captured["headers"] = dict(request.header_items()) + captured["body"] = json.loads(request.data.decode("utf-8")) + return FakeHTTPResponse( + json.dumps( + { + "id": "resp_123", + "status": "completed", + "output": [ + { + "type": "message", + "content": [{"type": "output_text", "text": "Assistant reply"}], + } + ], + "usage": { + "input_tokens": 12, + "output_tokens": 4, + "total_tokens": 16, + }, + } + ).encode("utf-8") + ) + + monkeypatch.setattr("alicebot_api.response_generation.urlopen", fake_urlopen) + + prompt = assemble_prompt( + request=PromptAssemblyInput( + context_pack=make_context_pack(), + system_instruction="System instruction", + developer_instruction="Developer instruction", + ), + compile_trace_id="compile-trace-123", + ) + response = invoke_model( + settings=Settings( + model_provider="openai_responses", + model_base_url="https://example.test/v1", + model_name="gpt-5-mini", + model_api_key="secret-key", + model_timeout_seconds=17, + ), + request=ModelInvocationRequest( + provider="openai_responses", + model="gpt-5-mini", + prompt=prompt, + ), + ) + + assert captured["url"] == "https://example.test/v1/responses" + assert captured["timeout"] == 17 + assert captured["headers"]["Authorization"] == "Bearer secret-key" + assert captured["body"]["tool_choice"] == "none" + assert captured["body"]["tools"] == [] + assert captured["body"]["store"] is False + assert [item["role"] for item in captured["body"]["input"]] == [ + "system", + "developer", + "user", + "user", + ] + assert response == ModelInvocationResponse( + provider="openai_responses", + model="gpt-5-mini", + response_id="resp_123", + finish_reason="completed", + output_text="Assistant reply", + usage={"input_tokens": 12, "output_tokens": 4, "total_tokens": 16}, + ) + + +def test_invoke_model_parses_optional_cached_input_token_telemetry(monkeypatch) -> None: + def fake_urlopen(_request, timeout): + del timeout + return FakeHTTPResponse( + json.dumps( + { + "id": "resp_telemetry", + "status": "completed", + "output": [ + { + "type": "message", + "content": [{"type": "output_text", "text": "Assistant reply"}], + } + ], + "usage": { + "input_tokens": 100, + "output_tokens": 8, + "total_tokens": 108, + "input_tokens_details": {"cached_tokens": 76}, + }, + } + ).encode("utf-8") + ) + + monkeypatch.setattr("alicebot_api.response_generation.urlopen", fake_urlopen) + + prompt = assemble_prompt( + request=PromptAssemblyInput( + context_pack=make_context_pack(), + system_instruction="System instruction", + developer_instruction="Developer instruction", + ), + compile_trace_id="compile-trace-123", + ) + + response = invoke_model( + settings=Settings( + model_provider="openai_responses", + model_base_url="https://example.test/v1", + model_name="gpt-5-mini", + model_api_key="secret-key", + model_timeout_seconds=17, + ), + request=ModelInvocationRequest( + provider="openai_responses", + model="gpt-5-mini", + prompt=prompt, + ), + ) + + assert response.usage == { + "input_tokens": 100, + "output_tokens": 8, + "total_tokens": 108, + "cached_input_tokens": 76, + } + + +def test_build_assistant_response_payload_captures_model_and_prompt_metadata() -> None: + prompt = assemble_prompt( + request=PromptAssemblyInput( + context_pack=make_context_pack(), + system_instruction="System instruction", + developer_instruction="Developer instruction", + ), + compile_trace_id="compile-trace-123", + ) + payload = build_assistant_response_payload( + prompt=prompt, + model_response=ModelInvocationResponse( + provider="openai_responses", + model="gpt-5-mini", + response_id="resp_123", + finish_reason="completed", + output_text="Assistant reply", + usage={ + "input_tokens": 12, + "output_tokens": 4, + "total_tokens": 16, + "cached_input_tokens": 9, + }, + ), + ) + + assert payload == { + "text": "Assistant reply", + "model": { + "provider": "openai_responses", + "model": "gpt-5-mini", + "response_id": "resp_123", + "finish_reason": "completed", + "usage": { + "input_tokens": 12, + "output_tokens": 4, + "total_tokens": 16, + "cached_input_tokens": 9, + }, + }, + "prompt": { + "assembly_version": "prompt_assembly_v0", + "prompt_sha256": prompt.prompt_sha256, + "section_order": ["system", "developer", "context", "conversation"], + }, + } + + +class FakeProfileStore: + def __init__(self, profile: dict[str, object] | None) -> None: + self.profile = profile + self.lookups: list[str] = [] + + def get_agent_profile_optional(self, profile_id: str): + self.lookups.append(profile_id) + return self.profile + + +def test_resolve_thread_model_runtime_prefers_profile_runtime_when_present() -> None: + settings = Settings( + model_provider="openai_responses", + model_name="gpt-5-mini", + ) + store = FakeProfileStore( + { + "id": "coach_default", + "name": "Coach Default", + "description": "Coaching profile", + "model_provider": "openai_responses", + "model_name": "gpt-5", + } + ) + + provider, model = resolve_thread_model_runtime( + store=store, # type: ignore[arg-type] + thread={"agent_profile_id": "coach_default"}, # type: ignore[arg-type] + settings=settings, + ) + + assert store.lookups == ["coach_default"] + assert provider == "openai_responses" + assert model == "gpt-5" + + +def test_resolve_thread_model_runtime_falls_back_when_profile_runtime_missing_or_partial() -> None: + settings = Settings( + model_provider="openai_responses", + model_name="gpt-5-mini", + ) + + missing_runtime_store = FakeProfileStore( + { + "id": "coach_default", + "name": "Coach Default", + "description": "Coaching profile", + "model_provider": None, + "model_name": None, + } + ) + partial_runtime_store = FakeProfileStore( + { + "id": "coach_default", + "name": "Coach Default", + "description": "Coaching profile", + "model_provider": "openai_responses", + "model_name": None, + } + ) + missing_profile_store = FakeProfileStore(None) + + assert resolve_thread_model_runtime( + store=missing_runtime_store, # type: ignore[arg-type] + thread={"agent_profile_id": "coach_default"}, # type: ignore[arg-type] + settings=settings, + ) == ("openai_responses", "gpt-5-mini") + assert resolve_thread_model_runtime( + store=partial_runtime_store, # type: ignore[arg-type] + thread={"agent_profile_id": "coach_default"}, # type: ignore[arg-type] + settings=settings, + ) == ("openai_responses", "gpt-5-mini") + assert resolve_thread_model_runtime( + store=missing_profile_store, # type: ignore[arg-type] + thread={"agent_profile_id": "coach_default"}, # type: ignore[arg-type] + settings=settings, + ) == ("openai_responses", "gpt-5-mini") diff --git a/tests/unit/test_retrieval_evaluation.py b/tests/unit/test_retrieval_evaluation.py new file mode 100644 index 0000000..1ecca26 --- /dev/null +++ b/tests/unit/test_retrieval_evaluation.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from uuid import UUID + +from alicebot_api.retrieval_evaluation import ( + RETRIEVAL_EVALUATION_PRECISION_TARGET, + get_retrieval_evaluation_summary, +) + + +class RetrievalEvaluationStoreStub: + pass + + +def test_retrieval_evaluation_summary_is_deterministic_and_fixture_backed() -> None: + payload_first = get_retrieval_evaluation_summary( + RetrievalEvaluationStoreStub(), # type: ignore[arg-type] + user_id=UUID("11111111-1111-4111-8111-111111111111"), + ) + payload_second = get_retrieval_evaluation_summary( + RetrievalEvaluationStoreStub(), # type: ignore[arg-type] + user_id=UUID("11111111-1111-4111-8111-111111111111"), + ) + + assert payload_first == payload_second + assert payload_first["summary"]["fixture_count"] == 3 + assert payload_first["summary"]["evaluated_fixture_count"] == 3 + assert payload_first["summary"]["precision_target"] == RETRIEVAL_EVALUATION_PRECISION_TARGET + assert payload_first["summary"]["precision_at_k_mean"] >= RETRIEVAL_EVALUATION_PRECISION_TARGET + assert payload_first["summary"]["status"] == "pass" + assert [fixture["fixture_id"] for fixture in payload_first["fixtures"]] == [ + "confirmed_fresh_truth_preferred", + "provenance_breaks_tie", + "supersession_chain_prefers_current_truth", + ] + assert all(fixture["precision_at_k"] == 1.0 for fixture in payload_first["fixtures"]) + assert payload_first["fixtures"][0]["top_result_ordering"] is not None + assert payload_first["fixtures"][0]["top_result_ordering"]["freshness_posture"] == "fresh" diff --git a/tests/unit/test_semantic_retrieval.py b/tests/unit/test_semantic_retrieval.py new file mode 100644 index 0000000..befc7ed --- /dev/null +++ b/tests/unit/test_semantic_retrieval.py @@ -0,0 +1,582 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from uuid import UUID, uuid4 + +import pytest + +from alicebot_api.contracts import ( + ArtifactScopedSemanticArtifactChunkRetrievalInput, + SemanticMemoryRetrievalRequestInput, + TaskScopedSemanticArtifactChunkRetrievalInput, +) +from alicebot_api.semantic_retrieval import ( + SemanticArtifactChunkRetrievalValidationError, + SemanticMemoryRetrievalValidationError, + calculate_mean_precision, + calculate_precision_at_k, + retrieve_artifact_scoped_semantic_artifact_chunk_records, + retrieve_semantic_memory_records, + retrieve_task_scoped_semantic_artifact_chunk_records, +) +from alicebot_api.tasks import TaskNotFoundError + + +class SemanticRetrievalStoreStub: + def __init__(self) -> None: + self.base_time = datetime(2026, 3, 12, 9, 0, tzinfo=UTC) + self.config_by_id: dict[UUID, dict[str, object]] = {} + self.retrieval_rows: list[dict[str, object]] = [] + self.task_artifact_retrieval_rows: list[dict[str, object]] = [] + self.tasks: dict[UUID, dict[str, object]] = {} + self.artifacts_by_id: dict[UUID, dict[str, object]] = {} + self.artifacts_by_task_id: dict[UUID, list[dict[str, object]]] = {} + self.last_query: dict[str, object] | None = None + + def get_embedding_config_optional(self, embedding_config_id: UUID) -> dict[str, object] | None: + return self.config_by_id.get(embedding_config_id) + + def retrieve_semantic_memory_matches( + self, + *, + embedding_config_id: UUID, + query_vector: list[float], + limit: int, + ) -> list[dict[str, object]]: + self.last_query = { + "embedding_config_id": embedding_config_id, + "query_vector": query_vector, + "limit": limit, + } + return list(self.retrieval_rows[:limit]) + + def get_task_optional(self, task_id: UUID) -> dict[str, object] | None: + return self.tasks.get(task_id) + + def get_task_artifact_optional(self, task_artifact_id: UUID) -> dict[str, object] | None: + return self.artifacts_by_id.get(task_artifact_id) + + def list_task_artifacts_for_task(self, task_id: UUID) -> list[dict[str, object]]: + return list(self.artifacts_by_task_id.get(task_id, [])) + + def retrieve_task_scoped_semantic_artifact_chunk_matches( + self, + *, + task_id: UUID, + embedding_config_id: UUID, + query_vector: list[float], + limit: int, + ) -> list[dict[str, object]]: + self.last_query = { + "scope": "task", + "task_id": task_id, + "embedding_config_id": embedding_config_id, + "query_vector": query_vector, + "limit": limit, + } + return list(self.task_artifact_retrieval_rows[:limit]) + + def retrieve_artifact_scoped_semantic_artifact_chunk_matches( + self, + *, + task_artifact_id: UUID, + embedding_config_id: UUID, + query_vector: list[float], + limit: int, + ) -> list[dict[str, object]]: + self.last_query = { + "scope": "artifact", + "task_artifact_id": task_artifact_id, + "embedding_config_id": embedding_config_id, + "query_vector": query_vector, + "limit": limit, + } + return list(self.task_artifact_retrieval_rows[:limit]) + + +def seed_config(store: SemanticRetrievalStoreStub, *, dimensions: int = 3) -> UUID: + config_id = uuid4() + store.config_by_id[config_id] = { + "id": config_id, + "dimensions": dimensions, + } + return config_id + + +def active_row( + store: SemanticRetrievalStoreStub, + *, + memory_key: str, + score: float, + minute_offset: int, +) -> dict[str, object]: + return { + "id": uuid4(), + "user_id": uuid4(), + "memory_key": memory_key, + "value": {"memory_key": memory_key}, + "status": "active", + "source_event_ids": [str(uuid4())], + "created_at": store.base_time + timedelta(minutes=minute_offset), + "updated_at": store.base_time + timedelta(minutes=minute_offset + 1), + "deleted_at": None, + "score": score, + } + + +def seed_task(store: SemanticRetrievalStoreStub) -> UUID: + task_id = uuid4() + store.tasks[task_id] = {"id": task_id} + return task_id + + +def seed_artifact( + store: SemanticRetrievalStoreStub, + *, + task_id: UUID, + ingestion_status: str = "ingested", + relative_path: str = "docs/spec.txt", + media_type_hint: str | None = "text/plain", +) -> UUID: + task_artifact_id = uuid4() + artifact = { + "id": task_artifact_id, + "task_id": task_id, + "ingestion_status": ingestion_status, + "relative_path": relative_path, + "media_type_hint": media_type_hint, + } + store.artifacts_by_id[task_artifact_id] = artifact + store.artifacts_by_task_id.setdefault(task_id, []).append(artifact) + return task_artifact_id + + +def semantic_artifact_row( + store: SemanticRetrievalStoreStub, + *, + task_id: UUID, + task_artifact_id: UUID, + relative_path: str, + score: float, + sequence_no: int, +) -> dict[str, object]: + return { + "id": uuid4(), + "user_id": uuid4(), + "task_id": task_id, + "task_artifact_id": task_artifact_id, + "relative_path": relative_path, + "media_type_hint": "text/plain", + "sequence_no": sequence_no, + "char_start": 0, + "char_end_exclusive": 11, + "text": f"{relative_path}-chunk", + "created_at": store.base_time + timedelta(minutes=sequence_no), + "updated_at": store.base_time + timedelta(minutes=sequence_no + 1), + "embedding_config_id": uuid4(), + "score": score, + } + + +def test_retrieve_semantic_memory_records_returns_stable_shape_and_summary() -> None: + store = SemanticRetrievalStoreStub() + config_id = seed_config(store, dimensions=3) + first_row = active_row(store, memory_key="user.preference.coffee", score=1.0, minute_offset=0) + second_row = active_row(store, memory_key="user.preference.tea", score=0.75, minute_offset=1) + store.retrieval_rows = [first_row, second_row] + + payload = retrieve_semantic_memory_records( + store, # type: ignore[arg-type] + user_id=uuid4(), + request=SemanticMemoryRetrievalRequestInput( + embedding_config_id=config_id, + query_vector=(0.1, 0.2, 0.3), + limit=2, + ), + ) + + assert payload == { + "items": [ + { + "memory_id": str(first_row["id"]), + "memory_key": "user.preference.coffee", + "value": {"memory_key": "user.preference.coffee"}, + "source_event_ids": first_row["source_event_ids"], + "created_at": first_row["created_at"].isoformat(), + "updated_at": first_row["updated_at"].isoformat(), + "score": 1.0, + }, + { + "memory_id": str(second_row["id"]), + "memory_key": "user.preference.tea", + "value": {"memory_key": "user.preference.tea"}, + "source_event_ids": second_row["source_event_ids"], + "created_at": second_row["created_at"].isoformat(), + "updated_at": second_row["updated_at"].isoformat(), + "score": 0.75, + }, + ], + "summary": { + "embedding_config_id": str(config_id), + "limit": 2, + "returned_count": 2, + "similarity_metric": "cosine_similarity", + "order": ["score_desc", "created_at_asc", "id_asc"], + }, + } + assert store.last_query == { + "embedding_config_id": config_id, + "query_vector": [0.1, 0.2, 0.3], + "limit": 2, + } + + +def test_retrieve_semantic_memory_records_rejects_missing_config() -> None: + store = SemanticRetrievalStoreStub() + + with pytest.raises( + SemanticMemoryRetrievalValidationError, + match="embedding_config_id must reference an existing embedding config owned by the user", + ): + retrieve_semantic_memory_records( + store, # type: ignore[arg-type] + user_id=uuid4(), + request=SemanticMemoryRetrievalRequestInput( + embedding_config_id=uuid4(), + query_vector=(0.1, 0.2, 0.3), + ), + ) + + +def test_retrieve_semantic_memory_records_rejects_dimension_mismatch() -> None: + store = SemanticRetrievalStoreStub() + config_id = seed_config(store, dimensions=3) + + with pytest.raises( + SemanticMemoryRetrievalValidationError, + match="query_vector length must match embedding config dimensions \\(3\\): 2", + ): + retrieve_semantic_memory_records( + store, # type: ignore[arg-type] + user_id=uuid4(), + request=SemanticMemoryRetrievalRequestInput( + embedding_config_id=config_id, + query_vector=(0.1, 0.2), + ), + ) + + +def test_retrieve_semantic_memory_records_rejects_non_active_memory_rows() -> None: + store = SemanticRetrievalStoreStub() + config_id = seed_config(store, dimensions=3) + invalid_row = active_row(store, memory_key="user.preference.music", score=0.5, minute_offset=0) + invalid_row["status"] = "deleted" + store.retrieval_rows = [invalid_row] + + with pytest.raises( + SemanticMemoryRetrievalValidationError, + match="semantic retrieval only supports active memories", + ): + retrieve_semantic_memory_records( + store, # type: ignore[arg-type] + user_id=uuid4(), + request=SemanticMemoryRetrievalRequestInput( + embedding_config_id=config_id, + query_vector=(0.1, 0.2, 0.3), + ), + ) + + +def test_calculate_precision_helpers_are_deterministic() -> None: + precision = calculate_precision_at_k( + returned_ids=["a", "b", "c"], + relevant_ids={"a", "c"}, + top_k=2, + ) + assert precision == 0.5 + assert calculate_mean_precision([1.0, 0.5, 1.0]) == pytest.approx(0.8333333333) + assert calculate_mean_precision([]) == 0.0 + + with pytest.raises( + SemanticMemoryRetrievalValidationError, + match="top_k must be greater than or equal to 1", + ): + calculate_precision_at_k( + returned_ids=["a"], + relevant_ids={"a"}, + top_k=0, + ) + + +def test_retrieve_task_scoped_semantic_artifact_chunk_records_returns_stable_shape_and_summary() -> None: + store = SemanticRetrievalStoreStub() + config_id = seed_config(store, dimensions=3) + task_id = seed_task(store) + first_artifact_id = seed_artifact( + store, + task_id=task_id, + relative_path="docs/a.txt", + ) + second_artifact_id = seed_artifact( + store, + task_id=task_id, + relative_path="notes/b.txt", + ) + pending_artifact_id = seed_artifact( + store, + task_id=task_id, + ingestion_status="pending", + relative_path="notes/pending.txt", + ) + first_row = semantic_artifact_row( + store, + task_id=task_id, + task_artifact_id=first_artifact_id, + relative_path="docs/a.txt", + score=1.0, + sequence_no=1, + ) + second_row = semantic_artifact_row( + store, + task_id=task_id, + task_artifact_id=second_artifact_id, + relative_path="notes/b.txt", + score=0.25, + sequence_no=1, + ) + store.task_artifact_retrieval_rows = [first_row, second_row] + + payload = retrieve_task_scoped_semantic_artifact_chunk_records( + store, # type: ignore[arg-type] + user_id=uuid4(), + request=TaskScopedSemanticArtifactChunkRetrievalInput( + task_id=task_id, + embedding_config_id=config_id, + query_vector=(1.0, 0.0, 0.0), + limit=2, + ), + ) + + assert payload == { + "items": [ + { + "id": str(first_row["id"]), + "task_id": str(task_id), + "task_artifact_id": str(first_artifact_id), + "relative_path": "docs/a.txt", + "media_type": "text/plain", + "sequence_no": 1, + "char_start": 0, + "char_end_exclusive": 11, + "text": "docs/a.txt-chunk", + "score": 1.0, + }, + { + "id": str(second_row["id"]), + "task_id": str(task_id), + "task_artifact_id": str(second_artifact_id), + "relative_path": "notes/b.txt", + "media_type": "text/plain", + "sequence_no": 1, + "char_start": 0, + "char_end_exclusive": 11, + "text": "notes/b.txt-chunk", + "score": 0.25, + }, + ], + "summary": { + "embedding_config_id": str(config_id), + "query_vector_dimensions": 3, + "limit": 2, + "returned_count": 2, + "searched_artifact_count": 2, + "similarity_metric": "cosine_similarity", + "order": ["score_desc", "relative_path_asc", "sequence_no_asc", "id_asc"], + "scope": {"kind": "task", "task_id": str(task_id)}, + }, + } + assert pending_artifact_id in store.artifacts_by_id + assert store.last_query == { + "scope": "task", + "task_id": task_id, + "embedding_config_id": config_id, + "query_vector": [1.0, 0.0, 0.0], + "limit": 2, + } + + +def test_retrieve_task_scoped_semantic_artifact_chunk_records_rejects_missing_task_and_dimension_mismatch() -> None: + store = SemanticRetrievalStoreStub() + config_id = seed_config(store, dimensions=3) + + with pytest.raises(TaskNotFoundError, match="task .* was not found"): + retrieve_task_scoped_semantic_artifact_chunk_records( + store, # type: ignore[arg-type] + user_id=uuid4(), + request=TaskScopedSemanticArtifactChunkRetrievalInput( + task_id=uuid4(), + embedding_config_id=config_id, + query_vector=(1.0, 0.0, 0.0), + ), + ) + + task_id = seed_task(store) + seed_artifact(store, task_id=task_id) + with pytest.raises( + SemanticArtifactChunkRetrievalValidationError, + match="query_vector length must match embedding config dimensions \\(3\\): 2", + ): + retrieve_task_scoped_semantic_artifact_chunk_records( + store, # type: ignore[arg-type] + user_id=uuid4(), + request=TaskScopedSemanticArtifactChunkRetrievalInput( + task_id=task_id, + embedding_config_id=config_id, + query_vector=(1.0, 0.0), + ), + ) + + +def test_retrieve_artifact_scoped_semantic_artifact_chunk_records_returns_empty_for_pending_artifact() -> None: + store = SemanticRetrievalStoreStub() + config_id = seed_config(store, dimensions=3) + task_id = seed_task(store) + artifact_id = seed_artifact( + store, + task_id=task_id, + ingestion_status="pending", + relative_path="notes/pending.txt", + media_type_hint="text/markdown", + ) + + payload = retrieve_artifact_scoped_semantic_artifact_chunk_records( + store, # type: ignore[arg-type] + user_id=uuid4(), + request=ArtifactScopedSemanticArtifactChunkRetrievalInput( + task_artifact_id=artifact_id, + embedding_config_id=config_id, + query_vector=(0.0, 1.0, 0.0), + limit=5, + ), + ) + + assert payload == { + "items": [], + "summary": { + "embedding_config_id": str(config_id), + "query_vector_dimensions": 3, + "limit": 5, + "returned_count": 0, + "searched_artifact_count": 0, + "similarity_metric": "cosine_similarity", + "order": ["score_desc", "relative_path_asc", "sequence_no_asc", "id_asc"], + "scope": { + "kind": "artifact", + "task_id": str(task_id), + "task_artifact_id": str(artifact_id), + }, + }, + } + assert store.last_query == { + "scope": "artifact", + "task_artifact_id": artifact_id, + "embedding_config_id": config_id, + "query_vector": [0.0, 1.0, 0.0], + "limit": 5, + } + + +def test_retrieve_task_scoped_semantic_artifact_chunk_records_infers_docx_media_type_without_hint() -> None: + store = SemanticRetrievalStoreStub() + config_id = seed_config(store, dimensions=3) + task_id = seed_task(store) + artifact_id = seed_artifact( + store, + task_id=task_id, + relative_path="docs/spec.docx", + media_type_hint=None, + ) + docx_row = semantic_artifact_row( + store, + task_id=task_id, + task_artifact_id=artifact_id, + relative_path="docs/spec.docx", + score=0.9, + sequence_no=1, + ) + docx_row["media_type_hint"] = None + store.task_artifact_retrieval_rows = [docx_row] + + payload = retrieve_task_scoped_semantic_artifact_chunk_records( + store, # type: ignore[arg-type] + user_id=uuid4(), + request=TaskScopedSemanticArtifactChunkRetrievalInput( + task_id=task_id, + embedding_config_id=config_id, + query_vector=(1.0, 0.0, 0.0), + limit=1, + ), + ) + + assert payload["items"] == [ + { + "id": str(docx_row["id"]), + "task_id": str(task_id), + "task_artifact_id": str(artifact_id), + "relative_path": "docs/spec.docx", + "media_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "sequence_no": 1, + "char_start": 0, + "char_end_exclusive": 11, + "text": "docs/spec.docx-chunk", + "score": 0.9, + } + ] + + +def test_retrieve_task_scoped_semantic_artifact_chunk_records_infers_rfc822_media_type_without_hint() -> None: + store = SemanticRetrievalStoreStub() + config_id = seed_config(store, dimensions=3) + task_id = seed_task(store) + artifact_id = seed_artifact( + store, + task_id=task_id, + relative_path="mail/update.eml", + media_type_hint=None, + ) + email_row = semantic_artifact_row( + store, + task_id=task_id, + task_artifact_id=artifact_id, + relative_path="mail/update.eml", + score=0.85, + sequence_no=1, + ) + email_row["media_type_hint"] = None + store.task_artifact_retrieval_rows = [email_row] + + payload = retrieve_task_scoped_semantic_artifact_chunk_records( + store, # type: ignore[arg-type] + user_id=uuid4(), + request=TaskScopedSemanticArtifactChunkRetrievalInput( + task_id=task_id, + embedding_config_id=config_id, + query_vector=(1.0, 0.0, 0.0), + limit=1, + ), + ) + + assert payload["items"] == [ + { + "id": str(email_row["id"]), + "task_id": str(task_id), + "task_artifact_id": str(artifact_id), + "relative_path": "mail/update.eml", + "media_type": "message/rfc822", + "sequence_no": 1, + "char_start": 0, + "char_end_exclusive": 11, + "text": "mail/update.eml-chunk", + "score": 0.85, + } + ] diff --git a/tests/unit/test_store.py b/tests/unit/test_store.py new file mode 100644 index 0000000..c046204 --- /dev/null +++ b/tests/unit/test_store.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +from typing import Any +from uuid import uuid4 + +from psycopg.types.json import Jsonb +import pytest + +from alicebot_api.store import ContinuityStore, ContinuityStoreInvariantError + + +class RecordingCursor: + def __init__(self, fetchone_results: list[dict[str, Any]], fetchall_result: list[dict[str, Any]] | None = None) -> None: + self.executed: list[tuple[str, tuple[object, ...] | None]] = [] + self.fetchone_results = list(fetchone_results) + self.fetchall_result = fetchall_result or [] + + def __enter__(self) -> "RecordingCursor": + return self + + def __exit__(self, exc_type, exc, tb) -> None: + return None + + def execute(self, query: str, params: tuple[object, ...] | None = None) -> None: + self.executed.append((query, params)) + + def fetchone(self) -> dict[str, Any] | None: + if not self.fetchone_results: + return None + return self.fetchone_results.pop(0) + + def fetchall(self) -> list[dict[str, Any]]: + return self.fetchall_result + + +class RecordingConnection: + def __init__(self, cursor: RecordingCursor) -> None: + self.cursor_instance = cursor + + def cursor(self) -> RecordingCursor: + return self.cursor_instance + + +def test_create_methods_return_cursor_rows_and_use_expected_parameters() -> None: + user_id = uuid4() + thread_id = uuid4() + cursor = RecordingCursor( + fetchone_results=[ + {"id": user_id, "email": "owner@example.com", "display_name": "Owner"}, + {"id": thread_id, "title": "Starter thread", "agent_profile_id": "assistant_default"}, + {"id": uuid4(), "thread_id": thread_id, "status": "active"}, + ] + ) + store = ContinuityStore(RecordingConnection(cursor)) + + user = store.create_user(user_id, "owner@example.com", "Owner") + thread = store.create_thread("Starter thread") + session = store.create_session(thread_id) + + assert user["id"] == user_id + assert thread["id"] == thread_id + assert session["thread_id"] == thread_id + assert cursor.executed == [ + ( + """ + INSERT INTO users (id, email, display_name) + VALUES (%s, %s, %s) + RETURNING id, email, display_name, created_at + """, + (user_id, "owner@example.com", "Owner"), + ), + ( + """ + INSERT INTO threads (user_id, title, agent_profile_id) + VALUES (app.current_user_id(), %s, %s) + RETURNING id, user_id, title, agent_profile_id, created_at, updated_at + """, + ("Starter thread", "assistant_default"), + ), + ( + """ + INSERT INTO sessions (user_id, thread_id, status) + VALUES (app.current_user_id(), %s, %s) + RETURNING id, user_id, thread_id, status, started_at, ended_at, created_at + """, + (thread_id, "active"), + ), + ] + + +def test_append_event_locks_thread_and_serializes_payload() -> None: + thread_id = uuid4() + session_id = uuid4() + payload = {"text": "hello"} + cursor = RecordingCursor( + fetchone_results=[ + { + "id": uuid4(), + "thread_id": thread_id, + "session_id": session_id, + "sequence_no": 1, + "kind": "message.user", + "payload": payload, + } + ] + ) + store = ContinuityStore(RecordingConnection(cursor)) + + event = store.append_event(thread_id, session_id, "message.user", payload) + + assert event["sequence_no"] == 1 + assert cursor.executed[0] == ( + "SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 0))", + (str(thread_id),), + ) + insert_query, insert_params = cursor.executed[1] + assert "WITH next_sequence AS" in insert_query + assert insert_params is not None + assert insert_params[:4] == (thread_id, thread_id, session_id, "message.user") + assert isinstance(insert_params[4], Jsonb) + assert insert_params[4].obj == payload + + +def test_append_event_raises_clear_error_when_returning_row_is_missing() -> None: + store = ContinuityStore(RecordingConnection(RecordingCursor(fetchone_results=[]))) + + with pytest.raises( + ContinuityStoreInvariantError, + match="append_event did not return a row", + ): + store.append_event(uuid4(), uuid4(), "message.user", {"text": "hello"}) + + +def test_list_thread_events_returns_all_rows_in_order() -> None: + thread_id = uuid4() + events = [ + {"sequence_no": 1, "kind": "message.user"}, + {"sequence_no": 2, "kind": "message.assistant"}, + ] + cursor = RecordingCursor(fetchone_results=[], fetchall_result=events) + store = ContinuityStore(RecordingConnection(cursor)) + + result = store.list_thread_events(thread_id) + + assert result == events + assert cursor.executed == [ + ( + """ + SELECT id, user_id, thread_id, session_id, sequence_no, kind, payload, created_at + FROM events + WHERE thread_id = %s + ORDER BY sequence_no ASC + """, + (thread_id,), + ), + ] + + +def test_create_user_raises_clear_error_when_returning_row_is_missing() -> None: + cursor = RecordingCursor(fetchone_results=[]) + store = ContinuityStore(RecordingConnection(cursor)) + + with pytest.raises( + ContinuityStoreInvariantError, + match="create_user did not return a row", + ): + store.create_user(uuid4(), "owner@example.com") diff --git a/tests/unit/test_task_artifact_chunk_embedding.py b/tests/unit/test_task_artifact_chunk_embedding.py new file mode 100644 index 0000000..d70366a --- /dev/null +++ b/tests/unit/test_task_artifact_chunk_embedding.py @@ -0,0 +1,344 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from uuid import UUID, uuid4 + +import pytest + +from alicebot_api.artifacts import TaskArtifactNotFoundError +from alicebot_api.contracts import TaskArtifactChunkEmbeddingUpsertInput +from alicebot_api.embedding import ( + TaskArtifactChunkEmbeddingNotFoundError, + TaskArtifactChunkEmbeddingValidationError, + get_task_artifact_chunk_embedding_record, + list_task_artifact_chunk_embedding_records_for_artifact, + list_task_artifact_chunk_embedding_records_for_chunk, + upsert_task_artifact_chunk_embedding_record, +) + + +class TaskArtifactChunkEmbeddingStoreStub: + def __init__(self) -> None: + self.base_time = datetime(2026, 3, 14, 12, 0, tzinfo=UTC) + self.artifacts: dict[UUID, dict[str, object]] = {} + self.chunks: dict[UUID, dict[str, object]] = {} + self.configs: dict[UUID, dict[str, object]] = {} + self.embeddings: list[dict[str, object]] = [] + self.embedding_by_id: dict[UUID, dict[str, object]] = {} + + def create_artifact(self) -> UUID: + artifact_id = uuid4() + self.artifacts[artifact_id] = { + "id": artifact_id, + "task_id": uuid4(), + "task_workspace_id": uuid4(), + "status": "registered", + "ingestion_status": "ingested", + "relative_path": "docs/spec.txt", + "media_type_hint": "text/plain", + "created_at": self.base_time, + "updated_at": self.base_time, + } + return artifact_id + + def create_chunk(self, *, task_artifact_id: UUID, sequence_no: int) -> UUID: + chunk_id = uuid4() + self.chunks[chunk_id] = { + "id": chunk_id, + "task_artifact_id": task_artifact_id, + "sequence_no": sequence_no, + "char_start": (sequence_no - 1) * 10, + "char_end_exclusive": sequence_no * 10, + "text": f"chunk-{sequence_no}", + "created_at": self.base_time + timedelta(minutes=sequence_no), + "updated_at": self.base_time + timedelta(minutes=sequence_no), + } + return chunk_id + + def create_config(self, *, dimensions: int = 3) -> UUID: + config_id = uuid4() + self.configs[config_id] = { + "id": config_id, + "provider": "openai", + "model": "text-embedding-3-large", + "version": "2026-03-14", + "dimensions": dimensions, + "status": "active", + "metadata": {"task": "artifact_chunk_retrieval"}, + "created_at": self.base_time, + } + return config_id + + def get_task_artifact_optional(self, task_artifact_id: UUID) -> dict[str, object] | None: + return self.artifacts.get(task_artifact_id) + + def get_task_artifact_chunk_optional( + self, + task_artifact_chunk_id: UUID, + ) -> dict[str, object] | None: + return self.chunks.get(task_artifact_chunk_id) + + def get_embedding_config_optional(self, embedding_config_id: UUID) -> dict[str, object] | None: + return self.configs.get(embedding_config_id) + + def get_task_artifact_chunk_embedding_by_chunk_and_config_optional( + self, + *, + task_artifact_chunk_id: UUID, + embedding_config_id: UUID, + ) -> dict[str, object] | None: + return next( + ( + embedding + for embedding in self.embeddings + if embedding["task_artifact_chunk_id"] == task_artifact_chunk_id + and embedding["embedding_config_id"] == embedding_config_id + ), + None, + ) + + def create_task_artifact_chunk_embedding( + self, + *, + task_artifact_chunk_id: UUID, + embedding_config_id: UUID, + dimensions: int, + vector: list[float], + ) -> dict[str, object]: + chunk = self.chunks[task_artifact_chunk_id] + embedding_id = uuid4() + record = { + "id": embedding_id, + "user_id": uuid4(), + "task_artifact_id": chunk["task_artifact_id"], + "task_artifact_chunk_id": task_artifact_chunk_id, + "task_artifact_chunk_sequence_no": chunk["sequence_no"], + "embedding_config_id": embedding_config_id, + "dimensions": dimensions, + "vector": vector, + "created_at": self.base_time + timedelta(seconds=len(self.embeddings)), + "updated_at": self.base_time + timedelta(seconds=len(self.embeddings)), + } + self.embeddings.append(record) + self.embedding_by_id[embedding_id] = record + return record + + def update_task_artifact_chunk_embedding( + self, + *, + task_artifact_chunk_embedding_id: UUID, + dimensions: int, + vector: list[float], + ) -> dict[str, object]: + record = self.embedding_by_id[task_artifact_chunk_embedding_id] + updated = { + **record, + "dimensions": dimensions, + "vector": vector, + "updated_at": self.base_time + timedelta(minutes=10), + } + self.embedding_by_id[task_artifact_chunk_embedding_id] = updated + for index, existing in enumerate(self.embeddings): + if existing["id"] == task_artifact_chunk_embedding_id: + self.embeddings[index] = updated + return updated + + def get_task_artifact_chunk_embedding_optional( + self, + task_artifact_chunk_embedding_id: UUID, + ) -> dict[str, object] | None: + return self.embedding_by_id.get(task_artifact_chunk_embedding_id) + + def list_task_artifact_chunk_embeddings_for_artifact( + self, + task_artifact_id: UUID, + ) -> list[dict[str, object]]: + return sorted( + ( + embedding + for embedding in self.embeddings + if embedding["task_artifact_id"] == task_artifact_id + ), + key=lambda embedding: ( + embedding["task_artifact_chunk_sequence_no"], + embedding["created_at"], + embedding["id"], + ), + ) + + def list_task_artifact_chunk_embeddings_for_chunk( + self, + task_artifact_chunk_id: UUID, + ) -> list[dict[str, object]]: + return sorted( + ( + embedding + for embedding in self.embeddings + if embedding["task_artifact_chunk_id"] == task_artifact_chunk_id + ), + key=lambda embedding: ( + embedding["task_artifact_chunk_sequence_no"], + embedding["created_at"], + embedding["id"], + ), + ) + + +def test_task_artifact_chunk_embedding_writes_and_reads_are_deterministic() -> None: + store = TaskArtifactChunkEmbeddingStoreStub() + artifact_id = store.create_artifact() + first_chunk_id = store.create_chunk(task_artifact_id=artifact_id, sequence_no=1) + second_chunk_id = store.create_chunk(task_artifact_id=artifact_id, sequence_no=2) + config_id = store.create_config() + + second_write = upsert_task_artifact_chunk_embedding_record( + store, # type: ignore[arg-type] + user_id=uuid4(), + request=TaskArtifactChunkEmbeddingUpsertInput( + task_artifact_chunk_id=second_chunk_id, + embedding_config_id=config_id, + vector=(0.4, 0.5, 0.6), + ), + ) + first_write = upsert_task_artifact_chunk_embedding_record( + store, # type: ignore[arg-type] + user_id=uuid4(), + request=TaskArtifactChunkEmbeddingUpsertInput( + task_artifact_chunk_id=first_chunk_id, + embedding_config_id=config_id, + vector=(0.1, 0.2, 0.3), + ), + ) + updated = upsert_task_artifact_chunk_embedding_record( + store, # type: ignore[arg-type] + user_id=uuid4(), + request=TaskArtifactChunkEmbeddingUpsertInput( + task_artifact_chunk_id=second_chunk_id, + embedding_config_id=config_id, + vector=(0.9, 0.8, 0.7), + ), + ) + + artifact_payload = list_task_artifact_chunk_embedding_records_for_artifact( + store, # type: ignore[arg-type] + user_id=uuid4(), + task_artifact_id=artifact_id, + ) + chunk_payload = list_task_artifact_chunk_embedding_records_for_chunk( + store, # type: ignore[arg-type] + user_id=uuid4(), + task_artifact_chunk_id=second_chunk_id, + ) + detail_payload = get_task_artifact_chunk_embedding_record( + store, # type: ignore[arg-type] + user_id=uuid4(), + task_artifact_chunk_embedding_id=UUID(updated["embedding"]["id"]), + ) + + assert second_write["write_mode"] == "created" + assert first_write["write_mode"] == "created" + assert updated["write_mode"] == "updated" + assert updated["embedding"]["vector"] == [0.9, 0.8, 0.7] + assert [item["task_artifact_chunk_id"] for item in artifact_payload["items"]] == [ + str(first_chunk_id), + str(second_chunk_id), + ] + assert artifact_payload["summary"] == { + "total_count": 2, + "order": ["task_artifact_chunk_sequence_no_asc", "created_at_asc", "id_asc"], + "scope": { + "kind": "artifact", + "task_artifact_id": str(artifact_id), + }, + } + assert chunk_payload["summary"] == { + "total_count": 1, + "order": ["task_artifact_chunk_sequence_no_asc", "created_at_asc", "id_asc"], + "scope": { + "kind": "chunk", + "task_artifact_id": str(artifact_id), + "task_artifact_chunk_id": str(second_chunk_id), + }, + } + assert detail_payload["embedding"]["id"] == updated["embedding"]["id"] + assert detail_payload["embedding"]["task_artifact_chunk_sequence_no"] == 2 + + +def test_task_artifact_chunk_embedding_writes_reject_missing_refs_and_dimension_mismatch() -> None: + store = TaskArtifactChunkEmbeddingStoreStub() + artifact_id = store.create_artifact() + chunk_id = store.create_chunk(task_artifact_id=artifact_id, sequence_no=1) + config_id = store.create_config(dimensions=3) + + with pytest.raises( + TaskArtifactChunkEmbeddingValidationError, + match="task_artifact_chunk_id must reference an existing task artifact chunk owned by the user", + ): + upsert_task_artifact_chunk_embedding_record( + store, # type: ignore[arg-type] + user_id=uuid4(), + request=TaskArtifactChunkEmbeddingUpsertInput( + task_artifact_chunk_id=uuid4(), + embedding_config_id=config_id, + vector=(0.1, 0.2, 0.3), + ), + ) + + with pytest.raises( + TaskArtifactChunkEmbeddingValidationError, + match="embedding_config_id must reference an existing embedding config owned by the user", + ): + upsert_task_artifact_chunk_embedding_record( + store, # type: ignore[arg-type] + user_id=uuid4(), + request=TaskArtifactChunkEmbeddingUpsertInput( + task_artifact_chunk_id=chunk_id, + embedding_config_id=uuid4(), + vector=(0.1, 0.2, 0.3), + ), + ) + + with pytest.raises( + TaskArtifactChunkEmbeddingValidationError, + match=r"vector length must match embedding config dimensions \(3\): 2", + ): + upsert_task_artifact_chunk_embedding_record( + store, # type: ignore[arg-type] + user_id=uuid4(), + request=TaskArtifactChunkEmbeddingUpsertInput( + task_artifact_chunk_id=chunk_id, + embedding_config_id=config_id, + vector=(0.1, 0.2), + ), + ) + + +def test_task_artifact_chunk_embedding_reads_raise_not_found_when_scope_is_missing() -> None: + store = TaskArtifactChunkEmbeddingStoreStub() + + with pytest.raises(TaskArtifactNotFoundError, match="task artifact .* was not found"): + list_task_artifact_chunk_embedding_records_for_artifact( + store, # type: ignore[arg-type] + user_id=uuid4(), + task_artifact_id=uuid4(), + ) + + with pytest.raises( + TaskArtifactChunkEmbeddingNotFoundError, + match="task artifact chunk .* was not found", + ): + list_task_artifact_chunk_embedding_records_for_chunk( + store, # type: ignore[arg-type] + user_id=uuid4(), + task_artifact_chunk_id=uuid4(), + ) + + with pytest.raises( + TaskArtifactChunkEmbeddingNotFoundError, + match="task artifact chunk embedding .* was not found", + ): + get_task_artifact_chunk_embedding_record( + store, # type: ignore[arg-type] + user_id=uuid4(), + task_artifact_chunk_embedding_id=uuid4(), + ) diff --git a/tests/unit/test_task_artifact_chunk_embedding_store.py b/tests/unit/test_task_artifact_chunk_embedding_store.py new file mode 100644 index 0000000..08704fb --- /dev/null +++ b/tests/unit/test_task_artifact_chunk_embedding_store.py @@ -0,0 +1,318 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any +from uuid import uuid4 + +from psycopg.types.json import Jsonb + +from alicebot_api.store import ContinuityStore + + +class RecordingCursor: + def __init__( + self, + fetchone_results: list[dict[str, Any]], + fetchall_results: list[list[dict[str, Any]]] | None = None, + ) -> None: + self.executed: list[tuple[str, tuple[object, ...] | None]] = [] + self.fetchone_results = list(fetchone_results) + self.fetchall_results = list(fetchall_results or []) + + def __enter__(self) -> "RecordingCursor": + return self + + def __exit__(self, exc_type, exc, tb) -> None: + return None + + def execute(self, query: str, params: tuple[object, ...] | None = None) -> None: + self.executed.append((query, params)) + + def fetchone(self) -> dict[str, Any] | None: + if not self.fetchone_results: + return None + return self.fetchone_results.pop(0) + + def fetchall(self) -> list[dict[str, Any]]: + if not self.fetchall_results: + return [] + return self.fetchall_results.pop(0) + + +class RecordingConnection: + def __init__(self, cursor: RecordingCursor) -> None: + self.cursor_instance = cursor + + def cursor(self) -> RecordingCursor: + return self.cursor_instance + + +def test_task_artifact_chunk_embedding_store_methods_use_expected_queries() -> None: + task_artifact_id = uuid4() + task_artifact_chunk_id = uuid4() + embedding_config_id = uuid4() + embedding_id = uuid4() + created_at = datetime(2026, 3, 14, 12, 0, tzinfo=UTC) + updated_at = datetime(2026, 3, 14, 12, 5, tzinfo=UTC) + cursor = RecordingCursor( + fetchone_results=[ + { + "id": task_artifact_chunk_id, + "user_id": uuid4(), + "task_artifact_id": task_artifact_id, + "sequence_no": 2, + "char_start": 10, + "char_end_exclusive": 20, + "text": "chunk-2", + "created_at": created_at, + "updated_at": created_at, + }, + { + "id": embedding_id, + "user_id": uuid4(), + "task_artifact_id": task_artifact_id, + "task_artifact_chunk_id": task_artifact_chunk_id, + "task_artifact_chunk_sequence_no": 2, + "embedding_config_id": embedding_config_id, + "dimensions": 3, + "vector": [0.1, 0.2, 0.3], + "created_at": created_at, + "updated_at": created_at, + }, + { + "id": embedding_id, + "user_id": uuid4(), + "task_artifact_id": task_artifact_id, + "task_artifact_chunk_id": task_artifact_chunk_id, + "task_artifact_chunk_sequence_no": 2, + "embedding_config_id": embedding_config_id, + "dimensions": 3, + "vector": [0.3, 0.2, 0.1], + "created_at": created_at, + "updated_at": updated_at, + }, + { + "id": embedding_id, + "user_id": uuid4(), + "task_artifact_id": task_artifact_id, + "task_artifact_chunk_id": task_artifact_chunk_id, + "task_artifact_chunk_sequence_no": 2, + "embedding_config_id": embedding_config_id, + "dimensions": 3, + "vector": [0.3, 0.2, 0.1], + "created_at": created_at, + "updated_at": updated_at, + }, + { + "id": embedding_id, + "user_id": uuid4(), + "task_artifact_id": task_artifact_id, + "task_artifact_chunk_id": task_artifact_chunk_id, + "task_artifact_chunk_sequence_no": 2, + "embedding_config_id": embedding_config_id, + "dimensions": 3, + "vector": [0.3, 0.2, 0.1], + "created_at": created_at, + "updated_at": updated_at, + }, + ], + fetchall_results=[ + [ + { + "id": embedding_id, + "task_artifact_id": task_artifact_id, + "task_artifact_chunk_id": task_artifact_chunk_id, + "task_artifact_chunk_sequence_no": 2, + "embedding_config_id": embedding_config_id, + } + ], + [ + { + "id": embedding_id, + "task_artifact_id": task_artifact_id, + "task_artifact_chunk_id": task_artifact_chunk_id, + "task_artifact_chunk_sequence_no": 2, + "embedding_config_id": embedding_config_id, + } + ], + ], + ) + store = ContinuityStore(RecordingConnection(cursor)) + + fetched_chunk = store.get_task_artifact_chunk_optional(task_artifact_chunk_id) + created = store.create_task_artifact_chunk_embedding( + task_artifact_chunk_id=task_artifact_chunk_id, + embedding_config_id=embedding_config_id, + dimensions=3, + vector=[0.1, 0.2, 0.3], + ) + updated = store.update_task_artifact_chunk_embedding( + task_artifact_chunk_embedding_id=embedding_id, + dimensions=3, + vector=[0.3, 0.2, 0.1], + ) + fetched_embedding = store.get_task_artifact_chunk_embedding_optional(embedding_id) + existing = store.get_task_artifact_chunk_embedding_by_chunk_and_config_optional( + task_artifact_chunk_id=task_artifact_chunk_id, + embedding_config_id=embedding_config_id, + ) + listed_for_chunk = store.list_task_artifact_chunk_embeddings_for_chunk(task_artifact_chunk_id) + listed_for_artifact = store.list_task_artifact_chunk_embeddings_for_artifact(task_artifact_id) + + assert fetched_chunk is not None + assert fetched_chunk["id"] == task_artifact_chunk_id + assert created["id"] == embedding_id + assert updated["updated_at"] == updated_at + assert fetched_embedding is not None + assert existing is not None + assert listed_for_chunk == [ + { + "id": embedding_id, + "task_artifact_id": task_artifact_id, + "task_artifact_chunk_id": task_artifact_chunk_id, + "task_artifact_chunk_sequence_no": 2, + "embedding_config_id": embedding_config_id, + } + ] + assert listed_for_artifact == [ + { + "id": embedding_id, + "task_artifact_id": task_artifact_id, + "task_artifact_chunk_id": task_artifact_chunk_id, + "task_artifact_chunk_sequence_no": 2, + "embedding_config_id": embedding_config_id, + } + ] + + get_chunk_query, get_chunk_params = cursor.executed[0] + assert "FROM task_artifact_chunks" in get_chunk_query + assert get_chunk_params == (task_artifact_chunk_id,) + + create_query, create_params = cursor.executed[1] + assert "INSERT INTO task_artifact_chunk_embeddings" in create_query + assert "JOIN task_artifact_chunks AS chunks" in create_query + assert create_params is not None + assert create_params[:3] == (task_artifact_chunk_id, embedding_config_id, 3) + assert isinstance(create_params[3], Jsonb) + assert create_params[3].obj == [0.1, 0.2, 0.3] + + update_query, update_params = cursor.executed[2] + assert "UPDATE task_artifact_chunk_embeddings" in update_query + assert update_params is not None + assert update_params[0] == 3 + assert isinstance(update_params[1], Jsonb) + assert update_params[1].obj == [0.3, 0.2, 0.1] + assert update_params[2] == embedding_id + + get_embedding_query, get_embedding_params = cursor.executed[3] + assert "FROM task_artifact_chunk_embeddings AS embeddings" in get_embedding_query + assert get_embedding_params == (embedding_id,) + + get_existing_query, get_existing_params = cursor.executed[4] + assert "WHERE embeddings.task_artifact_chunk_id = %s" in get_existing_query + assert "AND embeddings.embedding_config_id = %s" in get_existing_query + assert get_existing_params == (task_artifact_chunk_id, embedding_config_id) + + list_chunk_query, list_chunk_params = cursor.executed[5] + assert "WHERE embeddings.task_artifact_chunk_id = %s" in list_chunk_query + assert "ORDER BY chunks.sequence_no ASC, embeddings.created_at ASC, embeddings.id ASC" in list_chunk_query + assert list_chunk_params == (task_artifact_chunk_id,) + + list_artifact_query, list_artifact_params = cursor.executed[6] + assert "WHERE chunks.task_artifact_id = %s" in list_artifact_query + assert "ORDER BY chunks.sequence_no ASC, embeddings.created_at ASC, embeddings.id ASC" in list_artifact_query + assert list_artifact_params == (task_artifact_id,) + + +def test_task_artifact_chunk_embedding_store_optional_reads_return_none_when_row_is_missing() -> None: + cursor = RecordingCursor(fetchone_results=[]) + store = ContinuityStore(RecordingConnection(cursor)) + + assert store.get_task_artifact_chunk_optional(uuid4()) is None + assert store.get_task_artifact_chunk_embedding_optional(uuid4()) is None + assert store.get_task_artifact_chunk_embedding_by_chunk_and_config_optional( + task_artifact_chunk_id=uuid4(), + embedding_config_id=uuid4(), + ) is None + + +def test_semantic_artifact_chunk_retrieval_store_methods_use_expected_queries() -> None: + task_id = uuid4() + task_artifact_id = uuid4() + task_artifact_chunk_id = uuid4() + embedding_config_id = uuid4() + created_at = datetime(2026, 3, 15, 9, 0, tzinfo=UTC) + cursor = RecordingCursor( + fetchone_results=[], + fetchall_results=[ + [ + { + "id": task_artifact_chunk_id, + "user_id": uuid4(), + "task_id": task_id, + "task_artifact_id": task_artifact_id, + "relative_path": "docs/spec.txt", + "media_type_hint": "text/plain", + "sequence_no": 1, + "char_start": 0, + "char_end_exclusive": 11, + "text": "alpha chunk", + "created_at": created_at, + "updated_at": created_at, + "embedding_config_id": embedding_config_id, + "score": 1.0, + } + ], + [ + { + "id": task_artifact_chunk_id, + "user_id": uuid4(), + "task_id": task_id, + "task_artifact_id": task_artifact_id, + "relative_path": "docs/spec.txt", + "media_type_hint": "text/plain", + "sequence_no": 1, + "char_start": 0, + "char_end_exclusive": 11, + "text": "alpha chunk", + "created_at": created_at, + "updated_at": created_at, + "embedding_config_id": embedding_config_id, + "score": 1.0, + } + ], + ], + ) + store = ContinuityStore(RecordingConnection(cursor)) + + task_rows = store.retrieve_task_scoped_semantic_artifact_chunk_matches( + task_id=task_id, + embedding_config_id=embedding_config_id, + query_vector=[1.0, 0.0, 0.0], + limit=5, + ) + artifact_rows = store.retrieve_artifact_scoped_semantic_artifact_chunk_matches( + task_artifact_id=task_artifact_id, + embedding_config_id=embedding_config_id, + query_vector=[1.0, 0.0, 0.0], + limit=3, + ) + + assert task_rows[0]["task_id"] == task_id + assert artifact_rows[0]["task_artifact_id"] == task_artifact_id + + task_query, task_params = cursor.executed[0] + assert "FROM task_artifact_chunk_embeddings AS embeddings" in task_query + assert "JOIN task_artifacts AS artifacts" in task_query + assert "artifacts.task_id = %s" in task_query + assert "artifacts.ingestion_status = 'ingested'" in task_query + assert "ORDER BY score DESC, artifacts.relative_path ASC, chunks.sequence_no ASC, chunks.id ASC" in task_query + assert task_params == ("[1.0,0.0,0.0]", embedding_config_id, 3, task_id, 5) + + artifact_query, artifact_params = cursor.executed[1] + assert "FROM task_artifact_chunk_embeddings AS embeddings" in artifact_query + assert "JOIN task_artifacts AS artifacts" in artifact_query + assert "artifacts.id = %s" in artifact_query + assert "artifacts.ingestion_status = 'ingested'" in artifact_query + assert "ORDER BY score DESC, artifacts.relative_path ASC, chunks.sequence_no ASC, chunks.id ASC" in artifact_query + assert artifact_params == ("[1.0,0.0,0.0]", embedding_config_id, 3, task_artifact_id, 3) diff --git a/tests/unit/test_task_artifact_store.py b/tests/unit/test_task_artifact_store.py new file mode 100644 index 0000000..938c680 --- /dev/null +++ b/tests/unit/test_task_artifact_store.py @@ -0,0 +1,391 @@ +from __future__ import annotations + +from typing import Any +from uuid import uuid4 + +from alicebot_api.store import ContinuityStore + + +class RecordingCursor: + def __init__(self, fetchone_results: list[dict[str, Any]], fetchall_result: list[dict[str, Any]] | None = None) -> None: + self.executed: list[tuple[str, tuple[object, ...] | None]] = [] + self.fetchone_results = list(fetchone_results) + self.fetchall_result = fetchall_result or [] + + def __enter__(self) -> "RecordingCursor": + return self + + def __exit__(self, exc_type, exc, tb) -> None: + return None + + def execute(self, query: str, params: tuple[object, ...] | None = None) -> None: + self.executed.append((query, params)) + + def fetchone(self) -> dict[str, Any] | None: + if not self.fetchone_results: + return None + return self.fetchone_results.pop(0) + + def fetchall(self) -> list[dict[str, Any]]: + return self.fetchall_result + + +class RecordingConnection: + def __init__(self, cursor: RecordingCursor) -> None: + self.cursor_instance = cursor + + def cursor(self) -> RecordingCursor: + return self.cursor_instance + + +def test_task_artifact_store_methods_use_expected_queries() -> None: + task_artifact_id = uuid4() + task_id = uuid4() + task_workspace_id = uuid4() + cursor = RecordingCursor( + fetchone_results=[ + { + "id": task_artifact_id, + "user_id": uuid4(), + "task_id": task_id, + "task_workspace_id": task_workspace_id, + "status": "registered", + "ingestion_status": "pending", + "relative_path": "docs/spec.txt", + "media_type_hint": "text/plain", + "created_at": "2026-03-13T10:00:00+00:00", + "updated_at": "2026-03-13T10:00:00+00:00", + }, + { + "id": task_artifact_id, + "user_id": uuid4(), + "task_id": task_id, + "task_workspace_id": task_workspace_id, + "status": "registered", + "ingestion_status": "pending", + "relative_path": "docs/spec.txt", + "media_type_hint": "text/plain", + "created_at": "2026-03-13T10:00:00+00:00", + "updated_at": "2026-03-13T10:00:00+00:00", + }, + { + "id": task_artifact_id, + "user_id": uuid4(), + "task_id": task_id, + "task_workspace_id": task_workspace_id, + "status": "registered", + "ingestion_status": "pending", + "relative_path": "docs/spec.txt", + "media_type_hint": "text/plain", + "created_at": "2026-03-13T10:00:00+00:00", + "updated_at": "2026-03-13T10:00:00+00:00", + }, + ], + fetchall_result=[ + { + "id": task_artifact_id, + "user_id": uuid4(), + "task_id": task_id, + "task_workspace_id": task_workspace_id, + "status": "registered", + "ingestion_status": "pending", + "relative_path": "docs/spec.txt", + "media_type_hint": "text/plain", + "created_at": "2026-03-13T10:00:00+00:00", + "updated_at": "2026-03-13T10:00:00+00:00", + } + ], + ) + store = ContinuityStore(RecordingConnection(cursor)) + + created = store.create_task_artifact( + task_id=task_id, + task_workspace_id=task_workspace_id, + status="registered", + ingestion_status="pending", + relative_path="docs/spec.txt", + media_type_hint="text/plain", + ) + fetched = store.get_task_artifact_optional(task_artifact_id) + duplicate = store.get_task_artifact_by_workspace_relative_path_optional( + task_workspace_id=task_workspace_id, + relative_path="docs/spec.txt", + ) + listed = store.list_task_artifacts() + listed_for_task = store.list_task_artifacts_for_task(task_id) + store.lock_task_artifacts(task_workspace_id) + + assert created["id"] == task_artifact_id + assert fetched is not None + assert duplicate is not None + assert listed[0]["id"] == task_artifact_id + assert listed_for_task[0]["id"] == task_artifact_id + assert cursor.executed == [ + ( + """ + INSERT INTO task_artifacts ( + user_id, + task_id, + task_workspace_id, + status, + ingestion_status, + relative_path, + media_type_hint, + created_at, + updated_at + ) + VALUES ( + app.current_user_id(), + %s, + %s, + %s, + %s, + %s, + %s, + clock_timestamp(), + clock_timestamp() + ) + RETURNING + id, + user_id, + task_id, + task_workspace_id, + status, + ingestion_status, + relative_path, + media_type_hint, + created_at, + updated_at + """, + ( + task_id, + task_workspace_id, + "registered", + "pending", + "docs/spec.txt", + "text/plain", + ), + ), + ( + """ + SELECT + id, + user_id, + task_id, + task_workspace_id, + status, + ingestion_status, + relative_path, + media_type_hint, + created_at, + updated_at + FROM task_artifacts + WHERE id = %s + """, + (task_artifact_id,), + ), + ( + """ + SELECT + id, + user_id, + task_id, + task_workspace_id, + status, + ingestion_status, + relative_path, + media_type_hint, + created_at, + updated_at + FROM task_artifacts + WHERE task_workspace_id = %s + AND relative_path = %s + ORDER BY created_at ASC, id ASC + LIMIT 1 + """, + (task_workspace_id, "docs/spec.txt"), + ), + ( + """ + SELECT + id, + user_id, + task_id, + task_workspace_id, + status, + ingestion_status, + relative_path, + media_type_hint, + created_at, + updated_at + FROM task_artifacts + ORDER BY created_at ASC, id ASC + """, + None, + ), + ( + """ + SELECT + id, + user_id, + task_id, + task_workspace_id, + status, + ingestion_status, + relative_path, + media_type_hint, + created_at, + updated_at + FROM task_artifacts + WHERE task_id = %s + ORDER BY created_at ASC, id ASC + """, + (task_id,), + ), + ( + "SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 4))", + (str(task_workspace_id),), + ), + ] + + +def test_task_artifact_chunk_store_methods_use_expected_queries() -> None: + task_artifact_id = uuid4() + cursor = RecordingCursor( + fetchone_results=[ + { + "id": uuid4(), + "user_id": uuid4(), + "task_artifact_id": task_artifact_id, + "sequence_no": 1, + "char_start": 0, + "char_end_exclusive": 4, + "text": "spec", + "created_at": "2026-03-14T10:00:00+00:00", + "updated_at": "2026-03-14T10:00:00+00:00", + }, + { + "id": task_artifact_id, + "user_id": uuid4(), + "task_id": uuid4(), + "task_workspace_id": uuid4(), + "status": "registered", + "ingestion_status": "ingested", + "relative_path": "docs/spec.txt", + "media_type_hint": "text/plain", + "created_at": "2026-03-14T10:00:00+00:00", + "updated_at": "2026-03-14T10:01:00+00:00", + }, + ], + fetchall_result=[ + { + "id": uuid4(), + "user_id": uuid4(), + "task_artifact_id": task_artifact_id, + "sequence_no": 1, + "char_start": 0, + "char_end_exclusive": 4, + "text": "spec", + "created_at": "2026-03-14T10:00:00+00:00", + "updated_at": "2026-03-14T10:00:00+00:00", + } + ], + ) + store = ContinuityStore(RecordingConnection(cursor)) + + created = store.create_task_artifact_chunk( + task_artifact_id=task_artifact_id, + sequence_no=1, + char_start=0, + char_end_exclusive=4, + text="spec", + ) + updated = store.update_task_artifact_ingestion_status( + task_artifact_id=task_artifact_id, + ingestion_status="ingested", + ) + listed = store.list_task_artifact_chunks(task_artifact_id) + store.lock_task_artifact_ingestion(task_artifact_id) + + assert created["task_artifact_id"] == task_artifact_id + assert updated["ingestion_status"] == "ingested" + assert listed[0]["task_artifact_id"] == task_artifact_id + assert cursor.executed == [ + ( + """ + INSERT INTO task_artifact_chunks ( + user_id, + task_artifact_id, + sequence_no, + char_start, + char_end_exclusive, + text, + created_at, + updated_at + ) + VALUES ( + app.current_user_id(), + %s, + %s, + %s, + %s, + %s, + clock_timestamp(), + clock_timestamp() + ) + RETURNING + id, + user_id, + task_artifact_id, + sequence_no, + char_start, + char_end_exclusive, + text, + created_at, + updated_at + """, + (task_artifact_id, 1, 0, 4, "spec"), + ), + ( + """ + UPDATE task_artifacts + SET ingestion_status = %s, + updated_at = clock_timestamp() + WHERE id = %s + RETURNING + id, + user_id, + task_id, + task_workspace_id, + status, + ingestion_status, + relative_path, + media_type_hint, + created_at, + updated_at + """, + ("ingested", task_artifact_id), + ), + ( + """ + SELECT + id, + user_id, + task_artifact_id, + sequence_no, + char_start, + char_end_exclusive, + text, + created_at, + updated_at + FROM task_artifact_chunks + WHERE task_artifact_id = %s + ORDER BY sequence_no ASC, id ASC + """, + (task_artifact_id,), + ), + ( + "SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 5))", + (str(task_artifact_id),), + ), + ] diff --git a/tests/unit/test_task_run_store.py b/tests/unit/test_task_run_store.py new file mode 100644 index 0000000..cbe5e1b --- /dev/null +++ b/tests/unit/test_task_run_store.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +from typing import Any +from uuid import uuid4 + +from psycopg.types.json import Jsonb + +from alicebot_api.store import ContinuityStore + + +class RecordingCursor: + def __init__(self, fetchone_results: list[dict[str, Any]], fetchall_result: list[dict[str, Any]] | None = None) -> None: + self.executed: list[tuple[str, tuple[object, ...] | None]] = [] + self.fetchone_results = list(fetchone_results) + self.fetchall_result = fetchall_result or [] + + def __enter__(self) -> "RecordingCursor": + return self + + def __exit__(self, exc_type, exc, tb) -> None: + return None + + def execute(self, query: str, params: tuple[object, ...] | None = None) -> None: + self.executed.append((query, params)) + + def fetchone(self) -> dict[str, Any] | None: + if not self.fetchone_results: + return None + return self.fetchone_results.pop(0) + + def fetchall(self) -> list[dict[str, Any]]: + return self.fetchall_result + + +class RecordingConnection: + def __init__(self, cursor: RecordingCursor) -> None: + self.cursor_instance = cursor + + def cursor(self) -> RecordingCursor: + return self.cursor_instance + + +def test_task_run_store_methods_use_expected_queries_and_jsonb_parameters() -> None: + task_id = uuid4() + task_run_id = uuid4() + row = { + "id": task_run_id, + "user_id": uuid4(), + "task_id": task_id, + "status": "queued", + "checkpoint": {"cursor": 0, "target_steps": 2, "wait_for_signal": False}, + "tick_count": 0, + "step_count": 0, + "max_ticks": 2, + "retry_count": 0, + "retry_cap": 3, + "retry_posture": "none", + "failure_class": None, + "stop_reason": None, + "last_transitioned_at": "2026-03-27T10:00:00+00:00", + "created_at": "2026-03-27T10:00:00+00:00", + "updated_at": "2026-03-27T10:00:00+00:00", + } + cursor = RecordingCursor( + fetchone_results=[ + row, + row, + {**row, "status": "running", "tick_count": 1, "step_count": 1}, + {**row, "status": "running"}, + ], + fetchall_result=[row], + ) + store = ContinuityStore(RecordingConnection(cursor)) + + created = store.create_task_run( + task_id=task_id, + status="queued", + checkpoint={"cursor": 0, "target_steps": 2, "wait_for_signal": False}, + tick_count=0, + step_count=0, + max_ticks=2, + retry_count=0, + retry_cap=3, + retry_posture="none", + failure_class=None, + stop_reason=None, + ) + fetched = store.get_task_run_optional(task_run_id) + listed = store.list_task_runs_for_task(task_id) + updated = store.update_task_run_optional( + task_run_id=task_run_id, + status="running", + checkpoint={"cursor": 1, "target_steps": 2, "wait_for_signal": False}, + tick_count=1, + step_count=1, + retry_count=0, + retry_cap=3, + retry_posture="none", + failure_class=None, + stop_reason=None, + ) + acquired = store.acquire_next_task_run_optional() + + assert created["id"] == task_run_id + assert fetched is not None + assert fetched["id"] == task_run_id + assert listed[0]["id"] == task_run_id + assert updated is not None + assert updated["status"] == "running" + assert acquired is not None + assert acquired["status"] == "running" + + create_query, create_params = cursor.executed[0] + assert "INSERT INTO task_runs" in create_query + assert create_params is not None + assert create_params[0] == task_id + assert create_params[1] == "queued" + assert isinstance(create_params[2], Jsonb) + assert create_params[2].obj == {"cursor": 0, "target_steps": 2, "wait_for_signal": False} + assert create_params[3:] == (0, 0, 2, 0, 3, "none", None, None) + + assert "FROM task_runs" in cursor.executed[1][0] + assert "ORDER BY created_at ASC, id ASC" in cursor.executed[2][0] + + update_query, update_params = cursor.executed[3] + assert "UPDATE task_runs" in update_query + assert update_params is not None + assert update_params[0] == "running" + assert isinstance(update_params[1], Jsonb) + assert update_params[1].obj == {"cursor": 1, "target_steps": 2, "wait_for_signal": False} + assert update_params[2:] == (1, 1, 0, 3, "none", None, None, task_run_id) + + acquire_query, acquire_params = cursor.executed[4] + assert "WITH candidate AS" in acquire_query + assert "FOR UPDATE SKIP LOCKED" in acquire_query + assert "UPDATE task_runs" in acquire_query + assert acquire_params is None diff --git a/tests/unit/test_task_runs.py b/tests/unit/test_task_runs.py new file mode 100644 index 0000000..1a1377c --- /dev/null +++ b/tests/unit/test_task_runs.py @@ -0,0 +1,384 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from uuid import UUID, uuid4 + +import pytest + +from alicebot_api.contracts import ( + TaskRunCancelInput, + TaskRunCreateInput, + TaskRunPauseInput, + TaskRunResumeInput, + TaskRunTickInput, +) +from alicebot_api.task_runs import ( + TaskRunNotFoundError, + TaskRunTransitionError, + TaskRunValidationError, + cancel_task_run_record, + create_task_run_record, + get_task_run_record, + list_task_run_records, + pause_task_run_record, + resume_task_run_record, + tick_task_run_record, +) +from alicebot_api.tasks import TaskNotFoundError + + +class TaskRunStoreStub: + def __init__(self) -> None: + self.base_time = datetime(2026, 3, 27, 10, 0, tzinfo=UTC) + self.user_id = uuid4() + self.tasks: list[dict[str, object]] = [] + self.task_runs: list[dict[str, object]] = [] + + def seed_task(self) -> UUID: + task_id = uuid4() + self.tasks.append( + { + "id": task_id, + "user_id": self.user_id, + "thread_id": uuid4(), + "tool_id": uuid4(), + "status": "approved", + "request": { + "thread_id": str(uuid4()), + "tool_id": str(uuid4()), + "action": "tool.run", + "scope": "workspace", + "domain_hint": None, + "risk_hint": None, + "attributes": {}, + }, + "tool": {"id": str(uuid4()), "tool_key": "proxy.echo"}, + "latest_approval_id": None, + "latest_execution_id": None, + "created_at": self.base_time, + "updated_at": self.base_time, + } + ) + return task_id + + def get_task_optional(self, task_id: UUID) -> dict[str, object] | None: + return next((task for task in self.tasks if task["id"] == task_id), None) + + def create_task_run( + self, + *, + task_id: UUID, + status: str, + checkpoint: dict[str, object], + tick_count: int, + step_count: int, + max_ticks: int, + retry_count: int, + retry_cap: int, + retry_posture: str, + failure_class: str | None, + stop_reason: str | None, + ) -> dict[str, object]: + row = { + "id": uuid4(), + "user_id": self.user_id, + "task_id": task_id, + "status": status, + "checkpoint": dict(checkpoint), + "tick_count": tick_count, + "step_count": step_count, + "max_ticks": max_ticks, + "retry_count": retry_count, + "retry_cap": retry_cap, + "retry_posture": retry_posture, + "failure_class": failure_class, + "stop_reason": stop_reason, + "last_transitioned_at": self.base_time + timedelta(minutes=len(self.task_runs)), + "created_at": self.base_time + timedelta(minutes=len(self.task_runs)), + "updated_at": self.base_time + timedelta(minutes=len(self.task_runs)), + } + self.task_runs.append(row) + self.task_runs.sort(key=lambda item: (item["created_at"], item["id"])) + return row + + def list_task_runs_for_task(self, task_id: UUID) -> list[dict[str, object]]: + return [row for row in self.task_runs if row["task_id"] == task_id] + + def get_task_run_optional(self, task_run_id: UUID) -> dict[str, object] | None: + return next((row for row in self.task_runs if row["id"] == task_run_id), None) + + def update_task_run_optional( + self, + *, + task_run_id: UUID, + status: str, + checkpoint: dict[str, object], + tick_count: int, + step_count: int, + retry_count: int, + retry_cap: int, + retry_posture: str, + failure_class: str | None, + stop_reason: str | None, + ) -> dict[str, object] | None: + row = self.get_task_run_optional(task_run_id) + if row is None: + return None + row["status"] = status + row["checkpoint"] = dict(checkpoint) + row["tick_count"] = tick_count + row["step_count"] = step_count + row["retry_count"] = retry_count + row["retry_cap"] = retry_cap + row["retry_posture"] = retry_posture + row["failure_class"] = failure_class + row["stop_reason"] = stop_reason + row["last_transitioned_at"] = self.base_time + timedelta(hours=1, minutes=tick_count + step_count) + row["updated_at"] = self.base_time + timedelta(hours=1, minutes=tick_count + step_count) + return row + + +def test_create_list_and_get_task_run_records_are_deterministic() -> None: + store = TaskRunStoreStub() + task_id = store.seed_task() + + first = create_task_run_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=TaskRunCreateInput( + task_id=task_id, + max_ticks=2, + checkpoint={"cursor": 0, "target_steps": 2, "wait_for_signal": False}, + ), + ) + second = create_task_run_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=TaskRunCreateInput( + task_id=task_id, + max_ticks=1, + checkpoint={"cursor": 0, "target_steps": 1, "wait_for_signal": False}, + ), + ) + + listed = list_task_run_records( + store, # type: ignore[arg-type] + user_id=store.user_id, + task_id=task_id, + ) + detail = get_task_run_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + task_run_id=UUID(second["task_run"]["id"]), + ) + + assert [item["id"] for item in listed["items"]] == [ + first["task_run"]["id"], + second["task_run"]["id"], + ] + assert listed["summary"] == { + "task_id": str(task_id), + "total_count": 2, + "order": ["created_at_asc", "id_asc"], + } + assert detail == {"task_run": second["task_run"]} + + +def test_tick_advances_checkpoint_and_completes_run() -> None: + store = TaskRunStoreStub() + task_id = store.seed_task() + created = create_task_run_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=TaskRunCreateInput( + task_id=task_id, + max_ticks=3, + checkpoint={"cursor": 0, "target_steps": 2, "wait_for_signal": False}, + ), + ) + task_run_id = UUID(created["task_run"]["id"]) + + first_tick = tick_task_run_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=TaskRunTickInput(task_run_id=task_run_id), + ) + second_tick = tick_task_run_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=TaskRunTickInput(task_run_id=task_run_id), + ) + + assert first_tick["previous_status"] == "queued" + assert first_tick["task_run"]["status"] == "running" + assert first_tick["task_run"]["tick_count"] == 1 + assert first_tick["task_run"]["step_count"] == 1 + assert first_tick["task_run"]["checkpoint"]["cursor"] == 1 + assert first_tick["task_run"]["stop_reason"] is None + assert first_tick["task_run"]["retry_posture"] == "none" + + assert second_tick["previous_status"] == "running" + assert second_tick["task_run"]["status"] == "done" + assert second_tick["task_run"]["checkpoint"]["cursor"] == 2 + assert second_tick["task_run"]["tick_count"] == 2 + assert second_tick["task_run"]["step_count"] == 2 + assert second_tick["task_run"]["stop_reason"] == "done" + assert second_tick["task_run"]["retry_posture"] == "terminal" + + +def test_tick_sets_budget_exhaustion_as_failed_with_explicit_failure_class() -> None: + store = TaskRunStoreStub() + task_id = store.seed_task() + created = create_task_run_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=TaskRunCreateInput( + task_id=task_id, + max_ticks=1, + checkpoint={"cursor": 0, "target_steps": 3, "wait_for_signal": False}, + ), + ) + task_run_id = UUID(created["task_run"]["id"]) + + first_tick = tick_task_run_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=TaskRunTickInput(task_run_id=task_run_id), + ) + exhausted_tick = tick_task_run_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=TaskRunTickInput(task_run_id=task_run_id), + ) + + assert first_tick["task_run"]["status"] == "running" + assert exhausted_tick["task_run"]["status"] == "failed" + assert exhausted_tick["task_run"]["stop_reason"] == "budget_exhausted" + assert exhausted_tick["task_run"]["failure_class"] == "budget" + assert exhausted_tick["task_run"]["retry_posture"] == "terminal" + assert exhausted_tick["task_run"]["tick_count"] == 1 + + +def test_wait_resume_pause_cancel_transitions_are_deterministic() -> None: + store = TaskRunStoreStub() + task_id = store.seed_task() + created = create_task_run_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=TaskRunCreateInput( + task_id=task_id, + max_ticks=3, + checkpoint={"cursor": 0, "target_steps": 2, "wait_for_signal": True}, + ), + ) + task_run_id = UUID(created["task_run"]["id"]) + + waiting = tick_task_run_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=TaskRunTickInput(task_run_id=task_run_id), + ) + resumed = resume_task_run_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=TaskRunResumeInput(task_run_id=task_run_id), + ) + paused = pause_task_run_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=TaskRunPauseInput(task_run_id=task_run_id), + ) + cancelled = cancel_task_run_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=TaskRunCancelInput(task_run_id=task_run_id), + ) + + assert waiting["task_run"]["status"] == "waiting_user" + assert waiting["task_run"]["stop_reason"] == "waiting_user" + assert waiting["task_run"]["retry_posture"] == "awaiting_user" + assert waiting["task_run"]["checkpoint"]["wait_for_signal"] is True + assert resumed["task_run"]["status"] == "running" + assert resumed["task_run"]["checkpoint"]["wait_for_signal"] is False + assert resumed["task_run"]["retry_posture"] == "none" + assert paused["task_run"]["status"] == "paused" + assert paused["task_run"]["stop_reason"] == "paused" + assert paused["task_run"]["retry_posture"] == "paused" + assert cancelled["task_run"]["status"] == "cancelled" + assert cancelled["task_run"]["stop_reason"] == "cancelled" + assert cancelled["task_run"]["retry_posture"] == "terminal" + + with pytest.raises( + TaskRunTransitionError, + match="cannot be resumed", + ): + resume_task_run_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=TaskRunResumeInput(task_run_id=task_run_id), + ) + + +def test_create_task_run_rejects_invalid_checkpoint_and_missing_task() -> None: + store = TaskRunStoreStub() + + with pytest.raises(TaskNotFoundError, match="was not found"): + create_task_run_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=TaskRunCreateInput( + task_id=uuid4(), + max_ticks=1, + checkpoint={"cursor": 0, "target_steps": 1, "wait_for_signal": False}, + ), + ) + + task_id = store.seed_task() + with pytest.raises(TaskRunValidationError, match="checkpoint.cursor must be an integer"): + create_task_run_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=TaskRunCreateInput( + task_id=task_id, + max_ticks=1, + checkpoint={"cursor": "zero", "target_steps": 1, "wait_for_signal": False}, + ), + ) + + +def test_get_task_run_raises_not_found_for_missing_record() -> None: + store = TaskRunStoreStub() + + with pytest.raises(TaskRunNotFoundError, match="task run"): + get_task_run_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + task_run_id=uuid4(), + ) + + +def test_task_run_transitions_are_recorded_in_checkpoint_history() -> None: + store = TaskRunStoreStub() + task_id = store.seed_task() + created = create_task_run_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=TaskRunCreateInput( + task_id=task_id, + max_ticks=2, + checkpoint={"cursor": 0, "target_steps": 2, "wait_for_signal": False}, + ), + ) + task_run_id = UUID(created["task_run"]["id"]) + + tick = tick_task_run_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=TaskRunTickInput(task_run_id=task_run_id), + ) + + transitions = tick["task_run"]["checkpoint"]["transitions"] + assert isinstance(transitions, list) + assert len(transitions) == 2 + assert transitions[0]["status"] == "queued" + assert transitions[1]["status"] == "running" diff --git a/tests/unit/test_task_runs_main.py b/tests/unit/test_task_runs_main.py new file mode 100644 index 0000000..339baca --- /dev/null +++ b/tests/unit/test_task_runs_main.py @@ -0,0 +1,228 @@ +from __future__ import annotations + +import json +from contextlib import contextmanager +from uuid import uuid4 + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.task_runs import ( + TaskRunNotFoundError, + TaskRunTransitionError, + TaskRunValidationError, +) +from alicebot_api.tasks import TaskNotFoundError + + +def test_create_task_run_endpoint_returns_payload(monkeypatch) -> None: + user_id = uuid4() + task_id = uuid4() + settings = Settings(database_url="postgresql://app") + captured: dict[str, object] = {} + + @contextmanager + def fake_user_connection(database_url: str, current_user_id): + captured["database_url"] = database_url + captured["current_user_id"] = current_user_id + yield object() + + def fake_create_task_run_record(store, *, user_id, request): + captured["store_type"] = type(store).__name__ + captured["user_id"] = user_id + captured["request"] = request + return { + "task_run": { + "id": "run-1", + "task_id": str(task_id), + "status": "queued", + "checkpoint": {"cursor": 0, "target_steps": 2, "wait_for_signal": False}, + "tick_count": 0, + "step_count": 0, + "max_ticks": 2, + "stop_reason": None, + "created_at": "2026-03-27T10:00:00+00:00", + "updated_at": "2026-03-27T10:00:00+00:00", + } + } + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "create_task_run_record", fake_create_task_run_record) + + response = main_module.create_task_run( + task_id, + main_module.CreateTaskRunRequest( + user_id=user_id, + max_ticks=2, + checkpoint={"cursor": 0, "target_steps": 2, "wait_for_signal": False}, + ), + ) + + assert response.status_code == 201 + assert json.loads(response.body)["task_run"]["id"] == "run-1" + assert captured["database_url"] == "postgresql://app" + assert captured["current_user_id"] == user_id + assert captured["store_type"] == "ContinuityStore" + assert captured["user_id"] == user_id + assert captured["request"].task_id == task_id + + +def test_create_task_run_endpoint_maps_not_found_and_validation_errors(monkeypatch) -> None: + user_id = uuid4() + task_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + + monkeypatch.setattr( + main_module, + "create_task_run_record", + lambda *_args, **_kwargs: (_ for _ in ()).throw(TaskNotFoundError(f"task {task_id} was not found")), + ) + not_found_response = main_module.create_task_run( + task_id, + main_module.CreateTaskRunRequest(user_id=user_id, max_ticks=1, checkpoint={}), + ) + assert not_found_response.status_code == 404 + assert json.loads(not_found_response.body) == {"detail": f"task {task_id} was not found"} + + monkeypatch.setattr( + main_module, + "create_task_run_record", + lambda *_args, **_kwargs: (_ for _ in ()).throw(TaskRunValidationError("checkpoint.cursor must be an integer")), + ) + validation_response = main_module.create_task_run( + task_id, + main_module.CreateTaskRunRequest(user_id=user_id, max_ticks=1, checkpoint={"cursor": "x"}), + ) + assert validation_response.status_code == 400 + assert json.loads(validation_response.body) == {"detail": "checkpoint.cursor must be an integer"} + + +def test_list_and_get_task_runs_endpoints_return_payload(monkeypatch) -> None: + user_id = uuid4() + task_id = uuid4() + task_run_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr( + main_module, + "list_task_run_records", + lambda *_args, **_kwargs: { + "items": [ + { + "id": str(task_run_id), + "task_id": str(task_id), + "status": "running", + "checkpoint": {"cursor": 1, "target_steps": 2, "wait_for_signal": False}, + "tick_count": 1, + "step_count": 1, + "max_ticks": 2, + "stop_reason": None, + "created_at": "2026-03-27T10:00:00+00:00", + "updated_at": "2026-03-27T10:01:00+00:00", + } + ], + "summary": { + "task_id": str(task_id), + "total_count": 1, + "order": ["created_at_asc", "id_asc"], + }, + }, + ) + monkeypatch.setattr( + main_module, + "get_task_run_record", + lambda *_args, **_kwargs: { + "task_run": { + "id": str(task_run_id), + "task_id": str(task_id), + "status": "running", + "checkpoint": {"cursor": 1, "target_steps": 2, "wait_for_signal": False}, + "tick_count": 1, + "step_count": 1, + "max_ticks": 2, + "stop_reason": None, + "created_at": "2026-03-27T10:00:00+00:00", + "updated_at": "2026-03-27T10:01:00+00:00", + } + }, + ) + + list_response = main_module.list_task_runs(task_id, user_id) + get_response = main_module.get_task_run(task_run_id, user_id) + + assert list_response.status_code == 200 + assert json.loads(list_response.body)["summary"]["task_id"] == str(task_id) + assert get_response.status_code == 200 + assert json.loads(get_response.body)["task_run"]["id"] == str(task_run_id) + + +def test_get_task_run_endpoint_maps_missing_record_to_404(monkeypatch) -> None: + user_id = uuid4() + task_run_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr( + main_module, + "get_task_run_record", + lambda *_args, **_kwargs: (_ for _ in ()).throw(TaskRunNotFoundError(f"task run {task_run_id} was not found")), + ) + + response = main_module.get_task_run(task_run_id, user_id) + + assert response.status_code == 404 + assert json.loads(response.body) == {"detail": f"task run {task_run_id} was not found"} + + +def test_task_run_tick_pause_resume_cancel_endpoints_map_conflicts(monkeypatch) -> None: + user_id = uuid4() + task_run_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + def conflict(*_args, **_kwargs): + raise TaskRunTransitionError(f"task run {task_run_id} is completed and cannot be resumed") + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "tick_task_run_record", conflict) + monkeypatch.setattr(main_module, "pause_task_run_record", conflict) + monkeypatch.setattr(main_module, "resume_task_run_record", conflict) + monkeypatch.setattr(main_module, "cancel_task_run_record", conflict) + + request = main_module.MutateTaskRunRequest(user_id=user_id) + tick_response = main_module.tick_task_run(task_run_id, request) + pause_response = main_module.pause_task_run(task_run_id, request) + resume_response = main_module.resume_task_run(task_run_id, request) + cancel_response = main_module.cancel_task_run(task_run_id, request) + + expected = {"detail": f"task run {task_run_id} is completed and cannot be resumed"} + assert tick_response.status_code == 409 + assert json.loads(tick_response.body) == expected + assert pause_response.status_code == 409 + assert json.loads(pause_response.body) == expected + assert resume_response.status_code == 409 + assert json.loads(resume_response.body) == expected + assert cancel_response.status_code == 409 + assert json.loads(cancel_response.body) == expected diff --git a/tests/unit/test_task_step_store.py b/tests/unit/test_task_step_store.py new file mode 100644 index 0000000..b1b45e2 --- /dev/null +++ b/tests/unit/test_task_step_store.py @@ -0,0 +1,321 @@ +from __future__ import annotations + +from typing import Any +from uuid import uuid4 + +from psycopg.types.json import Jsonb +import pytest + +from alicebot_api.store import ContinuityStore, ContinuityStoreInvariantError + + +class RecordingCursor: + def __init__(self, fetchone_results: list[dict[str, Any]], fetchall_result: list[dict[str, Any]] | None = None) -> None: + self.executed: list[tuple[str, tuple[object, ...] | None]] = [] + self.fetchone_results = list(fetchone_results) + self.fetchall_result = fetchall_result or [] + + def __enter__(self) -> "RecordingCursor": + return self + + def __exit__(self, exc_type, exc, tb) -> None: + return None + + def execute(self, query: str, params: tuple[object, ...] | None = None) -> None: + self.executed.append((query, params)) + + def fetchone(self) -> dict[str, Any] | None: + if not self.fetchone_results: + return None + return self.fetchone_results.pop(0) + + def fetchall(self) -> list[dict[str, Any]]: + return self.fetchall_result + + +class RecordingConnection: + def __init__(self, cursor: RecordingCursor) -> None: + self.cursor_instance = cursor + + def cursor(self) -> RecordingCursor: + return self.cursor_instance + + +def test_task_step_store_methods_use_expected_queries_and_jsonb_parameters() -> None: + task_step_id = uuid4() + task_id = uuid4() + thread_id = uuid4() + tool_id = uuid4() + trace_id = uuid4() + cursor = RecordingCursor( + fetchone_results=[ + { + "id": task_step_id, + "user_id": uuid4(), + "task_id": task_id, + "sequence_no": 1, + "parent_step_id": None, + "source_approval_id": None, + "source_execution_id": None, + "kind": "governed_request", + "status": "created", + "request": {"thread_id": str(uuid4()), "tool_id": str(uuid4())}, + "outcome": { + "routing_decision": "approval_required", + "approval_id": None, + "approval_status": None, + "execution_id": None, + "execution_status": None, + "blocked_reason": None, + }, + "trace_id": trace_id, + "trace_kind": "approval.request", + }, + { + "id": task_step_id, + "user_id": uuid4(), + "task_id": task_id, + "sequence_no": 1, + "parent_step_id": None, + "source_approval_id": None, + "source_execution_id": None, + "kind": "governed_request", + "status": "created", + "request": {"thread_id": str(uuid4()), "tool_id": str(uuid4())}, + "outcome": { + "routing_decision": "approval_required", + "approval_id": None, + "approval_status": None, + "execution_id": None, + "execution_status": None, + "blocked_reason": None, + }, + "trace_id": trace_id, + "trace_kind": "approval.request", + }, + { + "id": task_step_id, + "user_id": uuid4(), + "task_id": task_id, + "sequence_no": 1, + "parent_step_id": None, + "source_approval_id": None, + "source_execution_id": None, + "kind": "governed_request", + "status": "approved", + "request": {"thread_id": str(uuid4()), "tool_id": str(uuid4())}, + "outcome": { + "routing_decision": "approval_required", + "approval_id": str(uuid4()), + "approval_status": "approved", + "execution_id": None, + "execution_status": None, + "blocked_reason": None, + }, + "trace_id": trace_id, + "trace_kind": "approval.resolve", + }, + { + "id": task_step_id, + "user_id": uuid4(), + "task_id": task_id, + "sequence_no": 1, + "parent_step_id": None, + "source_approval_id": None, + "source_execution_id": None, + "kind": "governed_request", + "status": "approved", + "request": {"thread_id": str(uuid4()), "tool_id": str(uuid4())}, + "outcome": { + "routing_decision": "approval_required", + "approval_id": str(uuid4()), + "approval_status": "approved", + "execution_id": None, + "execution_status": None, + "blocked_reason": None, + }, + "trace_id": trace_id, + "trace_kind": "approval.resolve", + }, + { + "id": task_id, + "user_id": uuid4(), + "thread_id": thread_id, + "tool_id": tool_id, + "status": "approved", + "request": {"thread_id": str(uuid4()), "tool_id": str(uuid4())}, + "tool": {"id": str(tool_id), "tool_key": "proxy.echo"}, + "latest_approval_id": None, + "latest_execution_id": None, + "created_at": "2026-03-13T10:00:00+00:00", + "updated_at": "2026-03-13T10:05:00+00:00", + }, + ], + fetchall_result=[ + { + "id": task_step_id, + "user_id": uuid4(), + "task_id": task_id, + "sequence_no": 1, + "parent_step_id": None, + "source_approval_id": None, + "source_execution_id": None, + "kind": "governed_request", + "status": "created", + "request": {"thread_id": str(uuid4()), "tool_id": str(uuid4())}, + "outcome": { + "routing_decision": "approval_required", + "approval_id": None, + "approval_status": None, + "execution_id": None, + "execution_status": None, + "blocked_reason": None, + }, + "trace_id": trace_id, + "trace_kind": "approval.request", + } + ], + ) + store = ContinuityStore(RecordingConnection(cursor)) + + created = store.create_task_step( + task_id=task_id, + sequence_no=1, + kind="governed_request", + status="created", + request={"thread_id": "thread-123", "tool_id": "tool-123"}, + outcome={ + "routing_decision": "approval_required", + "approval_id": None, + "approval_status": None, + "execution_id": None, + "execution_status": None, + "blocked_reason": None, + }, + trace_id=trace_id, + trace_kind="approval.request", + ) + fetched = store.get_task_step_optional(task_step_id) + listed = store.list_task_steps_for_task(task_id) + updated = store.update_task_step_for_task_sequence_optional( + task_id=task_id, + sequence_no=1, + status="approved", + outcome={ + "routing_decision": "approval_required", + "approval_id": "approval-123", + "approval_status": "approved", + "execution_id": None, + "execution_status": None, + "blocked_reason": None, + }, + trace_id=trace_id, + trace_kind="approval.resolve", + ) + updated_by_id = store.update_task_step_optional( + task_step_id=task_step_id, + status="approved", + outcome={ + "routing_decision": "approval_required", + "approval_id": "approval-123", + "approval_status": "approved", + "execution_id": None, + "execution_status": None, + "blocked_reason": None, + }, + trace_id=trace_id, + trace_kind="approval.resolve", + ) + updated_task = store.update_task_status_optional( + task_id=task_id, + status="approved", + latest_approval_id=None, + latest_execution_id=None, + ) + + assert created["id"] == task_step_id + assert fetched is not None + assert listed[0]["id"] == task_step_id + assert updated is not None + assert updated["status"] == "approved" + assert updated_by_id is not None + assert updated_by_id["status"] == "approved" + assert updated_task is not None + assert updated_task["status"] == "approved" + + lock_query, lock_params = cursor.executed[0] + assert "pg_advisory_xact_lock" in lock_query + assert lock_params == (str(task_id),) + + create_query, create_params = cursor.executed[1] + assert "INSERT INTO task_steps" in create_query + assert create_params is not None + assert create_params[:7] == (task_id, 1, None, None, None, "governed_request", "created") + assert isinstance(create_params[7], Jsonb) + assert create_params[7].obj == {"thread_id": "thread-123", "tool_id": "tool-123"} + assert isinstance(create_params[8], Jsonb) + assert create_params[8].obj == { + "routing_decision": "approval_required", + "approval_id": None, + "approval_status": None, + "execution_id": None, + "execution_status": None, + "blocked_reason": None, + } + assert create_params[9] == trace_id + assert create_params[10] == "approval.request" + assert "FROM task_steps" in cursor.executed[2][0] + assert "ORDER BY sequence_no ASC, created_at ASC, id ASC" in cursor.executed[3][0] + + update_query, update_params = cursor.executed[4] + assert "UPDATE task_steps" in update_query + assert "WHERE task_id = %s" in update_query + assert update_params is not None + assert update_params[0] == "approved" + assert isinstance(update_params[1], Jsonb) + assert update_params[1].obj["approval_status"] == "approved" + assert update_params[2] == trace_id + assert update_params[3] == "approval.resolve" + assert update_params[4:] == (task_id, 1) + + update_by_id_query, update_by_id_params = cursor.executed[5] + assert "UPDATE task_steps" in update_by_id_query + assert "WHERE id = %s" in update_by_id_query + assert update_by_id_params is not None + assert update_by_id_params[0] == "approved" + assert isinstance(update_by_id_params[1], Jsonb) + assert update_by_id_params[1].obj["approval_status"] == "approved" + assert update_by_id_params[2] == trace_id + assert update_by_id_params[3] == "approval.resolve" + assert update_by_id_params[4] == task_step_id + + task_update_query, task_update_params = cursor.executed[6] + assert "UPDATE tasks" in task_update_query + assert task_update_params == ("approved", None, None, task_id) + + +def test_create_task_step_raises_clear_error_when_returning_row_is_missing() -> None: + task_id = uuid4() + store = ContinuityStore(RecordingConnection(RecordingCursor(fetchone_results=[]))) + + with pytest.raises( + ContinuityStoreInvariantError, + match="create_task_step did not return a row", + ): + store.create_task_step( + task_id=task_id, + sequence_no=1, + kind="governed_request", + status="created", + request={"thread_id": "thread-123", "tool_id": "tool-123"}, + outcome={ + "routing_decision": "approval_required", + "approval_id": None, + "approval_status": None, + "execution_id": None, + "execution_status": None, + "blocked_reason": None, + }, + trace_id=uuid4(), + trace_kind="approval.request", + ) diff --git a/tests/unit/test_task_workspace_store.py b/tests/unit/test_task_workspace_store.py new file mode 100644 index 0000000..16bd0ae --- /dev/null +++ b/tests/unit/test_task_workspace_store.py @@ -0,0 +1,184 @@ +from __future__ import annotations + +from typing import Any +from uuid import uuid4 + +from alicebot_api.store import ContinuityStore + + +class RecordingCursor: + def __init__(self, fetchone_results: list[dict[str, Any]], fetchall_result: list[dict[str, Any]] | None = None) -> None: + self.executed: list[tuple[str, tuple[object, ...] | None]] = [] + self.fetchone_results = list(fetchone_results) + self.fetchall_result = fetchall_result or [] + + def __enter__(self) -> "RecordingCursor": + return self + + def __exit__(self, exc_type, exc, tb) -> None: + return None + + def execute(self, query: str, params: tuple[object, ...] | None = None) -> None: + self.executed.append((query, params)) + + def fetchone(self) -> dict[str, Any] | None: + if not self.fetchone_results: + return None + return self.fetchone_results.pop(0) + + def fetchall(self) -> list[dict[str, Any]]: + return self.fetchall_result + + +class RecordingConnection: + def __init__(self, cursor: RecordingCursor) -> None: + self.cursor_instance = cursor + + def cursor(self) -> RecordingCursor: + return self.cursor_instance + + +def test_task_workspace_store_methods_use_expected_queries() -> None: + task_workspace_id = uuid4() + task_id = uuid4() + cursor = RecordingCursor( + fetchone_results=[ + { + "id": task_workspace_id, + "user_id": uuid4(), + "task_id": task_id, + "status": "active", + "local_path": "/tmp/alicebot/task-workspaces/user/task", + "created_at": "2026-03-13T10:00:00+00:00", + "updated_at": "2026-03-13T10:00:00+00:00", + }, + { + "id": task_workspace_id, + "user_id": uuid4(), + "task_id": task_id, + "status": "active", + "local_path": "/tmp/alicebot/task-workspaces/user/task", + "created_at": "2026-03-13T10:00:00+00:00", + "updated_at": "2026-03-13T10:00:00+00:00", + }, + { + "id": task_workspace_id, + "user_id": uuid4(), + "task_id": task_id, + "status": "active", + "local_path": "/tmp/alicebot/task-workspaces/user/task", + "created_at": "2026-03-13T10:00:00+00:00", + "updated_at": "2026-03-13T10:00:00+00:00", + }, + ], + fetchall_result=[ + { + "id": task_workspace_id, + "user_id": uuid4(), + "task_id": task_id, + "status": "active", + "local_path": "/tmp/alicebot/task-workspaces/user/task", + "created_at": "2026-03-13T10:00:00+00:00", + "updated_at": "2026-03-13T10:00:00+00:00", + } + ], + ) + store = ContinuityStore(RecordingConnection(cursor)) + + created = store.create_task_workspace( + task_id=task_id, + status="active", + local_path="/tmp/alicebot/task-workspaces/user/task", + ) + fetched = store.get_task_workspace_optional(task_workspace_id) + active = store.get_active_task_workspace_for_task_optional(task_id) + listed = store.list_task_workspaces() + store.lock_task_workspaces(task_id) + + assert created["id"] == task_workspace_id + assert fetched is not None + assert active is not None + assert listed[0]["id"] == task_workspace_id + assert cursor.executed == [ + ( + """ + INSERT INTO task_workspaces ( + user_id, + task_id, + status, + local_path, + created_at, + updated_at + ) + VALUES ( + app.current_user_id(), + %s, + %s, + %s, + clock_timestamp(), + clock_timestamp() + ) + RETURNING + id, + user_id, + task_id, + status, + local_path, + created_at, + updated_at + """, + (task_id, "active", "/tmp/alicebot/task-workspaces/user/task"), + ), + ( + """ + SELECT + id, + user_id, + task_id, + status, + local_path, + created_at, + updated_at + FROM task_workspaces + WHERE id = %s + """, + (task_workspace_id,), + ), + ( + """ + SELECT + id, + user_id, + task_id, + status, + local_path, + created_at, + updated_at + FROM task_workspaces + WHERE task_id = %s + AND status = 'active' + ORDER BY created_at ASC, id ASC + LIMIT 1 + """, + (task_id,), + ), + ( + """ + SELECT + id, + user_id, + task_id, + status, + local_path, + created_at, + updated_at + FROM task_workspaces + ORDER BY created_at ASC, id ASC + """, + None, + ), + ( + "SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 3))", + (str(task_id),), + ), + ] diff --git a/tests/unit/test_tasks.py b/tests/unit/test_tasks.py new file mode 100644 index 0000000..142f048 --- /dev/null +++ b/tests/unit/test_tasks.py @@ -0,0 +1,1663 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from uuid import UUID, uuid4 + +from alicebot_api.tasks import ( + TaskNotFoundError, + TaskStepApprovalLinkageError, + TaskStepExecutionLinkageError, + TaskStepNotFoundError, + TaskStepSequenceError, + TaskStepTransitionError, + allowed_task_step_transitions, + create_next_task_step_record, + create_task_step_for_governed_request, + get_task_step_record, + get_task_record, + list_task_records, + list_task_step_records, + sync_task_with_task_step_status, + sync_task_step_with_approval, + sync_task_step_with_execution, + task_status_for_step_status, + next_task_status_for_approval, + task_lifecycle_trace_events, + task_step_lifecycle_trace_events, + task_step_outcome_snapshot, + task_step_status_for_approval_status, + task_step_status_for_execution_status, + task_step_status_for_routing_decision, + task_status_for_approval_status, + task_status_for_execution_status, + task_status_for_routing_decision, + transition_task_step_record, +) +from alicebot_api.contracts import ( + TaskStepCreateInput, + TaskStepLineageInput, + TaskStepNextCreateInput, + TaskStepTransitionInput, +) + + +class TaskStoreStub: + def __init__(self) -> None: + self.base_time = datetime(2026, 3, 13, 10, 0, tzinfo=UTC) + self.user_id = uuid4() + self.tasks: list[dict[str, object]] = [] + self.task_steps: list[dict[str, object]] = [] + self.approvals: list[dict[str, object]] = [] + self.tool_executions: list[dict[str, object]] = [] + self.traces: list[dict[str, object]] = [] + self.trace_events: list[dict[str, object]] = [] + self.locked_task_ids: list[UUID] = [] + + def create_task( + self, + *, + status: str, + latest_approval_id: UUID | None, + latest_execution_id: UUID | None, + ) -> dict[str, object]: + task = { + "id": uuid4(), + "user_id": self.user_id, + "thread_id": uuid4(), + "tool_id": uuid4(), + "status": status, + "request": { + "thread_id": str(uuid4()), + "tool_id": str(uuid4()), + "action": "tool.run", + "scope": "workspace", + "domain_hint": None, + "risk_hint": None, + "attributes": {}, + }, + "tool": { + "id": str(uuid4()), + "tool_key": "proxy.echo", + "name": "Proxy Echo", + "description": "Deterministic proxy handler.", + "version": "1.0.0", + "metadata_version": "tool_metadata_v0", + "active": True, + "tags": ["proxy"], + "action_hints": ["tool.run"], + "scope_hints": ["workspace"], + "domain_hints": [], + "risk_hints": [], + "metadata": {"transport": "proxy"}, + "created_at": self.base_time.isoformat(), + }, + "latest_approval_id": latest_approval_id, + "latest_execution_id": latest_execution_id, + "created_at": self.base_time + timedelta(minutes=len(self.tasks)), + "updated_at": self.base_time + timedelta(minutes=len(self.tasks)), + } + self.tasks.append(task) + return task + + def list_tasks(self) -> list[dict[str, object]]: + return sorted(self.tasks, key=lambda task: (task["created_at"], task["id"])) + + def get_task_optional(self, task_id: UUID) -> dict[str, object] | None: + return next((task for task in self.tasks if task["id"] == task_id), None) + + def get_task_by_approval_optional(self, approval_id: UUID) -> dict[str, object] | None: + return next((task for task in self.tasks if task["latest_approval_id"] == approval_id), None) + + def update_task_status_optional( + self, + *, + task_id: UUID, + status: str, + latest_approval_id: UUID | None, + latest_execution_id: UUID | None, + ) -> dict[str, object] | None: + task = self.get_task_optional(task_id) + if task is None: + return None + task["status"] = status + task["latest_approval_id"] = latest_approval_id + task["latest_execution_id"] = latest_execution_id + task["updated_at"] = self.base_time + timedelta(hours=1, minutes=len(self.trace_events)) + return task + + def lock_task_steps(self, task_id: UUID) -> None: + self.locked_task_ids.append(task_id) + + def create_task_step( + self, + *, + task_id: UUID, + sequence_no: int, + parent_step_id: UUID | None = None, + source_approval_id: UUID | None = None, + source_execution_id: UUID | None = None, + kind: str, + status: str, + request: dict[str, object], + outcome: dict[str, object], + trace_id: UUID, + trace_kind: str, + ) -> dict[str, object]: + task_step = { + "id": uuid4(), + "user_id": self.user_id, + "task_id": task_id, + "sequence_no": sequence_no, + "parent_step_id": parent_step_id, + "source_approval_id": source_approval_id, + "source_execution_id": source_execution_id, + "kind": kind, + "status": status, + "request": request, + "outcome": outcome, + "trace_id": trace_id, + "trace_kind": trace_kind, + "created_at": self.base_time + timedelta(minutes=len(self.task_steps)), + "updated_at": self.base_time + timedelta(minutes=len(self.task_steps)), + } + self.task_steps.append(task_step) + return task_step + + def get_task_step_optional(self, task_step_id: UUID) -> dict[str, object] | None: + return next((task_step for task_step in self.task_steps if task_step["id"] == task_step_id), None) + + def get_approval_optional(self, approval_id: UUID) -> dict[str, object] | None: + return next((approval for approval in self.approvals if approval["id"] == approval_id), None) + + def get_tool_execution_optional(self, execution_id: UUID) -> dict[str, object] | None: + return next((execution for execution in self.tool_executions if execution["id"] == execution_id), None) + + def get_task_step_for_task_sequence_optional( + self, + *, + task_id: UUID, + sequence_no: int, + ) -> dict[str, object] | None: + return next( + ( + task_step + for task_step in self.task_steps + if task_step["task_id"] == task_id and task_step["sequence_no"] == sequence_no + ), + None, + ) + + def list_task_steps_for_task(self, task_id: UUID) -> list[dict[str, object]]: + return sorted( + [task_step for task_step in self.task_steps if task_step["task_id"] == task_id], + key=lambda task_step: (task_step["sequence_no"], task_step["created_at"], task_step["id"]), + ) + + def update_task_step_for_task_sequence_optional( + self, + *, + task_id: UUID, + sequence_no: int, + status: str, + outcome: dict[str, object], + trace_id: UUID, + trace_kind: str, + ) -> dict[str, object] | None: + task_step = self.get_task_step_for_task_sequence_optional(task_id=task_id, sequence_no=sequence_no) + if task_step is None: + return None + task_step["status"] = status + task_step["outcome"] = outcome + task_step["trace_id"] = trace_id + task_step["trace_kind"] = trace_kind + task_step["updated_at"] = self.base_time + timedelta(hours=1, minutes=len(self.task_steps)) + return task_step + + def update_task_step_optional( + self, + *, + task_step_id: UUID, + status: str, + outcome: dict[str, object], + trace_id: UUID, + trace_kind: str, + ) -> dict[str, object] | None: + task_step = self.get_task_step_optional(task_step_id) + if task_step is None: + return None + task_step["status"] = status + task_step["outcome"] = outcome + task_step["trace_id"] = trace_id + task_step["trace_kind"] = trace_kind + task_step["updated_at"] = self.base_time + timedelta(hours=1, minutes=len(self.task_steps)) + return task_step + + def create_trace( + self, + *, + user_id: UUID, + thread_id: UUID, + kind: str, + compiler_version: str, + status: str, + limits: dict[str, object], + ) -> dict[str, object]: + trace = { + "id": uuid4(), + "user_id": user_id, + "thread_id": thread_id, + "kind": kind, + "compiler_version": compiler_version, + "status": status, + "limits": limits, + "created_at": self.base_time + timedelta(minutes=len(self.traces)), + } + self.traces.append(trace) + return trace + + def append_trace_event( + self, + *, + trace_id: UUID, + sequence_no: int, + kind: str, + payload: dict[str, object], + ) -> dict[str, object]: + event = { + "id": uuid4(), + "trace_id": trace_id, + "sequence_no": sequence_no, + "kind": kind, + "payload": payload, + "created_at": self.base_time + timedelta(minutes=len(self.trace_events)), + } + self.trace_events.append(event) + return event + + +def test_list_and_get_task_records_are_deterministic() -> None: + store = TaskStoreStub() + first = store.create_task( + status="approved", + latest_approval_id=None, + latest_execution_id=None, + ) + second = store.create_task( + status="blocked", + latest_approval_id=uuid4(), + latest_execution_id=uuid4(), + ) + + listed = list_task_records( + store, # type: ignore[arg-type] + user_id=store.user_id, + ) + detail = get_task_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + task_id=second["id"], + ) + + assert [item["id"] for item in listed["items"]] == [str(first["id"]), str(second["id"])] + assert [item["status"] for item in listed["items"]] == ["approved", "blocked"] + assert listed["summary"] == { + "total_count": 2, + "order": ["created_at_asc", "id_asc"], + } + assert detail["task"]["id"] == str(second["id"]) + assert detail["task"]["status"] == "blocked" + assert detail["task"]["latest_approval_id"] == str(second["latest_approval_id"]) + assert detail["task"]["latest_execution_id"] == str(second["latest_execution_id"]) + + +def test_task_lifecycle_helpers_return_deterministic_statuses_and_trace_payloads() -> None: + assert task_status_for_routing_decision("approval_required") == "pending_approval" + assert task_status_for_routing_decision("ready") == "approved" + assert task_status_for_routing_decision("denied") == "denied" + assert task_status_for_approval_status("approved") == "approved" + assert task_status_for_approval_status("rejected") == "denied" + assert next_task_status_for_approval(current_status="pending_approval", approval_status="approved") == "approved" + assert next_task_status_for_approval(current_status="executed", approval_status="approved") == "executed" + assert task_status_for_execution_status("completed") == "executed" + assert task_status_for_execution_status("blocked") == "blocked" + assert task_step_status_for_routing_decision("approval_required") == "created" + assert task_step_status_for_routing_decision("ready") == "approved" + assert task_step_status_for_routing_decision("denied") == "denied" + assert task_step_status_for_approval_status("approved") == "approved" + assert task_step_status_for_approval_status("rejected") == "denied" + assert task_step_status_for_execution_status("completed") == "executed" + assert task_step_status_for_execution_status("blocked") == "blocked" + assert task_status_for_step_status("created") == "pending_approval" + assert task_status_for_step_status("approved") == "approved" + assert task_status_for_step_status("executed") == "executed" + assert allowed_task_step_transitions("created") == ["approved", "denied"] + assert allowed_task_step_transitions("approved") == ["executed", "blocked"] + assert allowed_task_step_transitions("executed") == [] + + task = { + "id": str(uuid4()), + "thread_id": str(uuid4()), + "tool_id": str(uuid4()), + "status": "executed", + "request": { + "thread_id": str(uuid4()), + "tool_id": str(uuid4()), + "action": "tool.run", + "scope": "workspace", + "domain_hint": None, + "risk_hint": None, + "attributes": {}, + }, + "tool": { + "id": str(uuid4()), + "tool_key": "proxy.echo", + "name": "Proxy Echo", + "description": "Deterministic proxy handler.", + "version": "1.0.0", + "metadata_version": "tool_metadata_v0", + "active": True, + "tags": ["proxy"], + "action_hints": ["tool.run"], + "scope_hints": ["workspace"], + "domain_hints": [], + "risk_hints": [], + "metadata": {"transport": "proxy"}, + "created_at": "2026-03-13T10:00:00+00:00", + }, + "latest_approval_id": str(uuid4()), + "latest_execution_id": str(uuid4()), + "created_at": "2026-03-13T10:00:00+00:00", + "updated_at": "2026-03-13T10:05:00+00:00", + } + + events = task_lifecycle_trace_events( + task=task, + previous_status="approved", + source="proxy_execution", + ) + + assert events == [ + ( + "task.lifecycle.state", + { + "task_id": task["id"], + "source": "proxy_execution", + "previous_status": "approved", + "current_status": "executed", + "latest_approval_id": task["latest_approval_id"], + "latest_execution_id": task["latest_execution_id"], + }, + ), + ( + "task.lifecycle.summary", + { + "task_id": task["id"], + "source": "proxy_execution", + "final_status": "executed", + "latest_approval_id": task["latest_approval_id"], + "latest_execution_id": task["latest_execution_id"], + }, + ), + ] + + task_step = { + "id": str(uuid4()), + "task_id": task["id"], + "sequence_no": 1, + "lineage": { + "parent_step_id": None, + "source_approval_id": None, + "source_execution_id": None, + }, + "kind": "governed_request", + "status": "executed", + "request": task["request"], + "outcome": task_step_outcome_snapshot( + routing_decision="approval_required", + approval_id=task["latest_approval_id"], + approval_status="approved", + execution_id=task["latest_execution_id"], + execution_status="completed", + blocked_reason=None, + ), + "trace": { + "trace_id": str(uuid4()), + "trace_kind": "tool.proxy.execute", + }, + "created_at": "2026-03-13T10:00:00+00:00", + "updated_at": "2026-03-13T10:05:00+00:00", + } + + task_step_events = task_step_lifecycle_trace_events( + task_step=task_step, + previous_status="approved", + source="proxy_execution", + ) + + assert task_step_events == [ + ( + "task.step.lifecycle.state", + { + "task_id": task["id"], + "task_step_id": task_step["id"], + "source": "proxy_execution", + "sequence_no": 1, + "kind": "governed_request", + "previous_status": "approved", + "current_status": "executed", + "trace": task_step["trace"], + }, + ), + ( + "task.step.lifecycle.summary", + { + "task_id": task["id"], + "task_step_id": task_step["id"], + "source": "proxy_execution", + "sequence_no": 1, + "kind": "governed_request", + "final_status": "executed", + "trace": task_step["trace"], + }, + ), + ] + + +def test_get_task_record_raises_not_found_when_missing() -> None: + store = TaskStoreStub() + + try: + get_task_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + task_id=uuid4(), + ) + except TaskNotFoundError as exc: + assert "task" in str(exc) + else: + raise AssertionError("expected TaskNotFoundError") + + +def test_task_step_list_get_and_lifecycle_updates_are_deterministic() -> None: + store = TaskStoreStub() + task = store.create_task( + status="pending_approval", + latest_approval_id=uuid4(), + latest_execution_id=None, + ) + first_trace_id = uuid4() + create_payload = create_task_step_for_governed_request( + store, # type: ignore[arg-type] + request=TaskStepCreateInput( + task_id=task["id"], + sequence_no=1, + kind="governed_request", + status="created", + request=task["request"], + outcome=task_step_outcome_snapshot( + routing_decision="approval_required", + approval_id=str(task["latest_approval_id"]), + approval_status="pending", + execution_id=None, + execution_status=None, + blocked_reason=None, + ), + trace_id=first_trace_id, + trace_kind="approval.request", + ), + ) + second_trace_id = uuid4() + approval_transition = sync_task_step_with_approval( + store, # type: ignore[arg-type] + approval_id=UUID(str(task["latest_approval_id"])), + task_step_id=UUID(create_payload["task_step"]["id"]), + approval_status="approved", + trace_id=second_trace_id, + trace_kind="approval.resolve", + ) + execution = { + "id": uuid4(), + "approval_id": task["latest_approval_id"], + "task_step_id": UUID(create_payload["task_step"]["id"]), + "status": "completed", + "result": { + "handler_key": "proxy.echo", + "status": "completed", + "output": {"mode": "no_side_effect"}, + "reason": None, + }, + } + third_trace_id = uuid4() + execution_transition = sync_task_step_with_execution( + store, # type: ignore[arg-type] + task_id=task["id"], + execution=execution, # type: ignore[arg-type] + trace_id=third_trace_id, + trace_kind="tool.proxy.execute", + ) + store.create_task_step( + task_id=task["id"], + sequence_no=2, + kind="governed_request", + status="denied", + request=task["request"], + outcome=task_step_outcome_snapshot( + routing_decision="denied", + approval_id=None, + approval_status=None, + execution_id=None, + execution_status=None, + blocked_reason=None, + ), + trace_id=uuid4(), + trace_kind="approval.request", + ) + + listed = list_task_step_records( + store, # type: ignore[arg-type] + user_id=store.user_id, + task_id=task["id"], + ) + detail = get_task_step_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + task_step_id=UUID(create_payload["task_step"]["id"]), + ) + + assert [item["sequence_no"] for item in listed["items"]] == [1, 2] + assert listed["summary"] == { + "task_id": str(task["id"]), + "total_count": 2, + "latest_sequence_no": 2, + "latest_status": "denied", + "next_sequence_no": 3, + "append_allowed": True, + "order": ["sequence_no_asc", "created_at_asc", "id_asc"], + } + assert detail["task_step"]["id"] == create_payload["task_step"]["id"] + assert detail["task_step"]["status"] == "executed" + assert detail["task_step"]["trace"] == { + "trace_id": str(third_trace_id), + "trace_kind": "tool.proxy.execute", + } + assert detail["task_step"]["outcome"] == { + "routing_decision": "approval_required", + "approval_id": str(task["latest_approval_id"]), + "approval_status": "approved", + "execution_id": str(execution["id"]), + "execution_status": "completed", + "blocked_reason": None, + } + + +def test_sync_task_step_with_approval_updates_explicitly_linked_later_step_only() -> None: + store = TaskStoreStub() + approval_id = uuid4() + initial_execution_id = uuid4() + task = store.create_task( + status="pending_approval", + latest_approval_id=approval_id, + latest_execution_id=None, + ) + first_step = store.create_task_step( + task_id=task["id"], + sequence_no=1, + kind="governed_request", + status="executed", + request=task["request"], + outcome=task_step_outcome_snapshot( + routing_decision="approval_required", + approval_id=str(approval_id), + approval_status="approved", + execution_id=str(initial_execution_id), + execution_status="completed", + blocked_reason=None, + ), + trace_id=uuid4(), + trace_kind="tool.proxy.execute", + ) + later_step = store.create_task_step( + task_id=task["id"], + sequence_no=2, + parent_step_id=first_step["id"], + source_approval_id=approval_id, + source_execution_id=initial_execution_id, + kind="governed_request", + status="created", + request=task["request"], + outcome=task_step_outcome_snapshot( + routing_decision="approval_required", + approval_id=str(approval_id), + approval_status="pending", + execution_id=None, + execution_status=None, + blocked_reason=None, + ), + trace_id=uuid4(), + trace_kind="task.step.continuation", + ) + + original_first_trace_id = first_step["trace_id"] + original_first_trace_kind = first_step["trace_kind"] + original_first_outcome = dict(first_step["outcome"]) + later_trace_id = uuid4() + + transition = sync_task_step_with_approval( + store, # type: ignore[arg-type] + approval_id=approval_id, + task_step_id=later_step["id"], + approval_status="approved", + trace_id=later_trace_id, + trace_kind="approval.resolve", + ) + + assert transition.previous_status == "created" + assert transition.task_step["id"] == str(later_step["id"]) + assert transition.task_step["status"] == "approved" + assert first_step["status"] == "executed" + assert first_step["trace_id"] == original_first_trace_id + assert first_step["trace_kind"] == original_first_trace_kind + assert first_step["outcome"] == original_first_outcome + assert later_step["status"] == "approved" + assert later_step["trace_id"] == later_trace_id + assert later_step["trace_kind"] == "approval.resolve" + assert later_step["outcome"] == { + "routing_decision": "approval_required", + "approval_id": str(approval_id), + "approval_status": "approved", + "execution_id": None, + "execution_status": None, + "blocked_reason": None, + } + assert task["status"] == "pending_approval" + assert task["latest_execution_id"] is None + + +def test_sync_task_step_with_approval_rejects_inconsistent_linkage_without_mutating_steps() -> None: + store = TaskStoreStub() + approval_id = uuid4() + initial_execution_id = uuid4() + task = store.create_task( + status="pending_approval", + latest_approval_id=approval_id, + latest_execution_id=None, + ) + first_step = store.create_task_step( + task_id=task["id"], + sequence_no=1, + kind="governed_request", + status="executed", + request=task["request"], + outcome=task_step_outcome_snapshot( + routing_decision="approval_required", + approval_id=str(approval_id), + approval_status="approved", + execution_id=str(initial_execution_id), + execution_status="completed", + blocked_reason=None, + ), + trace_id=uuid4(), + trace_kind="tool.proxy.execute", + ) + later_step = store.create_task_step( + task_id=task["id"], + sequence_no=2, + parent_step_id=first_step["id"], + source_approval_id=approval_id, + source_execution_id=initial_execution_id, + kind="governed_request", + status="created", + request=task["request"], + outcome=task_step_outcome_snapshot( + routing_decision="approval_required", + approval_id=None, + approval_status=None, + execution_id=None, + execution_status=None, + blocked_reason=None, + ), + trace_id=uuid4(), + trace_kind="task.step.continuation", + ) + + original_first_outcome = dict(first_step["outcome"]) + original_later_trace_id = later_step["trace_id"] + + try: + sync_task_step_with_approval( + store, # type: ignore[arg-type] + approval_id=approval_id, + task_step_id=later_step["id"], + approval_status="approved", + trace_id=uuid4(), + trace_kind="approval.resolve", + ) + except TaskStepApprovalLinkageError as exc: + assert str(exc) == ( + f"approval {approval_id} is inconsistent with linked task step {later_step['id']}" + ) + else: + raise AssertionError("expected TaskStepApprovalLinkageError") + + assert first_step["outcome"] == original_first_outcome + assert later_step["status"] == "created" + assert later_step["trace_id"] == original_later_trace_id + assert later_step["trace_kind"] == "task.step.continuation" + + +def test_sync_task_step_with_execution_updates_the_linked_later_step_without_mutating_initial_step() -> None: + store = TaskStoreStub() + approval_id = uuid4() + initial_execution_id = uuid4() + task = store.create_task( + status="approved", + latest_approval_id=approval_id, + latest_execution_id=None, + ) + first_step = store.create_task_step( + task_id=task["id"], + sequence_no=1, + kind="governed_request", + status="executed", + request=task["request"], + outcome=task_step_outcome_snapshot( + routing_decision="approval_required", + approval_id=str(approval_id), + approval_status="approved", + execution_id=str(initial_execution_id), + execution_status="completed", + blocked_reason=None, + ), + trace_id=uuid4(), + trace_kind="tool.proxy.execute", + ) + later_step = store.create_task_step( + task_id=task["id"], + sequence_no=2, + parent_step_id=first_step["id"], + source_approval_id=approval_id, + source_execution_id=initial_execution_id, + kind="governed_request", + status="approved", + request=task["request"], + outcome=task_step_outcome_snapshot( + routing_decision="approval_required", + approval_id=str(approval_id), + approval_status="approved", + execution_id=None, + execution_status=None, + blocked_reason=None, + ), + trace_id=uuid4(), + trace_kind="task.step.transition", + ) + + original_first_trace_id = first_step["trace_id"] + original_first_trace_kind = first_step["trace_kind"] + original_first_outcome = dict(first_step["outcome"]) + execution = { + "id": uuid4(), + "approval_id": approval_id, + "task_step_id": later_step["id"], + "status": "completed", + "result": { + "handler_key": "proxy.echo", + "status": "completed", + "output": {"mode": "no_side_effect"}, + "reason": None, + }, + } + + transition = sync_task_step_with_execution( + store, # type: ignore[arg-type] + task_id=task["id"], + execution=execution, # type: ignore[arg-type] + trace_id=uuid4(), + trace_kind="tool.proxy.execute", + ) + + assert transition.previous_status == "approved" + assert transition.task_step["id"] == str(later_step["id"]) + assert transition.task_step["status"] == "executed" + assert first_step["status"] == "executed" + assert first_step["trace_id"] == original_first_trace_id + assert first_step["trace_kind"] == original_first_trace_kind + assert first_step["outcome"] == original_first_outcome + assert later_step["status"] == "executed" + assert later_step["trace_kind"] == "tool.proxy.execute" + assert later_step["outcome"] == { + "routing_decision": "approval_required", + "approval_id": str(approval_id), + "approval_status": "approved", + "execution_id": str(execution["id"]), + "execution_status": "completed", + "blocked_reason": None, + } + assert task["status"] == "approved" + assert task["latest_execution_id"] is None + + +def test_sync_task_step_with_execution_rejects_missing_linkage_without_mutating_steps() -> None: + store = TaskStoreStub() + approval_id = uuid4() + task = store.create_task( + status="approved", + latest_approval_id=approval_id, + latest_execution_id=None, + ) + first_step = store.create_task_step( + task_id=task["id"], + sequence_no=1, + kind="governed_request", + status="approved", + request=task["request"], + outcome=task_step_outcome_snapshot( + routing_decision="approval_required", + approval_id=str(approval_id), + approval_status="approved", + execution_id=None, + execution_status=None, + blocked_reason=None, + ), + trace_id=uuid4(), + trace_kind="approval.resolve", + ) + execution_id = uuid4() + + try: + sync_task_step_with_execution( + store, # type: ignore[arg-type] + task_id=task["id"], + execution={ + "id": execution_id, + "approval_id": approval_id, + "task_step_id": None, + "status": "completed", + "result": { + "handler_key": "proxy.echo", + "status": "completed", + "output": {"mode": "no_side_effect"}, + "reason": None, + }, + }, # type: ignore[arg-type] + trace_id=uuid4(), + trace_kind="tool.proxy.execute", + ) + except TaskStepExecutionLinkageError as exc: + assert str(exc) == f"tool execution {execution_id} is missing linked task_step_id" + else: + raise AssertionError("expected TaskStepExecutionLinkageError") + + assert first_step["status"] == "approved" + assert first_step["outcome"]["execution_id"] is None + + +def test_sync_task_step_with_execution_rejects_unknown_or_out_of_task_linkage() -> None: + store = TaskStoreStub() + approval_id = uuid4() + task = store.create_task( + status="approved", + latest_approval_id=approval_id, + latest_execution_id=None, + ) + store.create_task_step( + task_id=task["id"], + sequence_no=1, + kind="governed_request", + status="approved", + request=task["request"], + outcome=task_step_outcome_snapshot( + routing_decision="approval_required", + approval_id=str(approval_id), + approval_status="approved", + execution_id=None, + execution_status=None, + blocked_reason=None, + ), + trace_id=uuid4(), + trace_kind="approval.resolve", + ) + other_task = store.create_task( + status="approved", + latest_approval_id=approval_id, + latest_execution_id=None, + ) + other_step = store.create_task_step( + task_id=other_task["id"], + sequence_no=1, + kind="governed_request", + status="approved", + request=other_task["request"], + outcome=task_step_outcome_snapshot( + routing_decision="approval_required", + approval_id=str(approval_id), + approval_status="approved", + execution_id=None, + execution_status=None, + blocked_reason=None, + ), + trace_id=uuid4(), + trace_kind="approval.resolve", + ) + + missing_execution_id = uuid4() + missing_task_step_id = uuid4() + try: + sync_task_step_with_execution( + store, # type: ignore[arg-type] + task_id=task["id"], + execution={ + "id": missing_execution_id, + "approval_id": approval_id, + "task_step_id": missing_task_step_id, + "status": "completed", + "result": { + "handler_key": "proxy.echo", + "status": "completed", + "output": {"mode": "no_side_effect"}, + "reason": None, + }, + }, # type: ignore[arg-type] + trace_id=uuid4(), + trace_kind="tool.proxy.execute", + ) + except TaskStepExecutionLinkageError as exc: + assert str(exc) == ( + f"tool execution {missing_execution_id} references linked task step " + f"{missing_task_step_id} that was not found" + ) + else: + raise AssertionError("expected TaskStepExecutionLinkageError") + + outside_execution_id = uuid4() + try: + sync_task_step_with_execution( + store, # type: ignore[arg-type] + task_id=task["id"], + execution={ + "id": outside_execution_id, + "approval_id": approval_id, + "task_step_id": other_step["id"], + "status": "completed", + "result": { + "handler_key": "proxy.echo", + "status": "completed", + "output": {"mode": "no_side_effect"}, + "reason": None, + }, + }, # type: ignore[arg-type] + trace_id=uuid4(), + trace_kind="tool.proxy.execute", + ) + except TaskStepExecutionLinkageError as exc: + assert str(exc) == ( + f"tool execution {outside_execution_id} links task step {other_step['id']} " + f"outside task {task['id']}" + ) + else: + raise AssertionError("expected TaskStepExecutionLinkageError") + + +def test_sync_task_step_with_execution_rejects_inconsistent_linkage_without_mutating_steps() -> None: + store = TaskStoreStub() + approval_id = uuid4() + task = store.create_task( + status="approved", + latest_approval_id=approval_id, + latest_execution_id=None, + ) + step = store.create_task_step( + task_id=task["id"], + sequence_no=1, + kind="governed_request", + status="approved", + request=task["request"], + outcome=task_step_outcome_snapshot( + routing_decision="approval_required", + approval_id=str(approval_id), + approval_status="approved", + execution_id=None, + execution_status=None, + blocked_reason=None, + ), + trace_id=uuid4(), + trace_kind="approval.resolve", + ) + inconsistent_execution_id = uuid4() + + try: + sync_task_step_with_execution( + store, # type: ignore[arg-type] + task_id=task["id"], + execution={ + "id": inconsistent_execution_id, + "approval_id": uuid4(), + "task_step_id": step["id"], + "status": "completed", + "result": { + "handler_key": "proxy.echo", + "status": "completed", + "output": {"mode": "no_side_effect"}, + "reason": None, + }, + }, # type: ignore[arg-type] + trace_id=uuid4(), + trace_kind="tool.proxy.execute", + ) + except TaskStepExecutionLinkageError as exc: + assert str(exc) == ( + f"tool execution {inconsistent_execution_id} is inconsistent with linked task step {step['id']}" + ) + else: + raise AssertionError("expected TaskStepExecutionLinkageError") + + assert step["status"] == "approved" + assert step["outcome"]["execution_id"] is None + + +def test_sync_task_with_task_step_status_updates_parent_through_task_seam() -> None: + store = TaskStoreStub() + task = store.create_task( + status="executed", + latest_approval_id=uuid4(), + latest_execution_id=uuid4(), + ) + + transition = sync_task_with_task_step_status( + store, # type: ignore[arg-type] + task_id=task["id"], + task_step_status="created", + linked_approval_id=task["latest_approval_id"], + linked_execution_id=None, + ) + + assert transition.previous_status == "executed" + assert transition.task["status"] == "pending_approval" + assert transition.task["latest_execution_id"] is None + assert store.tasks[0]["status"] == "pending_approval" + assert store.tasks[0]["latest_execution_id"] is None + + +def test_create_next_task_step_assigns_deterministic_sequence_updates_parent_and_records_trace() -> None: + store = TaskStoreStub() + approval_id = uuid4() + initial_execution_id = uuid4() + task = store.create_task( + status="executed", + latest_approval_id=approval_id, + latest_execution_id=initial_execution_id, + ) + store.approvals.append({"id": approval_id, "thread_id": task["thread_id"], "tool_id": task["tool_id"]}) + store.tool_executions.append( + { + "id": task["latest_execution_id"], + "thread_id": task["thread_id"], + "tool_id": task["tool_id"], + "approval_id": approval_id, + } + ) + store.create_task_step( + task_id=task["id"], + sequence_no=1, + kind="governed_request", + status="executed", + request=task["request"], + outcome=task_step_outcome_snapshot( + routing_decision="approval_required", + approval_id=str(approval_id), + approval_status="approved", + execution_id=str(task["latest_execution_id"]), + execution_status="completed", + blocked_reason=None, + ), + trace_id=uuid4(), + trace_kind="tool.proxy.execute", + ) + + payload = create_next_task_step_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=TaskStepNextCreateInput( + task_id=task["id"], + kind="governed_request", + status="created", + request=task["request"], + outcome=task_step_outcome_snapshot( + routing_decision="approval_required", + approval_id=None, + approval_status=None, + execution_id=None, + execution_status=None, + blocked_reason=None, + ), + lineage=TaskStepLineageInput( + parent_step_id=store.task_steps[0]["id"], + source_approval_id=approval_id, + source_execution_id=initial_execution_id, + ), + ), + ) + + assert payload["task"]["status"] == "pending_approval" + assert payload["task"]["latest_approval_id"] == str(approval_id) + assert payload["task"]["latest_execution_id"] is None + assert payload["task_step"]["sequence_no"] == 2 + assert payload["task_step"]["status"] == "created" + assert payload["task_step"]["lineage"] == { + "parent_step_id": str(store.task_steps[0]["id"]), + "source_approval_id": str(approval_id), + "source_execution_id": str(initial_execution_id), + } + assert payload["task_step"]["trace"]["trace_kind"] == "task.step.continuation" + assert payload["sequencing"] == { + "task_id": str(task["id"]), + "total_count": 2, + "latest_sequence_no": 2, + "latest_status": "created", + "next_sequence_no": 3, + "append_allowed": False, + "order": ["sequence_no_asc", "created_at_asc", "id_asc"], + } + assert payload["trace"]["trace_event_count"] == 7 + assert [event["kind"] for event in store.trace_events] == [ + "task.step.continuation.request", + "task.step.continuation.lineage", + "task.step.continuation.summary", + "task.lifecycle.state", + "task.lifecycle.summary", + "task.step.lifecycle.state", + "task.step.lifecycle.summary", + ] + assert store.trace_events[1]["payload"] == { + "task_id": str(task["id"]), + "parent_task_step_id": str(store.task_steps[0]["id"]), + "parent_sequence_no": 1, + "parent_status": "executed", + "source_approval_id": str(approval_id), + "source_execution_id": str(initial_execution_id), + } + + +def test_create_next_task_step_rejects_when_latest_step_is_not_terminal() -> None: + store = TaskStoreStub() + task = store.create_task( + status="pending_approval", + latest_approval_id=uuid4(), + latest_execution_id=None, + ) + store.create_task_step( + task_id=task["id"], + sequence_no=1, + kind="governed_request", + status="created", + request=task["request"], + outcome=task_step_outcome_snapshot( + routing_decision="approval_required", + approval_id=str(task["latest_approval_id"]), + approval_status="pending", + execution_id=None, + execution_status=None, + blocked_reason=None, + ), + trace_id=uuid4(), + trace_kind="approval.request", + ) + + try: + create_next_task_step_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=TaskStepNextCreateInput( + task_id=task["id"], + kind="governed_request", + status="created", + request=task["request"], + outcome=task_step_outcome_snapshot( + routing_decision="approval_required", + approval_id=None, + approval_status="pending", + execution_id=None, + execution_status=None, + blocked_reason=None, + ), + lineage=TaskStepLineageInput(parent_step_id=store.task_steps[0]["id"]), + ), + ) + except TaskStepSequenceError as exc: + assert str(exc) == ( + f"task {task['id']} latest step {store.task_steps[0]['id']} is created and cannot append a next step" + ) + else: + raise AssertionError("expected TaskStepSequenceError") + + +def test_transition_task_step_updates_latest_step_parent_and_trace() -> None: + store = TaskStoreStub() + first_approval_id = uuid4() + first_execution_id = uuid4() + task = store.create_task( + status="approved", + latest_approval_id=first_approval_id, + latest_execution_id=first_execution_id, + ) + store.approvals.extend( + [ + {"id": first_approval_id, "thread_id": task["thread_id"], "tool_id": task["tool_id"]}, + ] + ) + store.tool_executions.extend( + [ + { + "id": first_execution_id, + "thread_id": task["thread_id"], + "tool_id": task["tool_id"], + "approval_id": first_approval_id, + }, + ] + ) + first_step = store.create_task_step( + task_id=task["id"], + sequence_no=1, + kind="governed_request", + status="executed", + request=task["request"], + outcome=task_step_outcome_snapshot( + routing_decision="approval_required", + approval_id=str(first_approval_id), + approval_status="approved", + execution_id=str(first_execution_id), + execution_status="completed", + blocked_reason=None, + ), + trace_id=uuid4(), + trace_kind="tool.proxy.execute", + ) + second_step = store.create_task_step( + task_id=task["id"], + sequence_no=2, + kind="governed_request", + status="approved", + request=task["request"], + outcome=task_step_outcome_snapshot( + routing_decision="ready", + approval_id=None, + approval_status=None, + execution_id=None, + execution_status=None, + blocked_reason=None, + ), + trace_id=uuid4(), + trace_kind="task.step.sequence", + ) + + payload = transition_task_step_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=TaskStepTransitionInput( + task_step_id=second_step["id"], + status="executed", + outcome=task_step_outcome_snapshot( + routing_decision="ready", + approval_id=str(first_approval_id), + approval_status="approved", + execution_id=str(first_execution_id), + execution_status="completed", + blocked_reason=None, + ), + ), + ) + + assert first_step["status"] == "executed" + assert payload["task"]["status"] == "executed" + assert payload["task"]["latest_approval_id"] == str(first_approval_id) + assert payload["task"]["latest_execution_id"] == str(first_execution_id) + assert payload["task_step"]["id"] == str(second_step["id"]) + assert payload["task_step"]["status"] == "executed" + assert payload["task_step"]["trace"]["trace_kind"] == "task.step.transition" + assert payload["sequencing"] == { + "task_id": str(task["id"]), + "total_count": 2, + "latest_sequence_no": 2, + "latest_status": "executed", + "next_sequence_no": 3, + "append_allowed": True, + "order": ["sequence_no_asc", "created_at_asc", "id_asc"], + } + assert [event["kind"] for event in store.trace_events] == [ + "task.step.transition.request", + "task.step.transition.state", + "task.step.transition.summary", + "task.lifecycle.state", + "task.lifecycle.summary", + "task.step.lifecycle.state", + "task.step.lifecycle.summary", + ] + assert store.trace_events[1]["payload"]["allowed_next_statuses"] == ["executed", "blocked"] + + +def test_create_next_task_step_locks_before_listing_existing_steps() -> None: + class LockingTaskStoreStub(TaskStoreStub): + def list_task_steps_for_task(self, task_id: UUID) -> list[dict[str, object]]: + if task_id not in self.locked_task_ids: + raise AssertionError("task steps were listed before the advisory lock was taken") + return super().list_task_steps_for_task(task_id) + + store = LockingTaskStoreStub() + approval_id = uuid4() + initial_execution_id = uuid4() + task = store.create_task( + status="executed", + latest_approval_id=approval_id, + latest_execution_id=initial_execution_id, + ) + store.approvals.append({"id": approval_id, "thread_id": task["thread_id"], "tool_id": task["tool_id"]}) + store.tool_executions.append( + { + "id": task["latest_execution_id"], + "thread_id": task["thread_id"], + "tool_id": task["tool_id"], + "approval_id": approval_id, + } + ) + store.create_task_step( + task_id=task["id"], + sequence_no=1, + kind="governed_request", + status="executed", + request=task["request"], + outcome=task_step_outcome_snapshot( + routing_decision="approval_required", + approval_id=str(approval_id), + approval_status="approved", + execution_id=str(task["latest_execution_id"]), + execution_status="completed", + blocked_reason=None, + ), + trace_id=uuid4(), + trace_kind="tool.proxy.execute", + ) + + payload = create_next_task_step_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=TaskStepNextCreateInput( + task_id=task["id"], + kind="governed_request", + status="created", + request=task["request"], + outcome=task_step_outcome_snapshot( + routing_decision="approval_required", + approval_id=None, + approval_status=None, + execution_id=None, + execution_status=None, + blocked_reason=None, + ), + lineage=TaskStepLineageInput( + parent_step_id=store.task_steps[0]["id"], + source_approval_id=approval_id, + source_execution_id=initial_execution_id, + ), + ), + ) + + assert payload["task_step"]["sequence_no"] == 2 + + +def test_create_next_task_step_rejects_visible_approval_from_unrelated_task_lineage() -> None: + store = TaskStoreStub() + task = store.create_task( + status="executed", + latest_approval_id=uuid4(), + latest_execution_id=uuid4(), + ) + unrelated_approval_id = uuid4() + store.approvals.append( + { + "id": unrelated_approval_id, + "thread_id": uuid4(), + "tool_id": uuid4(), + } + ) + store.create_task_step( + task_id=task["id"], + sequence_no=1, + kind="governed_request", + status="executed", + request=task["request"], + outcome=task_step_outcome_snapshot( + routing_decision="approval_required", + approval_id=str(task["latest_approval_id"]), + approval_status="approved", + execution_id=str(task["latest_execution_id"]), + execution_status="completed", + blocked_reason=None, + ), + trace_id=uuid4(), + trace_kind="tool.proxy.execute", + ) + + try: + create_next_task_step_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=TaskStepNextCreateInput( + task_id=task["id"], + kind="governed_request", + status="created", + request=task["request"], + outcome=task_step_outcome_snapshot( + routing_decision="approval_required", + approval_id=None, + approval_status=None, + execution_id=None, + execution_status=None, + blocked_reason=None, + ), + lineage=TaskStepLineageInput( + parent_step_id=store.task_steps[0]["id"], + source_approval_id=unrelated_approval_id, + ), + ), + ) + except TaskStepSequenceError as exc: + assert str(exc) == f"approval {unrelated_approval_id} does not belong to task {task['id']}" + else: + raise AssertionError("expected TaskStepSequenceError") + + +def test_create_next_task_step_rejects_parent_step_from_unrelated_task() -> None: + store = TaskStoreStub() + task = store.create_task( + status="executed", + latest_approval_id=None, + latest_execution_id=None, + ) + unrelated_task = store.create_task( + status="executed", + latest_approval_id=None, + latest_execution_id=None, + ) + store.create_task_step( + task_id=task["id"], + sequence_no=1, + kind="governed_request", + status="executed", + request=task["request"], + outcome=task_step_outcome_snapshot( + routing_decision="ready", + approval_id=None, + approval_status=None, + execution_id=None, + execution_status=None, + blocked_reason=None, + ), + trace_id=uuid4(), + trace_kind="tool.proxy.execute", + ) + unrelated_step = store.create_task_step( + task_id=unrelated_task["id"], + sequence_no=1, + kind="governed_request", + status="executed", + request=unrelated_task["request"], + outcome=task_step_outcome_snapshot( + routing_decision="ready", + approval_id=None, + approval_status=None, + execution_id=None, + execution_status=None, + blocked_reason=None, + ), + trace_id=uuid4(), + trace_kind="tool.proxy.execute", + ) + + try: + create_next_task_step_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=TaskStepNextCreateInput( + task_id=task["id"], + kind="governed_request", + status="approved", + request=task["request"], + outcome=task_step_outcome_snapshot( + routing_decision="ready", + approval_id=None, + approval_status=None, + execution_id=None, + execution_status=None, + blocked_reason=None, + ), + lineage=TaskStepLineageInput(parent_step_id=unrelated_step["id"]), + ), + ) + except TaskStepSequenceError as exc: + assert str(exc) == f"task step {unrelated_step['id']} does not belong to task {task['id']}" + else: + raise AssertionError("expected TaskStepSequenceError") + + +def test_transition_task_step_rejects_invalid_status_graph_edge() -> None: + store = TaskStoreStub() + task = store.create_task( + status="pending_approval", + latest_approval_id=uuid4(), + latest_execution_id=None, + ) + step = store.create_task_step( + task_id=task["id"], + sequence_no=1, + kind="governed_request", + status="created", + request=task["request"], + outcome=task_step_outcome_snapshot( + routing_decision="approval_required", + approval_id=str(task["latest_approval_id"]), + approval_status="pending", + execution_id=None, + execution_status=None, + blocked_reason=None, + ), + trace_id=uuid4(), + trace_kind="approval.request", + ) + + try: + transition_task_step_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=TaskStepTransitionInput( + task_step_id=step["id"], + status="executed", + outcome=task_step_outcome_snapshot( + routing_decision="approval_required", + approval_id=str(task["latest_approval_id"]), + approval_status="approved", + execution_id=str(uuid4()), + execution_status="completed", + blocked_reason=None, + ), + ), + ) + except TaskStepTransitionError as exc: + assert str(exc) == ( + f"task step {step['id']} is created and cannot transition to executed; allowed: approved, denied" + ) + else: + raise AssertionError("expected TaskStepTransitionError") + + +def test_transition_task_step_rejects_visible_execution_from_unrelated_task_lineage() -> None: + store = TaskStoreStub() + approval_id = uuid4() + task = store.create_task( + status="approved", + latest_approval_id=approval_id, + latest_execution_id=None, + ) + step = store.create_task_step( + task_id=task["id"], + sequence_no=1, + kind="governed_request", + status="approved", + request=task["request"], + outcome=task_step_outcome_snapshot( + routing_decision="approval_required", + approval_id=str(approval_id), + approval_status="approved", + execution_id=None, + execution_status=None, + blocked_reason=None, + ), + trace_id=uuid4(), + trace_kind="task.step.sequence", + ) + store.approvals.append({"id": approval_id, "thread_id": task["thread_id"], "tool_id": task["tool_id"]}) + unrelated_execution_id = uuid4() + store.tool_executions.append( + { + "id": unrelated_execution_id, + "thread_id": uuid4(), + "tool_id": uuid4(), + "approval_id": approval_id, + } + ) + + try: + transition_task_step_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=TaskStepTransitionInput( + task_step_id=step["id"], + status="executed", + outcome=task_step_outcome_snapshot( + routing_decision="approval_required", + approval_id=str(approval_id), + approval_status="approved", + execution_id=str(unrelated_execution_id), + execution_status="completed", + blocked_reason=None, + ), + ), + ) + except TaskStepTransitionError as exc: + assert str(exc) == f"tool execution {unrelated_execution_id} does not belong to task {task['id']}" + else: + raise AssertionError("expected TaskStepTransitionError") + + +def test_get_task_step_record_raises_not_found_when_missing() -> None: + store = TaskStoreStub() + + try: + get_task_step_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + task_step_id=uuid4(), + ) + except TaskStepNotFoundError as exc: + assert "task step" in str(exc) + else: + raise AssertionError("expected TaskStepNotFoundError") diff --git a/tests/unit/test_tasks_main.py b/tests/unit/test_tasks_main.py new file mode 100644 index 0000000..0be9960 --- /dev/null +++ b/tests/unit/test_tasks_main.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +import json +from contextlib import contextmanager +from uuid import uuid4 + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.tasks import ( + TaskNotFoundError, + TaskStepNotFoundError, + TaskStepSequenceError, + TaskStepTransitionError, +) + + +def test_list_task_steps_endpoint_returns_payload(monkeypatch) -> None: + user_id = uuid4() + task_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr( + main_module, + "list_task_step_records", + lambda *_args, **_kwargs: { + "items": [], + "summary": { + "task_id": str(task_id), + "total_count": 0, + "latest_sequence_no": None, + "latest_status": None, + "next_sequence_no": 1, + "append_allowed": False, + "order": ["sequence_no_asc", "created_at_asc", "id_asc"], + }, + }, + ) + + response = main_module.list_task_steps(task_id, user_id) + + assert response.status_code == 200 + assert json.loads(response.body) == { + "items": [], + "summary": { + "task_id": str(task_id), + "total_count": 0, + "latest_sequence_no": None, + "latest_status": None, + "next_sequence_no": 1, + "append_allowed": False, + "order": ["sequence_no_asc", "created_at_asc", "id_asc"], + }, + } + + +def test_list_task_steps_endpoint_maps_task_not_found_to_404(monkeypatch) -> None: + user_id = uuid4() + task_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + def fake_list_task_step_records(*_args, **_kwargs): + raise TaskNotFoundError(f"task {task_id} was not found") + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "list_task_step_records", fake_list_task_step_records) + + response = main_module.list_task_steps(task_id, user_id) + + assert response.status_code == 404 + assert json.loads(response.body) == {"detail": f"task {task_id} was not found"} + + +def test_get_task_step_endpoint_maps_not_found_to_404(monkeypatch) -> None: + user_id = uuid4() + task_step_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + def fake_get_task_step_record(*_args, **_kwargs): + raise TaskStepNotFoundError(f"task step {task_step_id} was not found") + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "get_task_step_record", fake_get_task_step_record) + + response = main_module.get_task_step(task_step_id, user_id) + + assert response.status_code == 404 + assert json.loads(response.body) == {"detail": f"task step {task_step_id} was not found"} + + +def test_create_next_task_step_endpoint_maps_sequence_conflict_to_409(monkeypatch) -> None: + task_id = uuid4() + user_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + def fake_create_next_task_step_record(*_args, **_kwargs): + raise TaskStepSequenceError(f"task {task_id} latest step blocked append") + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "create_next_task_step_record", fake_create_next_task_step_record) + + response = main_module.create_next_task_step( + task_id, + main_module.CreateNextTaskStepRequest( + user_id=user_id, + kind="governed_request", + status="created", + request=main_module.TaskStepRequestSnapshot( + thread_id=uuid4(), + tool_id=uuid4(), + action="tool.run", + scope="workspace", + attributes={}, + ), + outcome=main_module.TaskStepOutcomeRequest( + routing_decision="approval_required", + approval_status="pending", + ), + lineage=main_module.TaskStepLineageRequest(parent_step_id=uuid4()), + ), + ) + + assert response.status_code == 409 + assert json.loads(response.body) == {"detail": f"task {task_id} latest step blocked append"} + + +def test_transition_task_step_endpoint_maps_transition_conflict_to_409(monkeypatch) -> None: + task_step_id = uuid4() + user_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + def fake_transition_task_step_record(*_args, **_kwargs): + raise TaskStepTransitionError(f"task step {task_step_id} is created and cannot transition") + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "transition_task_step_record", fake_transition_task_step_record) + + response = main_module.transition_task_step( + task_step_id, + main_module.TransitionTaskStepRequest( + user_id=user_id, + status="approved", + outcome=main_module.TaskStepOutcomeRequest( + routing_decision="approval_required", + approval_status="approved", + ), + ), + ) + + assert response.status_code == 409 + assert json.loads(response.body) == { + "detail": f"task step {task_step_id} is created and cannot transition" + } diff --git a/tests/unit/test_telegram_channels.py b/tests/unit/test_telegram_channels.py new file mode 100644 index 0000000..eb0f14b --- /dev/null +++ b/tests/unit/test_telegram_channels.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import pytest + +from alicebot_api.telegram_channels import ( + TelegramWebhookValidationError, + build_inbound_idempotency_key, + extract_telegram_link_code, + normalize_telegram_update, + resolve_telegram_thread_key, +) + + +def test_build_inbound_idempotency_key_is_deterministic() -> None: + assert build_inbound_idempotency_key(update_id=1001) == build_inbound_idempotency_key(update_id=1001) + assert len(build_inbound_idempotency_key(update_id=1001)) == 64 + + +def test_extract_telegram_link_code_supports_link_and_start_commands() -> None: + assert extract_telegram_link_code("/link ABC12345", bot_username="alicebot") == "ABC12345" + assert extract_telegram_link_code("/start zx90aa11", bot_username="alicebot") == "ZX90AA11" + assert extract_telegram_link_code("/link@alicebot CODE2026", bot_username="alicebot") == "CODE2026" + assert extract_telegram_link_code("/link@otherbot CODE2026", bot_username="alicebot") is None + assert extract_telegram_link_code("hello", bot_username="alicebot") is None + + +def test_normalize_telegram_update_returns_stable_contract() -> None: + normalized = normalize_telegram_update( + { + "update_id": 2026001, + "message": { + "message_id": 77, + "date": 1710000000, + "chat": {"id": 999001, "type": "private"}, + "from": {"id": 555001, "username": "builder"}, + "text": "/link p10s2abc", + }, + }, + bot_username="alicebot", + ) + + assert normalized["provider_update_id"] == "2026001" + assert normalized["provider_message_id"] == "77" + assert normalized["external_chat_id"] == "999001" + assert normalized["external_user_id"] == "555001" + assert normalized["external_username"] == "builder" + assert normalized["link_code"] == "P10S2ABC" + assert normalized["idempotency_key"] == build_inbound_idempotency_key(update_id=2026001) + assert resolve_telegram_thread_key(external_chat_id=normalized["external_chat_id"]) == "telegram-chat:999001" + + +def test_normalize_telegram_update_rejects_missing_required_fields() -> None: + with pytest.raises(TelegramWebhookValidationError, match="requires integer update_id"): + normalize_telegram_update({"message": {}}, bot_username="alicebot") + + with pytest.raises(TelegramWebhookValidationError, match="requires message object"): + normalize_telegram_update({"update_id": 1}, bot_username="alicebot") + + with pytest.raises(TelegramWebhookValidationError, match="requires chat.id"): + normalize_telegram_update( + { + "update_id": 1, + "message": { + "message_id": 1, + "from": {"id": 2}, + }, + }, + bot_username="alicebot", + ) diff --git a/tests/unit/test_telegram_continuity.py b/tests/unit/test_telegram_continuity.py new file mode 100644 index 0000000..1f0ab51 --- /dev/null +++ b/tests/unit/test_telegram_continuity.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from alicebot_api.telegram_continuity import classify_telegram_message_intent + + +def test_classify_routes_capture_by_default() -> None: + classified = classify_telegram_message_intent("Decision: ship P10-S3") + + assert classified["intent_kind"] == "capture" + assert classified["intent_payload"]["raw_content"] == "Decision: ship P10-S3" + + +def test_classify_routes_recall_resume_open_loop_and_approvals_commands() -> None: + recall = classify_telegram_message_intent("/recall sprint objective") + resume = classify_telegram_message_intent("/resume") + open_loops = classify_telegram_message_intent("/open-loops") + approvals = classify_telegram_message_intent("/approvals") + + assert recall["intent_kind"] == "recall" + assert recall["intent_payload"]["query"] == "sprint objective" + assert resume["intent_kind"] == "resume" + assert open_loops["intent_kind"] == "open_loops" + assert approvals["intent_kind"] == "approvals" + + +def test_classify_routes_correction_and_open_loop_review_commands() -> None: + object_id = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + correction = classify_telegram_message_intent(f"/correct {object_id} Decision: use deterministic routing") + review = classify_telegram_message_intent(f"/open-loop {object_id} deferred needs new signal") + + assert correction["intent_kind"] == "correction" + assert correction["intent_payload"]["continuity_object_id"] == object_id + assert correction["intent_payload"]["replacement_title"] == "Decision: use deterministic routing" + + assert review["intent_kind"] == "open_loop_review" + assert review["intent_payload"]["continuity_object_id"] == object_id + assert review["intent_payload"]["action"] == "deferred" + assert review["intent_payload"]["note"] == "needs new signal" + + +def test_classify_routes_approval_resolution_commands() -> None: + approval_id = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" + approve = classify_telegram_message_intent(f"/approve {approval_id}") + reject = classify_telegram_message_intent(f"/reject {approval_id} no longer needed") + + assert approve["intent_kind"] == "approval_approve" + assert approve["intent_payload"]["approval_id"] == approval_id + + assert reject["intent_kind"] == "approval_reject" + assert reject["intent_payload"]["approval_id"] == approval_id + assert reject["intent_payload"]["note"] == "no longer needed" + + +def test_classify_empty_message_is_unknown() -> None: + classified = classify_telegram_message_intent(" ") + + assert classified["intent_kind"] == "unknown" + assert classified["intent_payload"]["reason"] == "empty_message" diff --git a/tests/unit/test_telegram_notifications.py b/tests/unit/test_telegram_notifications.py new file mode 100644 index 0000000..e1473ac --- /dev/null +++ b/tests/unit/test_telegram_notifications.py @@ -0,0 +1,201 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from uuid import uuid4 + +import alicebot_api.telegram_notifications as notifications + + +def _subscription_row(**overrides): + row = { + "id": uuid4(), + "workspace_id": uuid4(), + "channel_type": "telegram", + "channel_identity_id": uuid4(), + "notifications_enabled": True, + "daily_brief_enabled": True, + "daily_brief_window_start": "07:00", + "open_loop_prompts_enabled": True, + "waiting_for_prompts_enabled": True, + "stale_prompts_enabled": True, + "timezone": "Europe/Stockholm", + "quiet_hours_enabled": True, + "quiet_hours_start": "22:00", + "quiet_hours_end": "07:00", + "created_at": datetime(2026, 4, 8, 9, 0, tzinfo=UTC), + "updated_at": datetime(2026, 4, 8, 9, 0, tzinfo=UTC), + } + row.update(overrides) + return row + + +def test_delivery_policy_enforces_quiet_hours_deterministically() -> None: + subscription = _subscription_row() + + policy = notifications._evaluate_delivery_policy( + subscription, + mode="daily_brief", + prompt_kind=None, + now=datetime(2026, 4, 8, 21, 30, tzinfo=UTC), + force=False, + ) + + assert policy.allowed is False + assert policy.suppression_status == "suppressed_quiet_hours" + assert policy.quiet_hours_active is True + + +def test_materialize_due_jobs_selects_daily_and_prompt_jobs(monkeypatch) -> None: + captured: list[dict[str, object]] = [] + + monkeypatch.setattr( + notifications, + "_resolve_linked_identity", + lambda *_args, **_kwargs: {"id": uuid4()}, + ) + monkeypatch.setattr( + notifications, + "_build_open_loop_prompt_candidates", + lambda *_args, **_kwargs: [ + { + "prompt_id": "stale:11111111-1111-4111-8111-111111111111", + "prompt_kind": "stale", + "continuity_object_id": "11111111-1111-4111-8111-111111111111", + "title": "Stale item", + "continuity_status": "stale", + "review_action_hint": "deferred", + "due_at": datetime(2026, 4, 8, 8, 0, tzinfo=UTC).isoformat(), + "message_text": "prompt", + }, + { + "prompt_id": "waiting_for:22222222-2222-4222-8222-222222222222", + "prompt_kind": "waiting_for", + "continuity_object_id": "22222222-2222-4222-8222-222222222222", + "title": "Waiting for item", + "continuity_status": "active", + "review_action_hint": "still_blocked", + "due_at": datetime(2026, 4, 8, 8, 0, tzinfo=UTC).isoformat(), + "message_text": "prompt", + }, + ], + ) + + def _capture_upsert(_conn, **kwargs): + captured.append(kwargs) + return { + "id": uuid4(), + "workspace_id": kwargs["workspace_id"], + "channel_type": "telegram", + "channel_identity_id": kwargs["channel_identity_id"], + "job_kind": kwargs["job_kind"], + "prompt_kind": kwargs["prompt_kind"], + "prompt_id": kwargs["prompt_id"], + "continuity_object_id": kwargs["continuity_object_id"], + "continuity_brief_id": kwargs["continuity_brief_id"], + "schedule_slot": kwargs["schedule_slot"], + "idempotency_key": kwargs["idempotency_key"], + "due_at": kwargs["due_at"], + "status": "scheduled", + "suppression_reason": None, + "attempt_count": 0, + "delivery_receipt_id": None, + "payload": kwargs["payload"], + "result_payload": {}, + "attempted_at": None, + "completed_at": None, + "created_at": datetime(2026, 4, 8, 8, 0, tzinfo=UTC), + "updated_at": datetime(2026, 4, 8, 8, 0, tzinfo=UTC), + } + + monkeypatch.setattr(notifications, "_upsert_scheduled_job", _capture_upsert) + + workspace_id = uuid4() + subscription = _subscription_row( + workspace_id=workspace_id, + quiet_hours_enabled=False, + timezone="UTC", + daily_brief_window_start="07:00", + ) + + notifications._materialize_due_jobs( + conn=object(), + user_account_id=uuid4(), + workspace_id=workspace_id, + subscription=subscription, + now=datetime(2026, 4, 8, 8, 0, tzinfo=UTC), + prompt_limit=5, + ) + + assert len(captured) == 3 + assert [item["job_kind"] for item in captured] == [ + "daily_brief", + "open_loop_prompt", + "open_loop_prompt", + ] + + +def test_daily_brief_bundle_uses_continuity_and_chief_of_staff_sources(monkeypatch) -> None: + monkeypatch.setattr(notifications, "set_current_user", lambda *_args, **_kwargs: None) + monkeypatch.setattr( + notifications, + "compile_continuity_daily_brief", + lambda *_args, **_kwargs: { + "brief": { + "assembly_version": "continuity_daily_brief_v0", + "waiting_for_highlights": {"summary": {"total_count": 2}}, + "blocker_highlights": {"summary": {"total_count": 1}}, + "stale_items": {"summary": {"total_count": 1}}, + "next_suggested_action": {"item": {"title": "Next Action: Ship P10-S4"}}, + } + }, + ) + monkeypatch.setattr( + notifications, + "compile_chief_of_staff_priority_brief", + lambda *_args, **_kwargs: { + "brief": { + "summary": { + "trust_confidence_posture": "medium", + "follow_through_total_count": 3, + }, + "recommended_next_action": {"title": "Follow up waiting-for dependency"}, + } + }, + ) + + bundle = notifications._build_daily_brief_bundle( + conn=object(), + user_account_id=uuid4(), + timezone_name="UTC", + now=datetime(2026, 4, 8, 8, 0, tzinfo=UTC), + ) + + assert bundle["brief"]["assembly_version"] == "continuity_daily_brief_v0" + assert bundle["chief_of_staff_summary"]["trust_confidence_posture"] == "medium" + assert "Waiting-for: 2" in bundle["message_text"] + assert "Chief-of-staff recommendation: Follow up waiting-for dependency" in bundle["message_text"] + + +def test_internal_idempotency_key_scopes_custom_values_by_workspace() -> None: + shared_client_key = "same-client-key" + workspace_a = uuid4() + workspace_b = uuid4() + + key_a = notifications._resolve_internal_idempotency_key( + workspace_id=workspace_a, + job_kind="daily_brief", + schedule_slot="2026-04-08", + prompt_id=None, + client_idempotency_key=shared_client_key, + ) + key_b = notifications._resolve_internal_idempotency_key( + workspace_id=workspace_b, + job_kind="daily_brief", + schedule_slot="2026-04-08", + prompt_id=None, + client_idempotency_key=shared_client_key, + ) + + assert key_a != key_b + assert key_a.startswith("telegram:daily_brief:custom:") + assert key_b.startswith("telegram:daily_brief:custom:") diff --git a/tests/unit/test_tool_execution_store.py b/tests/unit/test_tool_execution_store.py new file mode 100644 index 0000000..e1a6e3e --- /dev/null +++ b/tests/unit/test_tool_execution_store.py @@ -0,0 +1,192 @@ +from __future__ import annotations + +from typing import Any +from uuid import uuid4 + +from psycopg.types.json import Jsonb + +from alicebot_api.store import ContinuityStore + + +class RecordingCursor: + def __init__(self, fetchone_results: list[dict[str, Any]], fetchall_result: list[dict[str, Any]] | None = None) -> None: + self.executed: list[tuple[str, tuple[object, ...] | None]] = [] + self.fetchone_results = list(fetchone_results) + self.fetchall_result = fetchall_result or [] + + def __enter__(self) -> "RecordingCursor": + return self + + def __exit__(self, exc_type, exc, tb) -> None: + return None + + def execute(self, query: str, params: tuple[object, ...] | None = None) -> None: + self.executed.append((query, params)) + + def fetchone(self) -> dict[str, Any] | None: + if not self.fetchone_results: + return None + return self.fetchone_results.pop(0) + + def fetchall(self) -> list[dict[str, Any]]: + return self.fetchall_result + + +class RecordingConnection: + def __init__(self, cursor: RecordingCursor) -> None: + self.cursor_instance = cursor + + def cursor(self) -> RecordingCursor: + return self.cursor_instance + + +def test_tool_execution_store_methods_use_expected_queries_and_jsonb_parameters() -> None: + execution_id = uuid4() + approval_id = uuid4() + task_step_id = uuid4() + thread_id = uuid4() + tool_id = uuid4() + trace_id = uuid4() + request_event_id = uuid4() + result_event_id = uuid4() + row = { + "id": execution_id, + "approval_id": approval_id, + "task_run_id": None, + "task_step_id": task_step_id, + "thread_id": thread_id, + "tool_id": tool_id, + "trace_id": trace_id, + "request_event_id": request_event_id, + "result_event_id": result_event_id, + "status": "completed", + "handler_key": "proxy.echo", + "idempotency_key": None, + "request": {"thread_id": str(thread_id), "tool_id": str(tool_id)}, + "tool": {"id": str(tool_id), "tool_key": "proxy.echo"}, + "result": {"handler_key": "proxy.echo", "status": "completed", "output": {"mode": "no_side_effect"}, "reason": None}, + "executed_at": "2026-03-13T10:00:00+00:00", + } + cursor = RecordingCursor( + fetchone_results=[row, row], + fetchall_result=[row], + ) + store = ContinuityStore(RecordingConnection(cursor)) + + created = store.create_tool_execution( + approval_id=approval_id, + task_step_id=task_step_id, + thread_id=thread_id, + tool_id=tool_id, + trace_id=trace_id, + request_event_id=request_event_id, + result_event_id=result_event_id, + status="completed", + handler_key="proxy.echo", + request={"thread_id": str(thread_id), "tool_id": str(tool_id)}, + tool={"id": str(tool_id), "tool_key": "proxy.echo"}, + result={"handler_key": "proxy.echo", "status": "completed", "output": {"mode": "no_side_effect"}, "reason": None}, + ) + fetched = store.get_tool_execution_optional(execution_id) + listed = store.list_tool_executions() + + assert created["id"] == execution_id + assert fetched is not None + assert fetched["id"] == execution_id + assert listed[0]["id"] == execution_id + + create_query, create_params = cursor.executed[0] + assert "INSERT INTO tool_executions" in create_query + assert create_params is not None + assert create_params[:11] == ( + approval_id, + None, + task_step_id, + thread_id, + tool_id, + trace_id, + request_event_id, + result_event_id, + "completed", + "proxy.echo", + None, + ) + assert isinstance(create_params[11], Jsonb) + assert create_params[11].obj == {"thread_id": str(thread_id), "tool_id": str(tool_id)} + assert isinstance(create_params[12], Jsonb) + assert create_params[12].obj == {"id": str(tool_id), "tool_key": "proxy.echo"} + assert isinstance(create_params[13], Jsonb) + assert create_params[13].obj == { + "handler_key": "proxy.echo", + "status": "completed", + "output": {"mode": "no_side_effect"}, + "reason": None, + } + assert "FROM tool_executions" in cursor.executed[1][0] + assert "ORDER BY executed_at ASC, id ASC" in cursor.executed[2][0] + + +def test_create_tool_execution_accepts_blocked_attempt_without_event_ids() -> None: + execution_id = uuid4() + approval_id = uuid4() + task_step_id = uuid4() + thread_id = uuid4() + tool_id = uuid4() + trace_id = uuid4() + cursor = RecordingCursor( + fetchone_results=[ + { + "id": execution_id, + "approval_id": approval_id, + "task_run_id": None, + "task_step_id": task_step_id, + "thread_id": thread_id, + "tool_id": tool_id, + "trace_id": trace_id, + "request_event_id": None, + "result_event_id": None, + "status": "blocked", + "handler_key": None, + "idempotency_key": None, + "request": {"thread_id": str(thread_id), "tool_id": str(tool_id)}, + "tool": {"id": str(tool_id), "tool_key": "proxy.missing"}, + "result": { + "handler_key": None, + "status": "blocked", + "output": None, + "reason": "tool 'proxy.missing' has no registered proxy handler", + }, + "executed_at": "2026-03-13T10:05:00+00:00", + } + ] + ) + store = ContinuityStore(RecordingConnection(cursor)) + + created = store.create_tool_execution( + approval_id=approval_id, + task_step_id=task_step_id, + thread_id=thread_id, + tool_id=tool_id, + trace_id=trace_id, + request_event_id=None, + result_event_id=None, + status="blocked", + handler_key=None, + request={"thread_id": str(thread_id), "tool_id": str(tool_id)}, + tool={"id": str(tool_id), "tool_key": "proxy.missing"}, + result={ + "handler_key": None, + "status": "blocked", + "output": None, + "reason": "tool 'proxy.missing' has no registered proxy handler", + }, + ) + + assert created["status"] == "blocked" + create_query, create_params = cursor.executed[0] + assert "INSERT INTO tool_executions" in create_query + assert create_params is not None + assert create_params[1] is None + assert create_params[6] is None + assert create_params[7] is None + assert create_params[9] is None diff --git a/tests/unit/test_tool_store.py b/tests/unit/test_tool_store.py new file mode 100644 index 0000000..6f9fc09 --- /dev/null +++ b/tests/unit/test_tool_store.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +from typing import Any +from uuid import uuid4 + +from psycopg.types.json import Jsonb + +from alicebot_api.store import ContinuityStore + + +class RecordingCursor: + def __init__(self, fetchone_results: list[dict[str, Any]], fetchall_result: list[dict[str, Any]] | None = None) -> None: + self.executed: list[tuple[str, tuple[object, ...] | None]] = [] + self.fetchone_results = list(fetchone_results) + self.fetchall_result = fetchall_result or [] + + def __enter__(self) -> "RecordingCursor": + return self + + def __exit__(self, exc_type, exc, tb) -> None: + return None + + def execute(self, query: str, params: tuple[object, ...] | None = None) -> None: + self.executed.append((query, params)) + + def fetchone(self) -> dict[str, Any] | None: + if not self.fetchone_results: + return None + return self.fetchone_results.pop(0) + + def fetchall(self) -> list[dict[str, Any]]: + return self.fetchall_result + + +class RecordingConnection: + def __init__(self, cursor: RecordingCursor) -> None: + self.cursor_instance = cursor + + def cursor(self) -> RecordingCursor: + return self.cursor_instance + + +def test_tool_store_methods_use_expected_queries_and_jsonb_parameters() -> None: + tool_id = uuid4() + cursor = RecordingCursor( + fetchone_results=[ + { + "id": tool_id, + "tool_key": "browser.open", + "name": "Browser Open", + "description": "Open documentation pages.", + "version": "1.0.0", + "metadata_version": "tool_metadata_v0", + "active": True, + "tags": ["browser"], + "action_hints": ["tool.run"], + "scope_hints": ["workspace"], + "domain_hints": ["docs"], + "risk_hints": [], + "metadata": {"transport": "proxy"}, + }, + { + "id": tool_id, + "tool_key": "browser.open", + "name": "Browser Open", + "description": "Open documentation pages.", + "version": "1.0.0", + "metadata_version": "tool_metadata_v0", + "active": True, + "tags": ["browser"], + "action_hints": ["tool.run"], + "scope_hints": ["workspace"], + "domain_hints": ["docs"], + "risk_hints": [], + "metadata": {"transport": "proxy"}, + }, + ], + fetchall_result=[ + { + "id": tool_id, + "tool_key": "browser.open", + "name": "Browser Open", + "description": "Open documentation pages.", + "version": "1.0.0", + "metadata_version": "tool_metadata_v0", + "active": True, + "tags": ["browser"], + "action_hints": ["tool.run"], + "scope_hints": ["workspace"], + "domain_hints": ["docs"], + "risk_hints": [], + "metadata": {"transport": "proxy"}, + } + ], + ) + store = ContinuityStore(RecordingConnection(cursor)) + + created = store.create_tool( + tool_key="browser.open", + name="Browser Open", + description="Open documentation pages.", + version="1.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["browser"], + action_hints=["tool.run"], + scope_hints=["workspace"], + domain_hints=["docs"], + risk_hints=[], + metadata={"transport": "proxy"}, + ) + fetched = store.get_tool_optional(tool_id) + listed = store.list_active_tools() + + assert created["id"] == tool_id + assert fetched is not None + assert listed[0]["id"] == tool_id + + create_query, create_params = cursor.executed[0] + assert "INSERT INTO tools" in create_query + assert create_params is not None + assert create_params[:6] == ( + "browser.open", + "Browser Open", + "Open documentation pages.", + "1.0.0", + "tool_metadata_v0", + True, + ) + for index, expected in ( + (6, ["browser"]), + (7, ["tool.run"]), + (8, ["workspace"]), + (9, ["docs"]), + (10, []), + ): + assert isinstance(create_params[index], Jsonb) + assert create_params[index].obj == expected + assert isinstance(create_params[11], Jsonb) + assert create_params[11].obj == {"transport": "proxy"} + + assert cursor.executed[1] == ( + """ + SELECT + id, + user_id, + tool_key, + name, + description, + version, + metadata_version, + active, + tags, + action_hints, + scope_hints, + domain_hints, + risk_hints, + metadata, + created_at + FROM tools + WHERE id = %s + """, + (tool_id,), + ) + assert "WHERE active = TRUE" in cursor.executed[2][0] diff --git a/tests/unit/test_tools.py b/tests/unit/test_tools.py new file mode 100644 index 0000000..169e24e --- /dev/null +++ b/tests/unit/test_tools.py @@ -0,0 +1,688 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from uuid import UUID, uuid4 + +import pytest + +from alicebot_api.contracts import ( + ToolAllowlistEvaluationRequestInput, + ToolCreateInput, + ToolRoutingRequestInput, +) +from alicebot_api.tools import ( + ToolAllowlistValidationError, + create_tool_record, + evaluate_tool_allowlist, + get_tool_record, + list_tool_records, + route_tool_invocation, + ToolRoutingValidationError, +) + + +class ToolStoreStub: + def __init__(self) -> None: + self.base_time = datetime(2026, 3, 12, 9, 0, tzinfo=UTC) + self.user_id = uuid4() + self.thread_id = uuid4() + self.consents: dict[str, dict[str, object]] = {} + self.policies: list[dict[str, object]] = [] + self.tools: list[dict[str, object]] = [] + self.traces: list[dict[str, object]] = [] + self.trace_events: list[dict[str, object]] = [] + + def create_consent(self, *, consent_key: str, status: str, metadata: dict[str, object]) -> dict[str, object]: + consent = { + "id": uuid4(), + "user_id": self.user_id, + "consent_key": consent_key, + "status": status, + "metadata": metadata, + "created_at": self.base_time + timedelta(minutes=len(self.consents)), + "updated_at": self.base_time + timedelta(minutes=len(self.consents)), + } + self.consents[consent_key] = consent + return consent + + def list_consents(self) -> list[dict[str, object]]: + return sorted( + self.consents.values(), + key=lambda consent: (consent["consent_key"], consent["created_at"], consent["id"]), + ) + + def create_policy( + self, + *, + name: str, + action: str, + scope: str, + effect: str, + priority: int, + active: bool, + conditions: dict[str, object], + required_consents: list[str], + ) -> dict[str, object]: + policy = { + "id": uuid4(), + "user_id": self.user_id, + "name": name, + "action": action, + "scope": scope, + "effect": effect, + "priority": priority, + "active": active, + "conditions": conditions, + "required_consents": required_consents, + "created_at": self.base_time + timedelta(minutes=len(self.policies)), + "updated_at": self.base_time + timedelta(minutes=len(self.policies)), + } + self.policies.append(policy) + return policy + + def list_active_policies(self) -> list[dict[str, object]]: + return sorted( + [policy for policy in self.policies if policy["active"] is True], + key=lambda policy: (policy["priority"], policy["created_at"], policy["id"]), + ) + + def create_tool( + self, + *, + tool_key: str, + name: str, + description: str, + version: str, + metadata_version: str, + active: bool, + tags: list[str], + action_hints: list[str], + scope_hints: list[str], + domain_hints: list[str], + risk_hints: list[str], + metadata: dict[str, object], + ) -> dict[str, object]: + tool = { + "id": uuid4(), + "user_id": self.user_id, + "tool_key": tool_key, + "name": name, + "description": description, + "version": version, + "metadata_version": metadata_version, + "active": active, + "tags": tags, + "action_hints": action_hints, + "scope_hints": scope_hints, + "domain_hints": domain_hints, + "risk_hints": risk_hints, + "metadata": metadata, + "created_at": self.base_time + timedelta(minutes=len(self.tools)), + } + self.tools.append(tool) + return tool + + def get_tool_optional(self, tool_id: UUID) -> dict[str, object] | None: + return next((tool for tool in self.tools if tool["id"] == tool_id), None) + + def list_tools(self) -> list[dict[str, object]]: + return sorted( + self.tools, + key=lambda tool: (tool["tool_key"], tool["version"], tool["created_at"], tool["id"]), + ) + + def list_active_tools(self) -> list[dict[str, object]]: + return [tool for tool in self.list_tools() if tool["active"] is True] + + def get_thread_optional(self, thread_id: UUID) -> dict[str, object] | None: + if thread_id != self.thread_id: + return None + return { + "id": self.thread_id, + "user_id": self.user_id, + "title": "Tool thread", + "created_at": self.base_time, + "updated_at": self.base_time, + } + + def create_trace( + self, + *, + user_id: UUID, + thread_id: UUID, + kind: str, + compiler_version: str, + status: str, + limits: dict[str, object], + ) -> dict[str, object]: + trace = { + "id": uuid4(), + "user_id": user_id, + "thread_id": thread_id, + "kind": kind, + "compiler_version": compiler_version, + "status": status, + "limits": limits, + "created_at": self.base_time, + } + self.traces.append(trace) + return trace + + def append_trace_event( + self, + *, + trace_id: UUID, + sequence_no: int, + kind: str, + payload: dict[str, object], + ) -> dict[str, object]: + event = { + "id": uuid4(), + "trace_id": trace_id, + "sequence_no": sequence_no, + "kind": kind, + "payload": payload, + "created_at": self.base_time, + } + self.trace_events.append(event) + return event + + +def test_create_list_and_get_tool_records_preserve_deterministic_order() -> None: + store = ToolStoreStub() + later = create_tool_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + tool=ToolCreateInput( + tool_key="zeta.fetch", + name="Zeta Fetch", + description="Fetch zeta records.", + version="2.0.0", + action_hints=("tool.run",), + scope_hints=("workspace",), + ), + ) + earlier = store.create_tool( + tool_key="alpha.open", + name="Alpha Open", + description="Open alpha pages.", + version="1.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["browser"], + action_hints=["tool.run"], + scope_hints=["workspace"], + domain_hints=["docs"], + risk_hints=[], + metadata={"transport": "proxy"}, + ) + + listed = list_tool_records( + store, # type: ignore[arg-type] + user_id=store.user_id, + ) + detail = get_tool_record( + store, # type: ignore[arg-type] + user_id=store.user_id, + tool_id=UUID(later["tool"]["id"]), + ) + + assert [item["tool_key"] for item in listed["items"]] == ["alpha.open", "zeta.fetch"] + assert listed["summary"] == { + "total_count": 2, + "order": ["tool_key_asc", "version_asc", "created_at_asc", "id_asc"], + } + assert detail == {"tool": later["tool"]} + assert listed["items"][0]["id"] == str(earlier["id"]) + + +def test_evaluate_tool_allowlist_splits_allowed_denied_and_approval_required() -> None: + store = ToolStoreStub() + store.create_consent(consent_key="web_access", status="granted", metadata={"source": "settings"}) + allowed_tool = store.create_tool( + tool_key="browser.open", + name="Browser Open", + description="Open documentation pages.", + version="1.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["browser"], + action_hints=["tool.run"], + scope_hints=["workspace"], + domain_hints=["docs"], + risk_hints=[], + metadata={"transport": "proxy"}, + ) + denied_tool = store.create_tool( + tool_key="calendar.read", + name="Calendar Read", + description="Read a calendar.", + version="1.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["calendar"], + action_hints=["calendar.read"], + scope_hints=["calendar"], + domain_hints=[], + risk_hints=[], + metadata={}, + ) + approval_tool = store.create_tool( + tool_key="shell.exec", + name="Shell Exec", + description="Run shell commands.", + version="1.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["shell"], + action_hints=["tool.run"], + scope_hints=["workspace"], + domain_hints=[], + risk_hints=[], + metadata={"transport": "local"}, + ) + + store.create_policy( + name="Allow docs browser", + action="tool.run", + scope="workspace", + effect="allow", + priority=10, + active=True, + conditions={"tool_key": "browser.open", "domain_hint": "docs"}, + required_consents=["web_access"], + ) + store.create_policy( + name="Require shell approval", + action="tool.run", + scope="workspace", + effect="require_approval", + priority=20, + active=True, + conditions={"tool_key": "shell.exec"}, + required_consents=[], + ) + + payload = evaluate_tool_allowlist( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=ToolAllowlistEvaluationRequestInput( + thread_id=store.thread_id, + action="tool.run", + scope="workspace", + domain_hint="docs", + attributes={}, + ), + ) + + assert payload["allowed"] == [ + { + "decision": "allowed", + "tool": { + "id": str(allowed_tool["id"]), + "tool_key": "browser.open", + "name": "Browser Open", + "description": "Open documentation pages.", + "version": "1.0.0", + "metadata_version": "tool_metadata_v0", + "active": True, + "tags": ["browser"], + "action_hints": ["tool.run"], + "scope_hints": ["workspace"], + "domain_hints": ["docs"], + "risk_hints": [], + "metadata": {"transport": "proxy"}, + "created_at": allowed_tool["created_at"].isoformat(), + }, + "reasons": [ + { + "code": "tool_metadata_matched", + "source": "tool", + "message": "Tool metadata matched the requested action, scope, and optional hints.", + "tool_id": str(allowed_tool["id"]), + "policy_id": None, + "consent_key": None, + }, + { + "code": "matched_policy", + "source": "policy", + "message": "Matched policy 'Allow docs browser' at priority 10.", + "tool_id": str(allowed_tool["id"]), + "policy_id": str(store.policies[0]["id"]), + "consent_key": None, + }, + { + "code": "policy_effect_allow", + "source": "policy", + "message": "Policy effect resolved the decision to 'allow'.", + "tool_id": str(allowed_tool["id"]), + "policy_id": str(store.policies[0]["id"]), + "consent_key": None, + }, + ], + } + ] + assert [item["tool"]["id"] for item in payload["approval_required"]] == [str(approval_tool["id"])] + assert payload["approval_required"][0]["reasons"][-1]["code"] == "policy_effect_require_approval" + assert [item["tool"]["id"] for item in payload["denied"]] == [str(denied_tool["id"])] + assert [reason["code"] for reason in payload["denied"][0]["reasons"]] == [ + "tool_action_unsupported", + "tool_scope_unsupported", + ] + assert payload["summary"] == { + "action": "tool.run", + "scope": "workspace", + "domain_hint": "docs", + "risk_hint": None, + "evaluated_tool_count": 3, + "allowed_count": 1, + "denied_count": 1, + "approval_required_count": 1, + "order": ["tool_key_asc", "version_asc", "created_at_asc", "id_asc"], + } + assert payload["trace"]["trace_event_count"] == 6 + assert [event["kind"] for event in store.trace_events] == [ + "tool.allowlist.request", + "tool.allowlist.order", + "tool.allowlist.decision", + "tool.allowlist.decision", + "tool.allowlist.decision", + "tool.allowlist.summary", + ] + + +def test_evaluate_tool_allowlist_validates_thread_scope() -> None: + with pytest.raises( + ToolAllowlistValidationError, + match="thread_id must reference an existing thread owned by the user", + ): + evaluate_tool_allowlist( + ToolStoreStub(), # type: ignore[arg-type] + user_id=uuid4(), + request=ToolAllowlistEvaluationRequestInput( + thread_id=uuid4(), + action="tool.run", + scope="workspace", + attributes={}, + ), + ) + + +def test_route_tool_invocation_returns_ready_with_trace() -> None: + store = ToolStoreStub() + store.create_consent(consent_key="web_access", status="granted", metadata={"source": "settings"}) + tool = store.create_tool( + tool_key="browser.open", + name="Browser Open", + description="Open documentation pages.", + version="1.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["browser"], + action_hints=["tool.run"], + scope_hints=["workspace"], + domain_hints=["docs"], + risk_hints=[], + metadata={"transport": "proxy"}, + ) + policy = store.create_policy( + name="Allow docs browser", + action="tool.run", + scope="workspace", + effect="allow", + priority=10, + active=True, + conditions={"tool_key": "browser.open", "domain_hint": "docs"}, + required_consents=["web_access"], + ) + + payload = route_tool_invocation( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=ToolRoutingRequestInput( + thread_id=store.thread_id, + tool_id=tool["id"], + action="tool.run", + scope="workspace", + domain_hint="docs", + attributes={"channel": "chat"}, + ), + ) + + assert payload == { + "request": { + "thread_id": str(store.thread_id), + "tool_id": str(tool["id"]), + "action": "tool.run", + "scope": "workspace", + "domain_hint": "docs", + "risk_hint": None, + "attributes": {"channel": "chat"}, + }, + "decision": "ready", + "tool": { + "id": str(tool["id"]), + "tool_key": "browser.open", + "name": "Browser Open", + "description": "Open documentation pages.", + "version": "1.0.0", + "metadata_version": "tool_metadata_v0", + "active": True, + "tags": ["browser"], + "action_hints": ["tool.run"], + "scope_hints": ["workspace"], + "domain_hints": ["docs"], + "risk_hints": [], + "metadata": {"transport": "proxy"}, + "created_at": tool["created_at"].isoformat(), + }, + "reasons": [ + { + "code": "tool_metadata_matched", + "source": "tool", + "message": "Tool metadata matched the requested action, scope, and optional hints.", + "tool_id": str(tool["id"]), + "policy_id": None, + "consent_key": None, + }, + { + "code": "matched_policy", + "source": "policy", + "message": "Matched policy 'Allow docs browser' at priority 10.", + "tool_id": str(tool["id"]), + "policy_id": str(policy["id"]), + "consent_key": None, + }, + { + "code": "policy_effect_allow", + "source": "policy", + "message": "Policy effect resolved the decision to 'allow'.", + "tool_id": str(tool["id"]), + "policy_id": str(policy["id"]), + "consent_key": None, + }, + ], + "summary": { + "thread_id": str(store.thread_id), + "tool_id": str(tool["id"]), + "action": "tool.run", + "scope": "workspace", + "domain_hint": "docs", + "risk_hint": None, + "decision": "ready", + "evaluated_tool_count": 1, + "active_policy_count": 1, + "consent_count": 1, + "order": ["tool_key_asc", "version_asc", "created_at_asc", "id_asc"], + }, + "trace": { + "trace_id": str(store.traces[0]["id"]), + "trace_event_count": 3, + }, + } + assert store.traces[0]["kind"] == "tool.route" + assert store.traces[0]["compiler_version"] == "tool_routing_v0" + assert [event["kind"] for event in store.trace_events] == [ + "tool.route.request", + "tool.route.decision", + "tool.route.summary", + ] + assert store.trace_events[1]["payload"]["allowlist_decision"] == "allowed" + assert store.trace_events[1]["payload"]["routing_decision"] == "ready" + + +def test_route_tool_invocation_returns_denied_for_metadata_or_policy_denial() -> None: + store = ToolStoreStub() + tool = store.create_tool( + tool_key="calendar.read", + name="Calendar Read", + description="Read calendars.", + version="1.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["calendar"], + action_hints=["calendar.read"], + scope_hints=["calendar"], + domain_hints=[], + risk_hints=[], + metadata={}, + ) + + payload = route_tool_invocation( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=ToolRoutingRequestInput( + thread_id=store.thread_id, + tool_id=tool["id"], + action="tool.run", + scope="workspace", + attributes={}, + ), + ) + + assert payload["decision"] == "denied" + assert [reason["code"] for reason in payload["reasons"]] == [ + "tool_action_unsupported", + "tool_scope_unsupported", + ] + assert payload["summary"]["decision"] == "denied" + assert payload["trace"]["trace_event_count"] == 3 + + +def test_route_tool_invocation_returns_approval_required() -> None: + store = ToolStoreStub() + tool = store.create_tool( + tool_key="shell.exec", + name="Shell Exec", + description="Run shell commands.", + version="1.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["shell"], + action_hints=["tool.run"], + scope_hints=["workspace"], + domain_hints=[], + risk_hints=[], + metadata={"transport": "local"}, + ) + policy = store.create_policy( + name="Require shell approval", + action="tool.run", + scope="workspace", + effect="require_approval", + priority=10, + active=True, + conditions={"tool_key": "shell.exec"}, + required_consents=[], + ) + + payload = route_tool_invocation( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=ToolRoutingRequestInput( + thread_id=store.thread_id, + tool_id=tool["id"], + action="tool.run", + scope="workspace", + attributes={}, + ), + ) + + assert payload["decision"] == "approval_required" + assert payload["summary"]["decision"] == "approval_required" + assert payload["reasons"][-1] == { + "code": "policy_effect_require_approval", + "source": "policy", + "message": "Policy effect resolved the decision to 'require_approval'.", + "tool_id": str(tool["id"]), + "policy_id": str(policy["id"]), + "consent_key": None, + } + + +def test_route_tool_invocation_validates_thread_scope() -> None: + store = ToolStoreStub() + tool = store.create_tool( + tool_key="browser.open", + name="Browser Open", + description="Open documentation pages.", + version="1.0.0", + metadata_version="tool_metadata_v0", + active=True, + tags=["browser"], + action_hints=["tool.run"], + scope_hints=["workspace"], + domain_hints=[], + risk_hints=[], + metadata={}, + ) + + with pytest.raises( + ToolRoutingValidationError, + match="thread_id must reference an existing thread owned by the user", + ): + route_tool_invocation( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=ToolRoutingRequestInput( + thread_id=uuid4(), + tool_id=tool["id"], + action="tool.run", + scope="workspace", + attributes={}, + ), + ) + + +def test_route_tool_invocation_validates_active_tool_scope() -> None: + store = ToolStoreStub() + inactive_tool = store.create_tool( + tool_key="browser.open", + name="Browser Open", + description="Open documentation pages.", + version="1.0.0", + metadata_version="tool_metadata_v0", + active=False, + tags=["browser"], + action_hints=["tool.run"], + scope_hints=["workspace"], + domain_hints=[], + risk_hints=[], + metadata={}, + ) + + with pytest.raises( + ToolRoutingValidationError, + match="tool_id must reference an existing active tool owned by the user", + ): + route_tool_invocation( + store, # type: ignore[arg-type] + user_id=store.user_id, + request=ToolRoutingRequestInput( + thread_id=store.thread_id, + tool_id=inactive_tool["id"], + action="tool.run", + scope="workspace", + attributes={}, + ), + ) diff --git a/tests/unit/test_tools_main.py b/tests/unit/test_tools_main.py new file mode 100644 index 0000000..fc86a9c --- /dev/null +++ b/tests/unit/test_tools_main.py @@ -0,0 +1,315 @@ +from __future__ import annotations + +import json +from contextlib import contextmanager +from uuid import uuid4 + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.tools import ( + ToolAllowlistValidationError, + ToolNotFoundError, + ToolRoutingValidationError, +) + + +def test_create_tool_endpoint_translates_request_and_returns_created_status(monkeypatch) -> None: + user_id = uuid4() + settings = Settings(database_url="postgresql://app") + captured: dict[str, object] = {} + + @contextmanager + def fake_user_connection(database_url: str, current_user_id): + captured["database_url"] = database_url + captured["current_user_id"] = current_user_id + yield object() + + def fake_create_tool_record(store, *, user_id, tool): + captured["store_type"] = type(store).__name__ + captured["user_id"] = user_id + captured["tool"] = tool + return { + "tool": { + "id": "tool-123", + "tool_key": "browser.open", + "name": "Browser Open", + "description": "Open documentation pages.", + "version": "1.0.0", + "metadata_version": "tool_metadata_v0", + "active": True, + "tags": ["browser"], + "action_hints": ["tool.run"], + "scope_hints": ["workspace"], + "domain_hints": ["docs"], + "risk_hints": [], + "metadata": {"transport": "proxy"}, + "created_at": "2026-03-12T09:00:00+00:00", + } + } + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "create_tool_record", fake_create_tool_record) + + response = main_module.create_tool( + main_module.CreateToolRequest( + user_id=user_id, + tool_key="browser.open", + name="Browser Open", + description="Open documentation pages.", + version="1.0.0", + action_hints=["tool.run"], + scope_hints=["workspace"], + domain_hints=["docs"], + risk_hints=[], + metadata={"transport": "proxy"}, + ) + ) + + assert response.status_code == 201 + assert json.loads(response.body)["tool"]["tool_key"] == "browser.open" + assert captured["database_url"] == "postgresql://app" + assert captured["current_user_id"] == user_id + assert captured["store_type"] == "ContinuityStore" + assert captured["user_id"] == user_id + assert captured["tool"].tool_key == "browser.open" + assert captured["tool"].action_hints == ("tool.run",) + assert captured["tool"].scope_hints == ("workspace",) + + +def test_get_tool_endpoint_maps_not_found_to_404(monkeypatch) -> None: + user_id = uuid4() + tool_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + def fake_get_tool_record(*_args, **_kwargs): + raise ToolNotFoundError(f"tool {tool_id} was not found") + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "get_tool_record", fake_get_tool_record) + + response = main_module.get_tool(tool_id, user_id) + + assert response.status_code == 404 + assert json.loads(response.body) == {"detail": f"tool {tool_id} was not found"} + + +def test_evaluate_tool_allowlist_endpoint_translates_request_and_returns_trace_payload(monkeypatch) -> None: + user_id = uuid4() + thread_id = uuid4() + settings = Settings(database_url="postgresql://app") + captured: dict[str, object] = {} + + @contextmanager + def fake_user_connection(database_url: str, current_user_id): + captured["database_url"] = database_url + captured["current_user_id"] = current_user_id + yield object() + + def fake_evaluate_tool_allowlist(store, *, user_id, request): + captured["store_type"] = type(store).__name__ + captured["user_id"] = user_id + captured["request"] = request + return { + "allowed": [], + "denied": [], + "approval_required": [], + "summary": { + "action": "tool.run", + "scope": "workspace", + "domain_hint": "docs", + "risk_hint": None, + "evaluated_tool_count": 0, + "allowed_count": 0, + "denied_count": 0, + "approval_required_count": 0, + "order": ["tool_key_asc", "version_asc", "created_at_asc", "id_asc"], + }, + "trace": {"trace_id": "trace-123", "trace_event_count": 3}, + } + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "evaluate_tool_allowlist", fake_evaluate_tool_allowlist) + + response = main_module.evaluate_tools_allowlist( + main_module.EvaluateToolAllowlistRequest( + user_id=user_id, + thread_id=thread_id, + action="tool.run", + scope="workspace", + domain_hint="docs", + attributes={"channel": "chat"}, + ) + ) + + assert response.status_code == 200 + assert json.loads(response.body)["trace"] == {"trace_id": "trace-123", "trace_event_count": 3} + assert captured["database_url"] == "postgresql://app" + assert captured["current_user_id"] == user_id + assert captured["store_type"] == "ContinuityStore" + assert captured["user_id"] == user_id + assert captured["request"].thread_id == thread_id + assert captured["request"].action == "tool.run" + assert captured["request"].scope == "workspace" + assert captured["request"].domain_hint == "docs" + assert captured["request"].attributes == {"channel": "chat"} + + +def test_evaluate_tool_allowlist_endpoint_maps_validation_errors_to_400(monkeypatch) -> None: + user_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + def fake_evaluate_tool_allowlist(*_args, **_kwargs): + raise ToolAllowlistValidationError("thread_id must reference an existing thread owned by the user") + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "evaluate_tool_allowlist", fake_evaluate_tool_allowlist) + + response = main_module.evaluate_tools_allowlist( + main_module.EvaluateToolAllowlistRequest( + user_id=user_id, + thread_id=uuid4(), + action="tool.run", + scope="workspace", + attributes={}, + ) + ) + + assert response.status_code == 400 + assert json.loads(response.body) == { + "detail": "thread_id must reference an existing thread owned by the user" + } + + +def test_route_tool_endpoint_translates_request_and_returns_trace_payload(monkeypatch) -> None: + user_id = uuid4() + thread_id = uuid4() + tool_id = uuid4() + settings = Settings(database_url="postgresql://app") + captured: dict[str, object] = {} + + @contextmanager + def fake_user_connection(database_url: str, current_user_id): + captured["database_url"] = database_url + captured["current_user_id"] = current_user_id + yield object() + + def fake_route_tool_invocation(store, *, user_id, request): + captured["store_type"] = type(store).__name__ + captured["user_id"] = user_id + captured["request"] = request + return { + "request": { + "thread_id": str(thread_id), + "tool_id": str(tool_id), + "action": "tool.run", + "scope": "workspace", + "domain_hint": "docs", + "risk_hint": None, + "attributes": {"channel": "chat"}, + }, + "decision": "ready", + "tool": { + "id": str(tool_id), + "tool_key": "browser.open", + "name": "Browser Open", + "description": "Open documentation pages.", + "version": "1.0.0", + "metadata_version": "tool_metadata_v0", + "active": True, + "tags": ["browser"], + "action_hints": ["tool.run"], + "scope_hints": ["workspace"], + "domain_hints": ["docs"], + "risk_hints": [], + "metadata": {"transport": "proxy"}, + "created_at": "2026-03-12T09:00:00+00:00", + }, + "reasons": [], + "summary": { + "thread_id": str(thread_id), + "tool_id": str(tool_id), + "action": "tool.run", + "scope": "workspace", + "domain_hint": "docs", + "risk_hint": None, + "decision": "ready", + "evaluated_tool_count": 1, + "active_policy_count": 1, + "consent_count": 1, + "order": ["tool_key_asc", "version_asc", "created_at_asc", "id_asc"], + }, + "trace": {"trace_id": "trace-123", "trace_event_count": 3}, + } + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "route_tool_invocation", fake_route_tool_invocation) + + response = main_module.route_tool( + main_module.RouteToolRequest( + user_id=user_id, + thread_id=thread_id, + tool_id=tool_id, + action="tool.run", + scope="workspace", + domain_hint="docs", + attributes={"channel": "chat"}, + ) + ) + + assert response.status_code == 200 + assert json.loads(response.body)["trace"] == {"trace_id": "trace-123", "trace_event_count": 3} + assert captured["database_url"] == "postgresql://app" + assert captured["current_user_id"] == user_id + assert captured["store_type"] == "ContinuityStore" + assert captured["user_id"] == user_id + assert captured["request"].thread_id == thread_id + assert captured["request"].tool_id == tool_id + assert captured["request"].action == "tool.run" + assert captured["request"].scope == "workspace" + assert captured["request"].domain_hint == "docs" + assert captured["request"].attributes == {"channel": "chat"} + + +def test_route_tool_endpoint_maps_validation_errors_to_400(monkeypatch) -> None: + user_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + def fake_route_tool_invocation(*_args, **_kwargs): + raise ToolRoutingValidationError("tool_id must reference an existing active tool owned by the user") + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "route_tool_invocation", fake_route_tool_invocation) + + response = main_module.route_tool( + main_module.RouteToolRequest( + user_id=user_id, + thread_id=uuid4(), + tool_id=uuid4(), + action="tool.run", + scope="workspace", + attributes={}, + ) + ) + + assert response.status_code == 400 + assert json.loads(response.body) == { + "detail": "tool_id must reference an existing active tool owned by the user" + } diff --git a/tests/unit/test_trace_store.py b/tests/unit/test_trace_store.py new file mode 100644 index 0000000..3e62418 --- /dev/null +++ b/tests/unit/test_trace_store.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +from typing import Any +from uuid import uuid4 + +from psycopg.types.json import Jsonb +import pytest + +from alicebot_api.store import AppendOnlyViolation, ContinuityStore, ContinuityStoreInvariantError + + +class RecordingCursor: + def __init__(self, fetchone_results: list[dict[str, Any]], fetchall_result: list[dict[str, Any]] | None = None) -> None: + self.executed: list[tuple[str, tuple[object, ...] | None]] = [] + self.fetchone_results = list(fetchone_results) + self.fetchall_result = fetchall_result or [] + + def __enter__(self) -> "RecordingCursor": + return self + + def __exit__(self, exc_type, exc, tb) -> None: + return None + + def execute(self, query: str, params: tuple[object, ...] | None = None) -> None: + self.executed.append((query, params)) + + def fetchone(self) -> dict[str, Any] | None: + if not self.fetchone_results: + return None + return self.fetchone_results.pop(0) + + def fetchall(self) -> list[dict[str, Any]]: + return self.fetchall_result + + +class RecordingConnection: + def __init__(self, cursor: RecordingCursor) -> None: + self.cursor_instance = cursor + + def cursor(self) -> RecordingCursor: + return self.cursor_instance + + +def test_trace_methods_use_expected_queries_and_payload_serialization() -> None: + user_id = uuid4() + thread_id = uuid4() + trace_id = uuid4() + payload = {"reason": "within_event_limit"} + cursor = RecordingCursor( + fetchone_results=[ + {"id": user_id, "email": "owner@example.com", "display_name": "Owner"}, + {"id": thread_id, "user_id": user_id, "title": "Thread", "agent_profile_id": "assistant_default"}, + {"id": trace_id, "user_id": user_id, "thread_id": thread_id, "kind": "context.compile"}, + { + "id": uuid4(), + "user_id": user_id, + "trace_id": trace_id, + "sequence_no": 1, + "kind": "context.include", + "payload": payload, + }, + ], + fetchall_result=[ + {"sequence_no": 1, "kind": "context.include", "payload": payload}, + ], + ) + store = ContinuityStore(RecordingConnection(cursor)) + + user = store.get_user(user_id) + thread = store.get_thread(thread_id) + trace = store.create_trace( + user_id=user_id, + thread_id=thread_id, + kind="context.compile", + compiler_version="continuity_v0", + status="completed", + limits={"max_sessions": 3, "max_events": 8}, + ) + trace_event = store.append_trace_event( + trace_id=trace_id, + sequence_no=1, + kind="context.include", + payload=payload, + ) + listed_trace_events = store.list_trace_events(trace_id) + + assert user["id"] == user_id + assert thread["id"] == thread_id + assert trace["id"] == trace_id + assert trace_event["sequence_no"] == 1 + assert listed_trace_events == [{"sequence_no": 1, "kind": "context.include", "payload": payload}] + + assert cursor.executed[0] == ( + """ + SELECT id, email, display_name, created_at + FROM users + WHERE id = %s + """, + (user_id,), + ) + assert cursor.executed[1] == ( + """ + SELECT id, user_id, title, agent_profile_id, created_at, updated_at + FROM threads + WHERE id = %s + """, + (thread_id,), + ) + create_trace_query, create_trace_params = cursor.executed[2] + assert "INSERT INTO traces" in create_trace_query + assert create_trace_params is not None + assert create_trace_params[:5] == ( + user_id, + thread_id, + "context.compile", + "continuity_v0", + "completed", + ) + assert isinstance(create_trace_params[5], Jsonb) + assert create_trace_params[5].obj == {"max_sessions": 3, "max_events": 8} + + append_trace_query, append_trace_params = cursor.executed[3] + assert "INSERT INTO trace_events" in append_trace_query + assert append_trace_params is not None + assert append_trace_params[:3] == (trace_id, 1, "context.include") + assert isinstance(append_trace_params[3], Jsonb) + assert append_trace_params[3].obj == payload + + +def test_trace_event_updates_and_deletes_are_rejected_by_contract() -> None: + store = ContinuityStore(conn=None) # type: ignore[arg-type] + + with pytest.raises(AppendOnlyViolation, match="append-only"): + store.update_trace_event("trace-event-id", {"text": "mutated"}) + + with pytest.raises(AppendOnlyViolation, match="append-only"): + store.delete_trace_event("trace-event-id") + + +def test_get_trace_raises_clear_error_when_missing() -> None: + cursor = RecordingCursor(fetchone_results=[]) + store = ContinuityStore(RecordingConnection(cursor)) + + with pytest.raises( + ContinuityStoreInvariantError, + match="get_trace did not return a row", + ): + store.get_trace(uuid4()) diff --git a/tests/unit/test_traces.py b/tests/unit/test_traces.py new file mode 100644 index 0000000..82a71d3 --- /dev/null +++ b/tests/unit/test_traces.py @@ -0,0 +1,420 @@ +from __future__ import annotations + +from contextlib import contextmanager +from datetime import UTC, datetime +import json +from uuid import UUID, uuid4 + +import pytest + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.traces import ( + TraceNotFoundError, + get_trace_record, + list_trace_event_records, + list_trace_records, +) + + +class TraceStoreStub: + def __init__(self, *, current_user_id: UUID) -> None: + self.current_user_id = current_user_id + self.base_time = datetime(2026, 3, 17, 9, 0, tzinfo=UTC) + self.traces: list[dict[str, object]] = [] + self.trace_events: list[dict[str, object]] = [] + + def add_trace( + self, + *, + trace_id: UUID, + user_id: UUID, + thread_id: UUID, + kind: str, + compiler_version: str, + status: str, + limits: dict[str, object], + created_at: datetime | None = None, + ) -> dict[str, object]: + trace = { + "id": trace_id, + "user_id": user_id, + "thread_id": thread_id, + "kind": kind, + "compiler_version": compiler_version, + "status": status, + "limits": limits, + "created_at": created_at or self.base_time, + } + self.traces.append(trace) + return trace + + def add_trace_event( + self, + *, + event_id: UUID, + user_id: UUID, + trace_id: UUID, + sequence_no: int, + kind: str, + payload: dict[str, object], + created_at: datetime | None = None, + ) -> dict[str, object]: + event = { + "id": event_id, + "user_id": user_id, + "trace_id": trace_id, + "sequence_no": sequence_no, + "kind": kind, + "payload": payload, + "created_at": created_at or self.base_time, + } + self.trace_events.append(event) + return event + + def list_trace_reviews(self) -> list[dict[str, object]]: + visible_traces = [ + trace + for trace in self.traces + if trace["user_id"] == self.current_user_id + ] + rows = [] + for trace in visible_traces: + rows.append( + { + **trace, + "trace_event_count": len( + [ + event + for event in self.trace_events + if event["user_id"] == self.current_user_id + and event["trace_id"] == trace["id"] + ] + ), + } + ) + return sorted(rows, key=lambda row: (row["created_at"], row["id"]), reverse=True) + + def get_trace_review_optional(self, trace_id: UUID) -> dict[str, object] | None: + return next((trace for trace in self.list_trace_reviews() if trace["id"] == trace_id), None) + + def list_trace_events(self, trace_id: UUID) -> list[dict[str, object]]: + visible_events = [ + event + for event in self.trace_events + if event["user_id"] == self.current_user_id and event["trace_id"] == trace_id + ] + return sorted(visible_events, key=lambda event: (event["sequence_no"], event["id"])) + + +def test_trace_review_records_preserve_deterministic_order_isolation_and_shape() -> None: + owner_id = uuid4() + intruder_id = uuid4() + first_trace_id = UUID("00000000-0000-4000-8000-000000000001") + second_trace_id = UUID("00000000-0000-4000-8000-000000000002") + hidden_trace_id = UUID("00000000-0000-4000-8000-000000000003") + owner_thread_id = uuid4() + hidden_thread_id = uuid4() + store = TraceStoreStub(current_user_id=owner_id) + + store.add_trace( + trace_id=first_trace_id, + user_id=owner_id, + thread_id=owner_thread_id, + kind="context.compile", + compiler_version="continuity_v0", + status="completed", + limits={"max_sessions": 3, "max_events": 8}, + ) + store.add_trace( + trace_id=second_trace_id, + user_id=owner_id, + thread_id=owner_thread_id, + kind="tool.proxy.execute", + compiler_version="response_generation_v0", + status="completed", + limits={"max_sessions": 1, "max_events": 2}, + ) + store.add_trace( + trace_id=hidden_trace_id, + user_id=intruder_id, + thread_id=hidden_thread_id, + kind="approval.request", + compiler_version="approval_request_v0", + status="completed", + limits={"max_sessions": 1, "max_events": 1}, + ) + + store.add_trace_event( + event_id=UUID("10000000-0000-4000-8000-000000000001"), + user_id=owner_id, + trace_id=second_trace_id, + sequence_no=2, + kind="tool.proxy.execute.summary", + payload={"approval_id": "approval-2"}, + ) + store.add_trace_event( + event_id=UUID("10000000-0000-4000-8000-000000000002"), + user_id=owner_id, + trace_id=second_trace_id, + sequence_no=1, + kind="tool.proxy.execute.request", + payload={"approval_id": "approval-2"}, + ) + store.add_trace_event( + event_id=UUID("10000000-0000-4000-8000-000000000003"), + user_id=owner_id, + trace_id=first_trace_id, + sequence_no=1, + kind="context.summary", + payload={"thread_id": str(owner_thread_id)}, + ) + store.add_trace_event( + event_id=UUID("10000000-0000-4000-8000-000000000004"), + user_id=intruder_id, + trace_id=hidden_trace_id, + sequence_no=1, + kind="approval.request.summary", + payload={"approval_id": "approval-hidden"}, + ) + + listed = list_trace_records( + store, # type: ignore[arg-type] + user_id=owner_id, + ) + detail = get_trace_record( + store, # type: ignore[arg-type] + user_id=owner_id, + trace_id=second_trace_id, + ) + events = list_trace_event_records( + store, # type: ignore[arg-type] + user_id=owner_id, + trace_id=second_trace_id, + ) + + assert listed == { + "items": [ + { + "id": str(second_trace_id), + "thread_id": str(owner_thread_id), + "kind": "tool.proxy.execute", + "compiler_version": "response_generation_v0", + "status": "completed", + "created_at": store.base_time.isoformat(), + "trace_event_count": 2, + }, + { + "id": str(first_trace_id), + "thread_id": str(owner_thread_id), + "kind": "context.compile", + "compiler_version": "continuity_v0", + "status": "completed", + "created_at": store.base_time.isoformat(), + "trace_event_count": 1, + }, + ], + "summary": { + "total_count": 2, + "order": ["created_at_desc", "id_desc"], + }, + } + assert detail == { + "trace": { + "id": str(second_trace_id), + "thread_id": str(owner_thread_id), + "kind": "tool.proxy.execute", + "compiler_version": "response_generation_v0", + "status": "completed", + "limits": {"max_sessions": 1, "max_events": 2}, + "created_at": store.base_time.isoformat(), + "trace_event_count": 2, + } + } + assert events == { + "items": [ + { + "id": "10000000-0000-4000-8000-000000000002", + "trace_id": str(second_trace_id), + "sequence_no": 1, + "kind": "tool.proxy.execute.request", + "payload": {"approval_id": "approval-2"}, + "created_at": store.base_time.isoformat(), + }, + { + "id": "10000000-0000-4000-8000-000000000001", + "trace_id": str(second_trace_id), + "sequence_no": 2, + "kind": "tool.proxy.execute.summary", + "payload": {"approval_id": "approval-2"}, + "created_at": store.base_time.isoformat(), + }, + ], + "summary": { + "trace_id": str(second_trace_id), + "total_count": 2, + "order": ["sequence_no_asc", "id_asc"], + }, + } + + +def test_trace_review_records_raise_not_found_for_invisible_traces() -> None: + owner_id = uuid4() + intruder_id = uuid4() + hidden_trace_id = uuid4() + store = TraceStoreStub(current_user_id=intruder_id) + store.add_trace( + trace_id=hidden_trace_id, + user_id=owner_id, + thread_id=uuid4(), + kind="context.compile", + compiler_version="continuity_v0", + status="completed", + limits={"max_sessions": 3}, + ) + store.add_trace_event( + event_id=uuid4(), + user_id=owner_id, + trace_id=hidden_trace_id, + sequence_no=1, + kind="context.summary", + payload={"scope": "owner-only"}, + ) + + listed = list_trace_records( + store, # type: ignore[arg-type] + user_id=intruder_id, + ) + + assert listed == { + "items": [], + "summary": { + "total_count": 0, + "order": ["created_at_desc", "id_desc"], + }, + } + with pytest.raises(TraceNotFoundError, match=f"trace {hidden_trace_id} was not found"): + get_trace_record( + store, # type: ignore[arg-type] + user_id=intruder_id, + trace_id=hidden_trace_id, + ) + with pytest.raises(TraceNotFoundError, match=f"trace {hidden_trace_id} was not found"): + list_trace_event_records( + store, # type: ignore[arg-type] + user_id=intruder_id, + trace_id=hidden_trace_id, + ) + + +def test_list_traces_endpoint_returns_payload(monkeypatch) -> None: + user_id = uuid4() + settings = Settings(database_url="postgresql://app") + captured: dict[str, object] = {} + + @contextmanager + def fake_user_connection(database_url: str, current_user_id): + captured["database_url"] = database_url + captured["current_user_id"] = current_user_id + yield object() + + def fake_list_trace_records(store, *, user_id): + captured["store_type"] = type(store).__name__ + captured["user_id"] = user_id + return { + "items": [], + "summary": {"total_count": 0, "order": ["created_at_desc", "id_desc"]}, + } + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "list_trace_records", fake_list_trace_records) + + response = main_module.list_traces(user_id) + + assert response.status_code == 200 + assert json.loads(response.body) == { + "items": [], + "summary": {"total_count": 0, "order": ["created_at_desc", "id_desc"]}, + } + assert captured == { + "database_url": "postgresql://app", + "current_user_id": user_id, + "store_type": "ContinuityStore", + "user_id": user_id, + } + + +def test_get_trace_endpoint_maps_not_found_to_404(monkeypatch) -> None: + user_id = uuid4() + trace_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + def fake_get_trace_record(*_args, **_kwargs): + raise TraceNotFoundError(f"trace {trace_id} was not found") + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "get_trace_record", fake_get_trace_record) + + response = main_module.get_trace(trace_id, user_id) + + assert response.status_code == 404 + assert json.loads(response.body) == {"detail": f"trace {trace_id} was not found"} + + +def test_list_trace_events_endpoint_returns_payload_and_maps_not_found(monkeypatch) -> None: + user_id = uuid4() + trace_id = uuid4() + settings = Settings(database_url="postgresql://app") + captured: dict[str, object] = {} + + @contextmanager + def fake_user_connection(database_url: str, current_user_id): + captured["database_url"] = database_url + captured["current_user_id"] = current_user_id + yield object() + + def fake_list_trace_event_records(store, *, user_id, trace_id): + captured["store_type"] = type(store).__name__ + captured["user_id"] = user_id + captured["trace_id"] = trace_id + if captured.get("fail"): + raise TraceNotFoundError(f"trace {trace_id} was not found") + return { + "items": [], + "summary": { + "trace_id": str(trace_id), + "total_count": 0, + "order": ["sequence_no_asc", "id_asc"], + }, + } + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "list_trace_event_records", fake_list_trace_event_records) + + success_response = main_module.list_trace_events(trace_id, user_id) + captured["fail"] = True + not_found_response = main_module.list_trace_events(trace_id, user_id) + + assert success_response.status_code == 200 + assert json.loads(success_response.body) == { + "items": [], + "summary": { + "trace_id": str(trace_id), + "total_count": 0, + "order": ["sequence_no_asc", "id_asc"], + }, + } + assert not_found_response.status_code == 404 + assert json.loads(not_found_response.body) == {"detail": f"trace {trace_id} was not found"} + assert captured["database_url"] == "postgresql://app" + assert captured["current_user_id"] == user_id + assert captured["store_type"] == "ContinuityStore" + assert captured["user_id"] == user_id + assert captured["trace_id"] == trace_id diff --git a/tests/unit/test_worker_main.py b/tests/unit/test_worker_main.py new file mode 100644 index 0000000..9a3c948 --- /dev/null +++ b/tests/unit/test_worker_main.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import logging +import os +from pathlib import Path +import subprocess +import sys +from contextlib import contextmanager +from uuid import uuid4 + +import workers.alicebot_worker.main as main_module +from apps.api.src.alicebot_api.config import Settings +from workers.alicebot_worker.task_runs import WorkerTickOutcome + + +def test_run_logs_skip_message_when_worker_user_id_is_missing(caplog, monkeypatch) -> None: + monkeypatch.delenv("ALICEBOT_WORKER_USER_ID", raising=False) + + with caplog.at_level(logging.INFO, logger="alicebot.worker"): + main_module.run() + + assert caplog.messages == [ + "Worker tick skipped because ALICEBOT_WORKER_USER_ID is not configured.", + ] + + +def test_run_ticks_one_task_run_when_worker_user_id_is_configured(caplog, monkeypatch) -> None: + worker_user_id = uuid4() + monkeypatch.setenv("ALICEBOT_WORKER_USER_ID", str(worker_user_id)) + captured: dict[str, object] = {} + + @contextmanager + def fake_user_connection(database_url: str, current_user_id): + captured["database_url"] = database_url + captured["current_user_id"] = current_user_id + yield object() + + def fake_acquire_and_tick_one_task_run(store, *, user_id): + captured["store_type"] = type(store).__name__ + captured["user_id"] = user_id + return WorkerTickOutcome( + task_run_id="run-1", + previous_status="queued", + status="running", + stop_reason=None, + retry_posture="none", + retry_count=0, + retry_cap=2, + failure_class=None, + ) + + monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url="postgresql://app")) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "acquire_and_tick_one_task_run", fake_acquire_and_tick_one_task_run) + + with caplog.at_level(logging.INFO, logger="alicebot.worker"): + main_module.run() + + assert captured == { + "database_url": "postgresql://app", + "current_user_id": worker_user_id, + "store_type": "ContinuityStore", + "user_id": worker_user_id, + } + assert caplog.messages == [ + "Worker ticked task run run-1 from queued to running (stop_reason=None failure_class=None retry=0/2 posture=none).", + ] + + +def test_module_entrypoint_logs_skip_message_when_worker_user_id_is_missing() -> None: + repo_root = Path(__file__).resolve().parents[2] + env = os.environ.copy() + pythonpath_entries = [str(repo_root / "apps" / "api" / "src"), str(repo_root / "workers")] + existing_pythonpath = env.get("PYTHONPATH") + if existing_pythonpath: + pythonpath_entries.append(existing_pythonpath) + env["PYTHONPATH"] = os.pathsep.join(pythonpath_entries) + env.pop("ALICEBOT_WORKER_USER_ID", None) + + result = subprocess.run( + [sys.executable, "-m", "alicebot_worker.main"], + cwd=repo_root, + env=env, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0 + assert "Worker tick skipped because ALICEBOT_WORKER_USER_ID is not configured." in result.stderr diff --git a/tests/unit/test_workspaces.py b/tests/unit/test_workspaces.py new file mode 100644 index 0000000..67cb1bc --- /dev/null +++ b/tests/unit/test_workspaces.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from pathlib import Path +from uuid import UUID, uuid4 + +import pytest + +from alicebot_api.config import Settings +from alicebot_api.contracts import TaskWorkspaceCreateInput +from alicebot_api.tasks import TaskNotFoundError +from alicebot_api.workspaces import ( + TaskWorkspaceAlreadyExistsError, + TaskWorkspaceNotFoundError, + TaskWorkspaceProvisioningError, + build_task_workspace_path, + create_task_workspace_record, + ensure_workspace_path_is_rooted, + get_task_workspace_record, + list_task_workspace_records, + serialize_task_workspace_row, +) + + +class WorkspaceStoreStub: + def __init__(self) -> None: + self.base_time = datetime(2026, 3, 13, 10, 0, tzinfo=UTC) + self.tasks: list[dict[str, object]] = [] + self.workspaces: list[dict[str, object]] = [] + self.locked_task_ids: list[UUID] = [] + + def create_task(self, *, task_id: UUID, user_id: UUID) -> None: + self.tasks.append( + { + "id": task_id, + "user_id": user_id, + "thread_id": uuid4(), + "tool_id": uuid4(), + "status": "approved", + "request": {}, + "tool": {}, + "latest_approval_id": None, + "latest_execution_id": None, + "created_at": self.base_time, + "updated_at": self.base_time, + } + ) + + def get_task_optional(self, task_id: UUID) -> dict[str, object] | None: + return next((task for task in self.tasks if task["id"] == task_id), None) + + def lock_task_workspaces(self, task_id: UUID) -> None: + self.locked_task_ids.append(task_id) + + def get_active_task_workspace_for_task_optional(self, task_id: UUID) -> dict[str, object] | None: + return next( + ( + workspace + for workspace in self.workspaces + if workspace["task_id"] == task_id and workspace["status"] == "active" + ), + None, + ) + + def create_task_workspace( + self, + *, + task_id: UUID, + status: str, + local_path: str, + ) -> dict[str, object]: + workspace = { + "id": uuid4(), + "user_id": self.tasks[0]["user_id"], + "task_id": task_id, + "status": status, + "local_path": local_path, + "created_at": self.base_time + timedelta(minutes=len(self.workspaces)), + "updated_at": self.base_time + timedelta(minutes=len(self.workspaces)), + } + self.workspaces.append(workspace) + return workspace + + def list_task_workspaces(self) -> list[dict[str, object]]: + return sorted(self.workspaces, key=lambda workspace: (workspace["created_at"], workspace["id"])) + + def get_task_workspace_optional(self, task_workspace_id: UUID) -> dict[str, object] | None: + return next((workspace for workspace in self.workspaces if workspace["id"] == task_workspace_id), None) + + +def test_build_task_workspace_path_is_deterministic() -> None: + user_id = UUID("00000000-0000-0000-0000-000000000111") + task_id = UUID("00000000-0000-0000-0000-000000000222") + root = Path("/tmp/alicebot/task-workspaces") + + path = build_task_workspace_path( + workspace_root=root, + user_id=user_id, + task_id=task_id, + ) + + assert path == Path("/tmp/alicebot/task-workspaces") / str(user_id) / str(task_id) + + +def test_ensure_workspace_path_is_rooted_rejects_escape() -> None: + with pytest.raises(TaskWorkspaceProvisioningError, match="escapes configured root"): + ensure_workspace_path_is_rooted( + workspace_root=Path("/tmp/alicebot/task-workspaces"), + workspace_path=Path("/tmp/alicebot/task-workspaces/../escape"), + ) + + +def test_create_task_workspace_record_provisions_directory_and_returns_record(tmp_path) -> None: + store = WorkspaceStoreStub() + user_id = uuid4() + task_id = uuid4() + store.create_task(task_id=task_id, user_id=user_id) + settings = Settings(task_workspace_root=str(tmp_path / "workspaces")) + + response = create_task_workspace_record( + store, + settings=settings, + user_id=user_id, + request=TaskWorkspaceCreateInput(task_id=task_id, status="active"), + ) + + expected_path = tmp_path / "workspaces" / str(user_id) / str(task_id) + assert response == { + "workspace": { + "id": response["workspace"]["id"], + "task_id": str(task_id), + "status": "active", + "local_path": str(expected_path.resolve()), + "created_at": "2026-03-13T10:00:00+00:00", + "updated_at": "2026-03-13T10:00:00+00:00", + } + } + assert expected_path.is_dir() + assert store.locked_task_ids == [task_id] + + +def test_create_task_workspace_record_rejects_duplicate_active_workspace(tmp_path) -> None: + store = WorkspaceStoreStub() + user_id = uuid4() + task_id = uuid4() + store.create_task(task_id=task_id, user_id=user_id) + settings = Settings(task_workspace_root=str(tmp_path / "workspaces")) + create_task_workspace_record( + store, + settings=settings, + user_id=user_id, + request=TaskWorkspaceCreateInput(task_id=task_id, status="active"), + ) + + with pytest.raises(TaskWorkspaceAlreadyExistsError, match=f"task {task_id} already has active workspace"): + create_task_workspace_record( + store, + settings=settings, + user_id=user_id, + request=TaskWorkspaceCreateInput(task_id=task_id, status="active"), + ) + + +def test_create_task_workspace_record_requires_visible_task(tmp_path) -> None: + store = WorkspaceStoreStub() + + with pytest.raises(TaskNotFoundError, match="was not found"): + create_task_workspace_record( + store, + settings=Settings(task_workspace_root=str(tmp_path / "workspaces")), + user_id=uuid4(), + request=TaskWorkspaceCreateInput(task_id=uuid4(), status="active"), + ) + + +def test_list_and_get_task_workspace_records_are_deterministic() -> None: + store = WorkspaceStoreStub() + user_id = uuid4() + task_id = uuid4() + store.create_task(task_id=task_id, user_id=user_id) + workspace = store.create_task_workspace( + task_id=task_id, + status="active", + local_path="/tmp/alicebot/task-workspaces/user/task", + ) + + assert list_task_workspace_records(store, user_id=user_id) == { + "items": [serialize_task_workspace_row(workspace)], + "summary": { + "total_count": 1, + "order": ["created_at_asc", "id_asc"], + }, + } + assert get_task_workspace_record( + store, + user_id=user_id, + task_workspace_id=workspace["id"], + ) == {"workspace": serialize_task_workspace_row(workspace)} + + +def test_get_task_workspace_record_raises_when_workspace_is_missing() -> None: + with pytest.raises(TaskWorkspaceNotFoundError, match="was not found"): + get_task_workspace_record( + WorkspaceStoreStub(), + user_id=uuid4(), + task_workspace_id=uuid4(), + ) diff --git a/tests/unit/test_workspaces_main.py b/tests/unit/test_workspaces_main.py new file mode 100644 index 0000000..b5f19ff --- /dev/null +++ b/tests/unit/test_workspaces_main.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import json +from contextlib import contextmanager +from uuid import uuid4 + +import apps.api.src.alicebot_api.main as main_module +from apps.api.src.alicebot_api.config import Settings +from alicebot_api.tasks import TaskNotFoundError +from alicebot_api.workspaces import TaskWorkspaceAlreadyExistsError, TaskWorkspaceNotFoundError + + +def test_list_task_workspaces_endpoint_returns_payload(monkeypatch) -> None: + user_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr( + main_module, + "list_task_workspace_records", + lambda *_args, **_kwargs: { + "items": [], + "summary": {"total_count": 0, "order": ["created_at_asc", "id_asc"]}, + }, + ) + + response = main_module.list_task_workspaces(user_id) + + assert response.status_code == 200 + assert json.loads(response.body) == { + "items": [], + "summary": {"total_count": 0, "order": ["created_at_asc", "id_asc"]}, + } + + +def test_get_task_workspace_endpoint_maps_not_found_to_404(monkeypatch) -> None: + user_id = uuid4() + task_workspace_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + def fake_get_task_workspace_record(*_args, **_kwargs): + raise TaskWorkspaceNotFoundError(f"task workspace {task_workspace_id} was not found") + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "get_task_workspace_record", fake_get_task_workspace_record) + + response = main_module.get_task_workspace(task_workspace_id, user_id) + + assert response.status_code == 404 + assert json.loads(response.body) == {"detail": f"task workspace {task_workspace_id} was not found"} + + +def test_create_task_workspace_endpoint_maps_task_not_found_to_404(monkeypatch) -> None: + task_id = uuid4() + user_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + def fake_create_task_workspace_record(*_args, **_kwargs): + raise TaskNotFoundError(f"task {task_id} was not found") + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "create_task_workspace_record", fake_create_task_workspace_record) + + response = main_module.create_task_workspace( + task_id, + main_module.CreateTaskWorkspaceRequest(user_id=user_id), + ) + + assert response.status_code == 404 + assert json.loads(response.body) == {"detail": f"task {task_id} was not found"} + + +def test_create_task_workspace_endpoint_maps_duplicate_to_409(monkeypatch) -> None: + task_id = uuid4() + user_id = uuid4() + settings = Settings(database_url="postgresql://app") + + @contextmanager + def fake_user_connection(*_args, **_kwargs): + yield object() + + def fake_create_task_workspace_record(*_args, **_kwargs): + raise TaskWorkspaceAlreadyExistsError(f"task {task_id} already has active workspace workspace-123") + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "create_task_workspace_record", fake_create_task_workspace_record) + + response = main_module.create_task_workspace( + task_id, + main_module.CreateTaskWorkspaceRequest(user_id=user_id), + ) + + assert response.status_code == 409 + assert json.loads(response.body) == { + "detail": f"task {task_id} already has active workspace workspace-123" + } diff --git a/workers/.gitkeep b/workers/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/workers/.gitkeep @@ -0,0 +1 @@ + diff --git a/workers/alicebot_worker/__init__.py b/workers/alicebot_worker/__init__.py new file mode 100644 index 0000000..462d476 --- /dev/null +++ b/workers/alicebot_worker/__init__.py @@ -0,0 +1,2 @@ +"""Worker scaffold for future asynchronous jobs.""" + diff --git a/workers/alicebot_worker/main.py b/workers/alicebot_worker/main.py new file mode 100644 index 0000000..d8451ba --- /dev/null +++ b/workers/alicebot_worker/main.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import logging +import os +from uuid import UUID + +from alicebot_api.config import get_settings +from alicebot_api.db import user_connection +from alicebot_api.store import ContinuityStore + +from alicebot_worker.task_runs import acquire_and_tick_one_task_run + + +LOGGER = logging.getLogger("alicebot.worker") +WORKER_USER_ID_ENV = "ALICEBOT_WORKER_USER_ID" + + +def _read_worker_user_id() -> UUID | None: + raw_value = os.getenv(WORKER_USER_ID_ENV) + if not raw_value: + LOGGER.info( + "Worker tick skipped because %s is not configured.", + WORKER_USER_ID_ENV, + ) + return None + + try: + return UUID(raw_value) + except ValueError: + LOGGER.error( + "Worker tick skipped because %s is not a valid UUID.", + WORKER_USER_ID_ENV, + ) + return None + + +def run() -> None: + worker_user_id = _read_worker_user_id() + if worker_user_id is None: + return + + settings = get_settings() + with user_connection(settings.database_url, worker_user_id) as conn: + outcome = acquire_and_tick_one_task_run( + ContinuityStore(conn), + user_id=worker_user_id, + ) + + if outcome is None: + LOGGER.info("Worker tick completed with no runnable task runs.") + return + + LOGGER.info( + ( + "Worker ticked task run %s from %s to %s " + "(stop_reason=%s failure_class=%s retry=%s/%s posture=%s)." + ), + outcome.task_run_id, + outcome.previous_status, + outcome.status, + outcome.stop_reason, + outcome.failure_class, + outcome.retry_count, + outcome.retry_cap, + outcome.retry_posture, + ) + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + run() diff --git a/workers/alicebot_worker/task_runs.py b/workers/alicebot_worker/task_runs.py new file mode 100644 index 0000000..3fbc51a --- /dev/null +++ b/workers/alicebot_worker/task_runs.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from dataclasses import dataclass +from uuid import UUID + +from alicebot_api.contracts import TaskRunTickInput +from alicebot_api.store import ContinuityStore +from alicebot_api.task_runs import ( + TaskRunNotFoundError, + TaskRunTransitionError, + tick_task_run_record, +) +from alicebot_worker.tool_execution import execute_task_run_if_ready + + +@dataclass(frozen=True, slots=True) +class WorkerTickOutcome: + task_run_id: str + previous_status: str + status: str + stop_reason: str | None + retry_posture: str + retry_count: int + retry_cap: int + failure_class: str | None + + +def acquire_and_tick_one_task_run( + store: ContinuityStore, + *, + user_id: UUID, +) -> WorkerTickOutcome | None: + row = store.acquire_next_task_run_optional() + if row is None: + return None + + task_run_id = row["id"] + execution_outcome = execute_task_run_if_ready( + store, + user_id=user_id, + task_run=row, + ) + if execution_outcome is not None: + return WorkerTickOutcome( + task_run_id=execution_outcome.task_run_id, + previous_status=str(row["status"]), + status=execution_outcome.status, + stop_reason=execution_outcome.stop_reason, + retry_posture=execution_outcome.retry_posture, + retry_count=execution_outcome.retry_count, + retry_cap=execution_outcome.retry_cap, + failure_class=execution_outcome.failure_class, + ) + + try: + payload = tick_task_run_record( + store, + user_id=user_id, + request=TaskRunTickInput(task_run_id=task_run_id), + ) + except (TaskRunNotFoundError, TaskRunTransitionError): + return None + + task_run = payload["task_run"] + return WorkerTickOutcome( + task_run_id=task_run["id"], + previous_status=payload["previous_status"], + status=task_run["status"], + stop_reason=task_run["stop_reason"], + retry_posture=task_run["retry_posture"], + retry_count=task_run["retry_count"], + retry_cap=task_run["retry_cap"], + failure_class=task_run["failure_class"], + ) diff --git a/workers/alicebot_worker/tool_execution.py b/workers/alicebot_worker/tool_execution.py new file mode 100644 index 0000000..9fbd556 --- /dev/null +++ b/workers/alicebot_worker/tool_execution.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import cast +from uuid import UUID + +from alicebot_api.approvals import ApprovalNotFoundError +from alicebot_api.contracts import ProxyExecutionRequestInput +from alicebot_api.proxy_execution import ( + ProxyExecutionApprovalStateError, + ProxyExecutionHandlerNotFoundError, + ProxyExecutionIdempotencyError, + execute_approved_proxy_request, +) +from alicebot_api.store import ContinuityStore, TaskRunRow +from alicebot_api.task_runs import mark_task_run_failed + + +@dataclass(frozen=True, slots=True) +class WorkerExecutionOutcome: + task_run_id: str + status: str + stop_reason: str | None + retry_posture: str + retry_count: int + retry_cap: int + failure_class: str | None + + +def _worker_outcome_from_task_run(task_run: TaskRunRow) -> WorkerExecutionOutcome: + return WorkerExecutionOutcome( + task_run_id=str(task_run["id"]), + status=str(task_run["status"]), + stop_reason=None if task_run["stop_reason"] is None else str(task_run["stop_reason"]), + retry_posture=str(task_run["retry_posture"]), + retry_count=int(task_run["retry_count"]), + retry_cap=int(task_run["retry_cap"]), + failure_class=None if task_run["failure_class"] is None else str(task_run["failure_class"]), + ) + + +def execute_task_run_if_ready( + store: ContinuityStore, + *, + user_id: UUID, + task_run: TaskRunRow, +) -> WorkerExecutionOutcome | None: + task = store.get_task_optional(task_run["task_id"]) + if task is None: + return None + + latest_approval_id = task.get("latest_approval_id") + if task["status"] != "approved" or latest_approval_id is None or task.get("latest_execution_id") is not None: + return None + + approval = store.get_approval_optional(latest_approval_id) + if approval is None or approval["status"] != "approved": + return None + + try: + execute_approved_proxy_request( + store, + user_id=user_id, + request=ProxyExecutionRequestInput( + approval_id=latest_approval_id, + task_run_id=task_run["id"], + ), + ) + except ProxyExecutionHandlerNotFoundError: + refreshed = store.get_task_run_optional(task_run["id"]) + if refreshed is not None: + return _worker_outcome_from_task_run(refreshed) + failure = mark_task_run_failed( + store, + user_id=user_id, + task_run_id=task_run["id"], + stop_reason="policy_blocked", + failure_class="policy", + source="worker_execute_missing_handler", + ) + if failure is None: + return None + return _worker_outcome_from_task_run(cast(TaskRunRow, failure["task_run"])) + except ( + ApprovalNotFoundError, + ProxyExecutionApprovalStateError, + ProxyExecutionIdempotencyError, + ): + failure = mark_task_run_failed( + store, + user_id=user_id, + task_run_id=task_run["id"], + stop_reason="fatal_error", + failure_class="transient", + source="worker_execute_exception", + ) + if failure is None: + return None + return _worker_outcome_from_task_run(cast(TaskRunRow, failure["task_run"])) + + refreshed = store.get_task_run_optional(task_run["id"]) + if refreshed is None: + return None + + return _worker_outcome_from_task_run(refreshed)